b1a213f3c63e49c088fa64ab2cac333006b1add8
[be.git] / libbe / storage / http.py
1 # Copyright (C) 2010-2012 Chris Ball <cjb@laptop.org>
2 #                         W. Trevor King <wking@drexel.edu>
3 #
4 # This file is part of Bugs Everywhere.
5 #
6 # Bugs Everywhere is free software: you can redistribute it and/or modify it
7 # under the terms of the GNU General Public License as published by the Free
8 # Software Foundation, either version 2 of the License, or (at your option) any
9 # later version.
10 #
11 # Bugs Everywhere is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 # FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
14 # more details.
15 #
16 # You should have received a copy of the GNU General Public License along with
17 # Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
18
19 """Define an HTTP-based :class:`~libbe.storage.base.VersionedStorage`
20 implementation.
21
22 See Also
23 --------
24 :mod:`libbe.command.serve_storage` : the associated server
25 """
26
27 from __future__ import absolute_import
28 import sys
29 import urllib
30 import urlparse
31
32 import libbe
33 import libbe.version
34 import libbe.util.http
35 from libbe.util.http import HTTP_VALID, HTTP_USER_ERROR
36 from . import base
37
38 from libbe import TESTING
39
40 if TESTING == True:
41     import copy
42     import doctest
43     import StringIO
44     import unittest
45
46     import libbe.bugdir
47     import libbe.command.serve_storage
48     import libbe.util.http
49     import libbe.util.wsgi
50
51
52 class HTTP (base.VersionedStorage):
53     """:class:`~libbe.storage.base.VersionedStorage` implementation over
54     HTTP.
55
56     Uses GET to retrieve information and POST to set information.
57     """
58     name = 'HTTP'
59     user_agent = 'BE-HTTP-Storage'
60
61     def __init__(self, repo, *args, **kwargs):
62         repo,self.uname,self.password = self.parse_repo(repo)
63         base.VersionedStorage.__init__(self, repo, *args, **kwargs)
64
65     def parse_repo(self, repo):
66         """Grab username and password (if any) from the repo URL.
67
68         Examples
69         --------
70
71         >>> s = HTTP('http://host.com/path/to/repo')
72         >>> s.repo
73         'http://host.com/path/to/repo'
74         >>> s.uname == None
75         True
76         >>> s.password == None
77         True
78         >>> s.parse_repo('http://joe:secret@host.com/path/to/repo')
79         ('http://host.com/path/to/repo', 'joe', 'secret')
80         """
81         scheme,netloc,path,params,query,fragment = urlparse.urlparse(repo)
82         parts = netloc.split('@', 1)
83         if len(parts) == 2:
84             uname,password = parts[0].split(':')
85             repo = urlparse.urlunparse(
86                 (scheme, parts[1], path, params, query, fragment))
87         else:
88             uname,password = (None, None)
89         return (repo, uname, password)
90
91     def get_post_url(self, url, get=True, data_dict=None, headers=[]):
92         if self.uname != None and self.password != None:
93             headers.append(('Authorization','Basic %s' % \
94                 ('%s:%s' % (self.uname, self.password)).encode('base64')))
95         return libbe.util.http.get_post_url(
96             url, get, data_dict=data_dict, headers=headers,
97             agent=self.user_agent)
98
99     def storage_version(self, revision=None):
100         """Return the storage format for this backend."""
101         return libbe.storage.STORAGE_VERSION
102
103     def _init(self):
104         """Create a new storage repository."""
105         raise base.NotSupported(
106             'init', 'Cannot initialize this repository format.')
107
108     def _destroy(self):
109         """Remove the storage repository."""
110         raise base.NotSupported(
111             'destroy', 'Cannot destroy this repository format.')
112
113     def _connect(self):
114         self.check_storage_version()
115
116     def _disconnect(self):
117         pass
118
119     def _add(self, id, parent=None, directory=False):
120         url = urlparse.urljoin(self.repo, 'add')
121         page,final_url,info = self.get_post_url(
122             url, get=False,
123             data_dict={'id':id, 'parent':parent, 'directory':directory})
124
125     def _exists(self, id, revision=None):
126         url = urlparse.urljoin(self.repo, 'exists')
127         page,final_url,info = self.get_post_url(
128             url, get=True,
129             data_dict={'id':id, 'revision':revision})
130         if page == 'True':
131             return True
132         return False
133
134     def _remove(self, id):
135         url = urlparse.urljoin(self.repo, 'remove')
136         page,final_url,info = self.get_post_url(
137             url, get=False,
138             data_dict={'id':id, 'recursive':False})
139
140     def _recursive_remove(self, id):
141         url = urlparse.urljoin(self.repo, 'remove')
142         page,final_url,info = self.get_post_url(
143             url, get=False,
144             data_dict={'id':id, 'recursive':True})
145
146     def _ancestors(self, id=None, revision=None):
147         url = urlparse.urljoin(self.repo, 'ancestors')
148         page,final_url,info = self.get_post_url(
149             url, get=True,
150             data_dict={'id':id, 'revision':revision})
151         return page.strip('\n').splitlines()
152
153     def _children(self, id=None, revision=None):
154         url = urlparse.urljoin(self.repo, 'children')
155         page,final_url,info = self.get_post_url(
156             url, get=True,
157             data_dict={'id':id, 'revision':revision})
158         return page.strip('\n').splitlines()
159
160     def _get(self, id, default=base.InvalidObject, revision=None):
161         url = urlparse.urljoin(self.repo, '/'.join(['get', id]))
162         try:
163             page,final_url,info = self.get_post_url(
164                 url, get=True,
165                 data_dict={'revision':revision})
166         except libbe.util.http.HTTPError, e:
167             if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID):
168                 raise
169             elif default == base.InvalidObject:
170                 raise base.InvalidID(id)
171             return default
172         version = info['X-BE-Version']
173         if version != libbe.storage.STORAGE_VERSION:
174             raise base.InvalidStorageVersion(
175                 version, libbe.storage.STORAGE_VERSION)
176         return page
177
178     def _set(self, id, value):
179         url = urlparse.urljoin(self.repo, '/'.join(['set', id]))
180         try:
181             page,final_url,info = self.get_post_url(
182                 url, get=False,
183                 data_dict={'value':value})
184         except libbe.util.http.HTTPError, e:
185             if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID):
186                 raise
187             if e.error.code == HTTP_USER_ERROR \
188                     and not 'InvalidID' in str(e.error):
189                 raise base.InvalidDirectory(
190                     'Directory %s cannot have data' % id)
191             raise base.InvalidID(id)
192
193     def _commit(self, summary, body=None, allow_empty=False):
194         url = urlparse.urljoin(self.repo, 'commit')
195         try:
196             page,final_url,info = self.get_post_url(
197                 url, get=False,
198                 data_dict={'summary':summary, 'body':body,
199                            'allow_empty':allow_empty})
200         except libbe.util.http.HTTPError, e:
201             if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID):
202                 raise
203             if e.error.code == HTTP_USER_ERROR:
204                 raise base.EmptyCommit
205             raise base.InvalidID(id)
206         return page.rstrip('\n')
207
208     def revision_id(self, index=None):
209         """Return the name of the <index>th revision.
210
211         The choice of which branch to follow when crossing
212         branches/merges is not defined.  Revision indices start at 1;
213         ID 0 is the blank repository.
214
215         Return None if index==None.
216
217         Raises
218         ------
219         InvalidRevision
220           If the specified revision does not exist.
221         """
222         if index == None:
223             return None
224         try:
225             if int(index) != index:
226                 raise base.InvalidRevision(index)
227         except ValueError:
228             raise base.InvalidRevision(index)
229         url = urlparse.urljoin(self.repo, 'revision-id')
230         try:
231             page,final_url,info = self.get_post_url(
232                 url, get=True,
233                 data_dict={'index':index})
234         except libbe.util.http.HTTPError, e:
235             if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID):
236                 raise
237             if e.error.code == HTTP_USER_ERROR:
238                 raise base.InvalidRevision(index)
239             raise base.InvalidID(id)
240         return page.rstrip('\n')
241
242     def changed(self, revision=None):
243         url = urlparse.urljoin(self.repo, 'changed')
244         page,final_url,info = self.get_post_url(
245             url, get=True,
246             data_dict={'revision':revision})
247         lines = page.strip('\n')
248         new,mod,rem = [p.splitlines() for p in page.split('\n\n')]
249         return (new, mod, rem)
250
251     def check_storage_version(self):
252         version = self.storage_version()
253         if version != libbe.storage.STORAGE_VERSION:
254             raise base.InvalidStorageVersion(
255                 version, libbe.storage.STORAGE_VERSION)
256
257     def storage_version(self, revision=None):
258         url = urlparse.urljoin(self.repo, 'version')
259         page,final_url,info = self.get_post_url(
260             url, get=True, data_dict={'revision':revision})
261         return page.rstrip('\n')
262
263 if TESTING == True:
264     class TestingHTTP (HTTP):
265         name = 'TestingHTTP'
266         def __init__(self, repo, *args, **kwargs):
267             self._storage_backend = base.VersionedStorage(repo)
268             app = libbe.command.serve_storage.ServerApp(
269                 storage=self._storage_backend)
270             self.app = libbe.util.wsgi.BEExceptionApp(app=app)
271             HTTP.__init__(self, repo='http://localhost:8000/', *args, **kwargs)
272             self.intitialized = False
273             # duplicated from libbe.util.wsgi.WSGITestCase
274             self.default_environ = {
275                 'REQUEST_METHOD': 'GET', # 'POST', 'HEAD'
276                 'REMOTE_ADDR': '192.168.0.123',
277                 'SCRIPT_NAME':'',
278                 'PATH_INFO': '',
279                 #'QUERY_STRING':'',   # may be empty or absent
280                 #'CONTENT_TYPE':'',   # may be empty or absent
281                 #'CONTENT_LENGTH':'', # may be empty or absent
282                 'SERVER_NAME':'example.com',
283                 'SERVER_PORT':'80',
284                 'SERVER_PROTOCOL':'HTTP/1.1',
285                 'wsgi.version':(1,0),
286                 'wsgi.url_scheme':'http',
287                 'wsgi.input':StringIO.StringIO(),
288                 'wsgi.errors':StringIO.StringIO(),
289                 'wsgi.multithread':False,
290                 'wsgi.multiprocess':False,
291                 'wsgi.run_once':False,
292                 }
293         def getURL(self, app, path='/', method='GET', data=None,
294                    scheme='http', environ={}):
295             # duplicated from libbe.util.wsgi.WSGITestCase
296             env = copy.copy(self.default_environ)
297             env['PATH_INFO'] = path
298             env['REQUEST_METHOD'] = method
299             env['scheme'] = scheme
300             if data != None:
301                 enc_data = urllib.urlencode(data)
302                 if method == 'POST':
303                     env['CONTENT_LENGTH'] = len(enc_data)
304                     env['wsgi.input'] = StringIO.StringIO(enc_data)
305                 else:
306                     assert method in ['GET', 'HEAD'], method
307                     env['QUERY_STRING'] = enc_data
308             for key,value in environ.items():
309                 env[key] = value
310             try:
311                 result = app(env, self.start_response)
312             except libbe.util.wsgi.HandlerError as e:
313                 raise libbe.util.http.HTTPError(error=e, url=path, msg=str(e))
314             return ''.join(result)
315         def start_response(self, status, response_headers, exc_info=None):
316             self.status = status
317             self.response_headers = response_headers
318             self.exc_info = exc_info
319         def get_post_url(self, url, get=True, data_dict=None, headers=[]):
320             if get == True:
321                 method = 'GET'
322             else:
323                 method = 'POST'
324             scheme,netloc,path,params,query,fragment = urlparse.urlparse(url)
325             environ = {}
326             for header_name,header_value in headers:
327                 environ['HTTP_%s' % header_name] = header_value
328             output = self.getURL(
329                 self.app, path, method, data_dict, scheme, environ)
330             if self.status != '200 OK':
331                 class __estr (object):
332                     def __init__(self, string):
333                         self.string = string
334                         self.code = int(string.split()[0])
335                     def __str__(self):
336                         return self.string
337                 error = __estr(self.status)
338                 raise libbe.util.http.HTTPError(
339                     error=error, url=url, msg=output)
340             info = dict(self.response_headers)
341             return (output, url, info)
342         def _init(self):
343             try:
344                 HTTP._init(self)
345                 raise AssertionError
346             except base.NotSupported:
347                 pass
348             self._storage_backend._init()
349         def _destroy(self):
350             try:
351                 HTTP._destroy(self)
352                 raise AssertionError
353             except base.NotSupported:
354                 pass
355             self._storage_backend._destroy()
356         def _connect(self):
357             self._storage_backend._connect()
358             HTTP._connect(self)
359         def _disconnect(self):
360             HTTP._disconnect(self)
361             self._storage_backend._disconnect()
362
363
364     base.make_versioned_storage_testcase_subclasses(
365         TestingHTTP, sys.modules[__name__])
366
367     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
368     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])