PageRenderTime 64ms CodeModel.GetById 14ms app.highlight 44ms RepoModel.GetById 1ms app.codeStats 0ms

/indra/lib/python/indra/ipc/siesta.py

https://bitbucket.org/lindenlab/viewer-beta/
Python | 468 lines | 459 code | 0 blank | 9 comment | 0 complexity | 764b8e495ff801c59da1a725dbea1d49 MD5 | raw file
  1"""\
  2@file siesta.py
  3@brief A tiny llsd based RESTful web services framework
  4
  5$LicenseInfo:firstyear=2008&license=mit$
  6
  7Copyright (c) 2008, Linden Research, Inc.
  8
  9Permission is hereby granted, free of charge, to any person obtaining a copy
 10of this software and associated documentation files (the "Software"), to deal
 11in the Software without restriction, including without limitation the rights
 12to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 13copies of the Software, and to permit persons to whom the Software is
 14furnished to do so, subject to the following conditions:
 15
 16The above copyright notice and this permission notice shall be included in
 17all copies or substantial portions of the Software.
 18
 19THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 20IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 21FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 22AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 23LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 24OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 25THE SOFTWARE.
 26$/LicenseInfo$
 27"""
 28
 29from indra.base import config
 30from indra.base import llsd
 31from webob import exc
 32import webob
 33import re, socket
 34
 35try:
 36    from cStringIO import StringIO
 37except ImportError:
 38    from StringIO import StringIO
 39
 40try:
 41    import cjson
 42    json_decode = cjson.decode
 43    json_encode = cjson.encode
 44    JsonDecodeError = cjson.DecodeError
 45    JsonEncodeError = cjson.EncodeError
 46except ImportError:
 47    import simplejson
 48    json_decode = simplejson.loads
 49    json_encode = simplejson.dumps
 50    JsonDecodeError = ValueError
 51    JsonEncodeError = TypeError
 52
 53
 54llsd_parsers = {
 55    'application/json': json_decode,
 56    llsd.BINARY_MIME_TYPE: llsd.parse_binary,
 57    'application/llsd+notation': llsd.parse_notation,
 58    llsd.XML_MIME_TYPE: llsd.parse_xml,
 59    'application/xml': llsd.parse_xml,
 60    }
 61
 62
 63def mime_type(content_type):
 64    '''Given a Content-Type header, return only the MIME type.'''
 65
 66    return content_type.split(';', 1)[0].strip().lower()
 67    
 68class BodyLLSD(object):
 69    '''Give a webob Request or Response an llsd based "content" property.
 70
 71    Getting the content property parses the body, and caches the result.
 72
 73    Setting the content property formats a payload, and the body property
 74    is set.'''
 75
 76    def _llsd__get(self):
 77        '''Get, set, or delete the LLSD value stored in this object.'''
 78
 79        try:
 80            return self._llsd
 81        except AttributeError:
 82            if not self.body:
 83                raise AttributeError('No llsd attribute has been set')
 84            else:
 85                mtype = mime_type(self.content_type)
 86                try:
 87                    parser = llsd_parsers[mtype]
 88                except KeyError:
 89                    raise exc.HTTPUnsupportedMediaType(
 90                        'Content type %s not supported' % mtype).exception
 91                try:
 92                    self._llsd = parser(self.body)
 93                except (llsd.LLSDParseError, JsonDecodeError, TypeError), err:
 94                    raise exc.HTTPBadRequest(
 95                        'Could not parse body: %r' % err.args).exception
 96            return self._llsd
 97
 98    def _llsd__set(self, val):
 99        req = getattr(self, 'request', None)
100        if req is not None:
101            formatter, ctype = formatter_for_request(req)
102            self.content_type = ctype
103        else:
104            formatter, ctype = formatter_for_mime_type(
105                mime_type(self.content_type))
106        self.body = formatter(val)
107
108    def _llsd__del(self):
109        if hasattr(self, '_llsd'):
110            del self._llsd
111
112    content = property(_llsd__get, _llsd__set, _llsd__del)
113
114
115class Response(webob.Response, BodyLLSD):
116    '''Response class with LLSD support.
117
118    A sensible default content type is used.
119
120    Setting the llsd property also sets the body.  Getting the llsd
121    property parses the body if necessary.
122
123    If you set the body property directly, the llsd property will be
124    deleted.'''
125
126    default_content_type = 'application/llsd+xml'
127
128    def _body__set(self, body):
129        if hasattr(self, '_llsd'):
130            del self._llsd
131        super(Response, self)._body__set(body)
132
133    def cache_forever(self):
134        self.cache_expires(86400 * 365)
135
136    body = property(webob.Response._body__get, _body__set,
137                    webob.Response._body__del,
138                    webob.Response._body__get.__doc__)
139
140
141class Request(webob.Request, BodyLLSD):
142    '''Request class with LLSD support.
143
144    Sensible content type and accept headers are used by default.
145
146    Setting the content property also sets the body. Getting the content
147    property parses the body if necessary.
148
149    If you set the body property directly, the content property will be
150    deleted.'''
151    
152    default_content_type = 'application/llsd+xml'
153    default_accept = ('application/llsd+xml; q=0.5, '
154                      'application/llsd+notation; q=0.3, '
155                      'application/llsd+binary; q=0.2, '
156                      'application/xml; q=0.1, '
157                      'application/json; q=0.0')
158
159    def __init__(self, environ=None, *args, **kwargs):
160        if environ is None:
161            environ = {}
162        else:
163            environ = environ.copy()
164        if 'CONTENT_TYPE' not in environ:
165            environ['CONTENT_TYPE'] = self.default_content_type
166        if 'HTTP_ACCEPT' not in environ:
167            environ['HTTP_ACCEPT'] = self.default_accept
168        super(Request, self).__init__(environ, *args, **kwargs)
169
170    def _body__set(self, body):
171        if hasattr(self, '_llsd'):
172            del self._llsd
173        super(Request, self)._body__set(body)
174
175    def path_urljoin(self, *parts):
176        return '/'.join([path_url.rstrip('/')] + list(parts))
177
178    body = property(webob.Request._body__get, _body__set,
179                    webob.Request._body__del, webob.Request._body__get.__doc__)
180
181    def create_response(self, content=None, status='200 OK',
182                        conditional_response=webob.NoDefault):
183        resp = self.ResponseClass(status=status, request=self,
184                                  conditional_response=conditional_response)
185        resp.content = content
186        return resp
187
188    def curl(self):
189        '''Create and fill out a pycurl easy object from this request.'''
190 
191        import pycurl
192        c = pycurl.Curl()
193        c.setopt(pycurl.URL, self.url())
194        if self.headers:
195            c.setopt(pycurl.HTTPHEADER,
196                     ['%s: %s' % (k, self.headers[k]) for k in self.headers])
197        c.setopt(pycurl.FOLLOWLOCATION, True)
198        c.setopt(pycurl.AUTOREFERER, True)
199        c.setopt(pycurl.MAXREDIRS, 16)
200        c.setopt(pycurl.NOSIGNAL, True)
201        c.setopt(pycurl.READFUNCTION, self.body_file.read)
202        c.setopt(pycurl.SSL_VERIFYHOST, 2)
203        
204        if self.method == 'POST':
205            c.setopt(pycurl.POST, True)
206            post301 = getattr(pycurl, 'POST301', None)
207            if post301 is not None:
208                # Added in libcurl 7.17.1.
209                c.setopt(post301, True)
210        elif self.method == 'PUT':
211            c.setopt(pycurl.PUT, True)
212        elif self.method != 'GET':
213            c.setopt(pycurl.CUSTOMREQUEST, self.method)
214        return c
215
216Request.ResponseClass = Response
217Response.RequestClass = Request
218
219
220llsd_formatters = {
221    'application/json': json_encode,
222    'application/llsd+binary': llsd.format_binary,
223    'application/llsd+notation': llsd.format_notation,
224    'application/llsd+xml': llsd.format_xml,
225    'application/xml': llsd.format_xml,
226    }
227
228formatter_qualities = (
229    ('application/llsd+xml', 1.0),
230    ('application/llsd+notation', 0.5),
231    ('application/llsd+binary', 0.4),
232    ('application/xml', 0.3),
233    ('application/json', 0.2),
234    )
235
236def formatter_for_mime_type(mime_type):
237    '''Return a formatter that encodes to the given MIME type.
238
239    The result is a pair of function and MIME type.'''
240    try:
241        return llsd_formatters[mime_type], mime_type
242    except KeyError:
243        raise exc.HTTPInternalServerError(
244            'Could not use MIME type %r to format response' %
245            mime_type).exception
246
247
248def formatter_for_request(req):
249    '''Return a formatter that encodes to the preferred type of the client.
250
251    The result is a pair of function and actual MIME type.'''
252    ctype = req.accept.best_match(formatter_qualities)
253    try:
254        return llsd_formatters[ctype], ctype
255    except KeyError:
256        raise exc.HTTPNotAcceptable().exception
257
258
259def wsgi_adapter(func, environ, start_response):
260    '''Adapt a Siesta callable to act as a WSGI application.'''
261    # Process the request as appropriate.
262    try:
263        req = Request(environ)
264        #print req.urlvars
265        resp = func(req, **req.urlvars)
266        if not isinstance(resp, webob.Response):
267            try:
268                formatter, ctype = formatter_for_request(req)
269                resp = req.ResponseClass(formatter(resp), content_type=ctype)
270                resp._llsd = resp
271            except (JsonEncodeError, TypeError), err:
272                resp = exc.HTTPInternalServerError(
273                    detail='Could not format response')
274    except exc.HTTPException, e:
275        resp = e
276    except socket.error, e:
277        resp = exc.HTTPInternalServerError(detail=e.args[1])
278    return resp(environ, start_response)
279
280
281def llsd_callable(func):
282    '''Turn a callable into a Siesta application.'''
283
284    def replacement(environ, start_response):
285        return wsgi_adapter(func, environ, start_response)
286
287    return replacement
288
289
290def llsd_method(http_method, func):
291    def replacement(environ, start_response):
292        if environ['REQUEST_METHOD'] == http_method:
293            return wsgi_adapter(func, environ, start_response)
294        return exc.HTTPMethodNotAllowed()(environ, start_response)
295
296    return replacement
297
298
299http11_methods = 'OPTIONS GET HEAD POST PUT DELETE TRACE CONNECT'.split()
300http11_methods.sort()
301
302def llsd_class(cls):
303    '''Turn a class into a Siesta application.
304
305    A new instance is created for each request.  A HTTP method FOO is
306    turned into a call to the handle_foo method of the instance.'''
307
308    def foo(req, **kwargs):
309        instance = cls()
310        method = req.method.lower()
311        try:
312            handler = getattr(instance, 'handle_' + method)
313        except AttributeError:
314            allowed = [m for m in http11_methods
315                       if hasattr(instance, 'handle_' + m.lower())]
316            raise exc.HTTPMethodNotAllowed(
317                headers={'Allow': ', '.join(allowed)}).exception
318        #print "kwargs: ", kwargs
319        return handler(req, **kwargs)
320
321    def replacement(environ, start_response):
322        return wsgi_adapter(foo, environ, start_response)
323
324    return replacement
325
326
327def curl(reqs):
328    import pycurl
329
330    m = pycurl.CurlMulti()
331    curls = [r.curl() for r in reqs]
332    io = {}
333    for c in curls:
334        fp = StringIO()
335        hdr = StringIO()
336        c.setopt(pycurl.WRITEFUNCTION, fp.write)
337        c.setopt(pycurl.HEADERFUNCTION, hdr.write)
338        io[id(c)] = fp, hdr
339    m.handles = curls
340    try:
341        while True:
342            ret, num_handles = m.perform()
343            if ret != pycurl.E_CALL_MULTI_PERFORM:
344                break
345    finally:
346        m.close()
347
348    for req, c in zip(reqs, curls):
349        fp, hdr = io[id(c)]
350        hdr.seek(0)
351        status = hdr.readline().rstrip()
352        headers = []
353        name, values = None, None
354
355        # XXX We don't currently handle bogus header data.
356
357        for line in hdr.readlines():
358            if not line[0].isspace():
359                if name:
360                    headers.append((name, ' '.join(values)))
361                name, value = line.strip().split(':', 1)
362                value = [value]
363            else:
364                values.append(line.strip())
365        if name:
366            headers.append((name, ' '.join(values)))
367
368        resp = c.ResponseClass(fp.getvalue(), status, headers, request=req)
369
370
371route_re = re.compile(r'''
372    \{                 # exact character "{"
373    (\w*)              # "config" or variable (restricted to a-z, 0-9, _)
374    (?:([:~])([^}]+))? # optional :type or ~regex part
375    \}                 # exact character "}"
376    ''', re.VERBOSE)
377
378predefined_regexps = {
379    'uuid': r'[a-f0-9][a-f0-9-]{31,35}',
380    'int': r'\d+',
381    'host': r'[a-z0-9][a-z0-9\-\.]*',
382    }
383
384def compile_route(route):
385    fp = StringIO()
386    last_pos = 0
387    for match in route_re.finditer(route):
388        #print "matches: ", match.groups()
389        fp.write(re.escape(route[last_pos:match.start()]))
390        var_name = match.group(1)
391        sep = match.group(2)
392        expr = match.group(3)
393        if var_name == 'config':
394            expr = re.escape(str(config.get(var_name)))
395        else:
396            if expr:
397                if sep == ':':
398                    expr = predefined_regexps[expr]
399                # otherwise, treat what follows '~' as a regexp
400            else:
401                expr = '[^/]+'
402            if var_name != '':
403                expr = '(?P<%s>%s)' % (var_name, expr)
404            else:
405                expr = '(%s)' % (expr,)
406        fp.write(expr)
407        last_pos = match.end()
408    fp.write(re.escape(route[last_pos:]))
409    compiled_route = '^%s$' % fp.getvalue()
410    #print route, "->", compiled_route
411    return compiled_route
412
413class Router(object):
414    '''WSGI routing class.  Parses a URL and hands off a request to
415    some other WSGI application.  If no suitable application is found,
416    responds with a 404.'''
417
418    def __init__(self):
419        self._new_routes = []
420        self._routes = []
421        self._paths = []
422
423    def add(self, route, app, methods=None):
424        self._new_routes.append((route, app, methods))
425
426    def _create_routes(self):
427        for route, app, methods in self._new_routes:
428            self._paths.append(route)
429            self._routes.append(
430                (re.compile(compile_route(route)),
431                 app,
432                 methods and dict.fromkeys(methods)))
433        self._new_routes = []
434
435    def __call__(self, environ, start_response):
436        # load up the config from the config file. Only needs to be
437        # done once per interpreter. This is the entry point of all
438        # siesta applications, so this is where we trap it.
439        _conf = config.get_config()
440        if _conf is None:
441            import os.path
442            fname = os.path.join(
443                environ.get('ll.config_dir', '/local/linden/etc'),
444                'indra.xml')
445            config.load(fname)
446
447        # proceed with handling the request
448        self._create_routes()
449        path_info = environ['PATH_INFO']
450        request_method = environ['REQUEST_METHOD']
451        allowed = []
452        for regex, app, methods in self._routes:
453            m = regex.match(path_info)
454            if m:
455                #print "groupdict:",m.groupdict()
456                if not methods or request_method in methods:
457                    environ['paste.urlvars'] = m.groupdict()
458                    return app(environ, start_response)
459                else:
460                    allowed += methods
461        if allowed:
462            allowed = dict.fromkeys(allows).keys()
463            allowed.sort()
464            resp = exc.HTTPMethodNotAllowed(
465                headers={'Allow': ', '.join(allowed)})
466        else:
467            resp = exc.HTTPNotFound()
468        return resp(environ, start_response)