0001"""
0002A set of descriptors for creating the server-side component of a
0003web-based API.
0004"""
0005from paste import httpexceptions
0006import paste.request
0007from ohm.validators import JSONConverter, SimplePostConverter,        to_python_headers, from_python_headers, SimplePostIdentity
0009from formencode.api import Invalid
0010import cgi
0011from paste.request import EnvironHeaders
0012from paste.util.template import bunch
0013import re
0014
0015class ClassInit(type):
0016    """
0017    Metaclass to call __classinit__.
0018    """
0019    def __new__(meta, class_name, bases, new_attrs):
0020        cls = type.__new__(meta, class_name, bases, new_attrs)
0021        if new_attrs.has_key('__classinit__'):
0022            cls.__classinit__ = staticmethod(cls.__classinit__.im_func)
0023        cls.__classinit__(cls, new_attrs)
0024        return cls
0025
0026class ApplicationWrapper(object):
0027
0028    """
0029    Object that wraps another object with a WSGI application
0030    interface.
0031
0032    Primarily provides dispatch to attributes which put themselves in
0033    ``_attribute_apps`` (particular ``Setter()``).
0034    """
0035
0036    __metaclass__ = ClassInit
0037    _attribute_apps = []
0038
0039    def __classinit__(cls, new_attrs):
0040        if not '_attribute_apps' in new_attrs:
0041            cls._attribute_apps = list(cls._attribute_apps)
0042        for name, value in new_attrs.items():
0043            if hasattr(value, '__addtoclass__'):
0044                value.__addtoclass__(cls, name)
0045
0046    def __init__(self, obj):
0047        self.object = obj
0048
0049    def __repr__(self):
0050        return '<%s wrapping %s>' % (
0051            self.__class__.__name__,
0052            repr(self.object).strip('<>'))
0053
0054    def __call__(self, environ, start_response):
0055        path_info = environ.get('PATH_INFO', '')
0056        for prefix, app in self._attribute_apps:
0057            if (prefix == path_info
0058                or path_info.startswith(prefix+'/')):
0059                environ['SCRIPT_NAME'] = environ.get('SCRIPT_NAME', '')+path_info[:len(prefix)]
0060                environ['PATH_INFO'] = path_info[len(prefix):]
0061                break
0062        else:
0063            return self.not_found(environ, start_response)
0064        environ['ohm.object_wrapped'] = self.object
0065        environ['ohm.wrapper'] = self
0066        return app(environ, start_response)
0067
0068    def not_found(self, environ, start_response):
0069        exc = httpexceptions.HTTPNotFound(
0070            'No handler for %r (need one of attributes: %s)'
0071            % (self, ', '.join([repr(path) for path, app in self._attribute_apps])))
0072        return exc(environ, start_response)
0073
0074    def add_attribute_app(cls, prefix, app):
0075        if prefix.endswith('/'):
0076            prefix = prefix[:-1]
0077        if not prefix.startswith('/'):
0078            prefix = '/' + prefix
0079        cls._attribute_apps.append((prefix, app))
0080        cls._attribute_apps.sort(
0081            key=lambda i: -len(i[0]))
0082
0083    add_attribute_app = classmethod(add_attribute_app)
0084
0085class Setter(object):
0086
0087    default_encoding = 'utf8'
0088    default_validator = None
0089    content_type = 'application/octet-stream'
0090
0091    def __init__(self,
0092                 unicode=False,
0093                 content_type=None,
0094                 uri_path=None,
0095                 parent_app=None,
0096                 attr=None,
0097                 validator=None,
0098                 POST=None,
0099                 getter=None,
0100                 setter=None,
0101                 deleter=None):
0102        self.unicode = unicode
0103        if content_type is not None:
0104            self.content_type = content_type
0105        self.parent_app = parent_app
0106        self.attr = attr
0107        self.validator = validator
0108        self.uri_path = uri_path
0109        if POST is None:
0110            POST = {}
0111        elif not isinstance(POST, dict):
0112            POST = {'': POST}
0113        self.POST = POST
0114        if getter is not None:
0115            self.getter = getter
0116        if setter is not None:
0117            self.setter = setter
0118        if deleter is not None:
0119            self.deleter = deleter
0120
0121    def __addtoclass__(self, cls, name):
0122        assert self.parent_app is None and self.attr is None, (
0123            "Attribute %r bound multiple times to different "
0124            "classes (first %r as %r, not %r as %r)"
0125            % (self.parent_app, self.attr, cls, name))
0126        self.parent_app = cls
0127        self.attr = name
0128        if self.uri_path is None:
0129            self.uri_path = name
0130        assert self.uri_path not in cls._attribute_apps, (
0131            "Setter already registered at %r: %r"
0132            % (self.uri_path, cls._attribute_apps[self.uri_path]))
0133        cls.add_attribute_app(self.uri_path, self)
0134
0135    def getter(self, obj):
0136        try:
0137            return getattr(obj, self.attr)
0138        except AttributeError, e:
0139            raise httpexceptions.HTTPNotFound(
0140                "You cannot read this resource (attribute %r: %s)"
0141                % (self.attr, e))
0142
0143    def setter(self, obj, value):
0144        try:
0145            setattr(obj, self.attr, value)
0146        except AttributeError, e:
0147            # @@: I don't at this point actually know what is allowed
0148            # Maybe BadRequest would be more straight-forward?
0149            # Or Forbidden
0150            raise httpexceptions.HTTPMethodNotAllowed(
0151                "You cannot PUT to this resource (attribute %r: %s)"
0152                % (self.attr, e),
0153                headers=[('Allow', 'GET,POST')])
0154
0155    def deleter(self, obj):
0156        try:
0157            delattr(obj, self.attr)
0158        except AttributeError, e:
0159            raise httpexceptions.HTTPMethodNotAllowed(
0160                "You cannot DELETE this resource (attribute %r: %s)"
0161                % (self.attr, e),
0162                headers=[('Allow', 'GET,PUT,POST')])
0163
0164    def __call__(self, environ, start_response):
0165        obj = environ['ohm.object_wrapped']
0166        method = environ['REQUEST_METHOD']
0167        self_method = getattr(self, 'method_'+method, None)
0168        if self_method is None:
0169            exc = httpexceptions.HTTPNotImplemented(
0170                "The method %r is not implemented" % method)
0171            return exc(environ, start_response)
0172        try:
0173            return self_method(obj, environ, start_response)
0174        except Invalid, e:
0175            msg = str(e)
0176            exc = httpexceptions.HTTPBadRequest(msg)
0177            return exc(environ, start_response)
0178        except httpexceptions.HTTPException, exc:
0179            return exc(environ, start_response)
0180
0181    def method_GET(self, obj, environ, start_response):
0182        try:
0183            data = self.getter(obj)
0184        except AttributeError, e:
0185            exc = httpexceptions.HTTPNotFound(
0186                "Cannot retrieve: %s" % e)
0187            return exc(environ, start_response)
0188        state = bunch(object=obj, attr=self.attr)
0189        if self.validator:
0190            data = self.validator.from_python(data, state)
0191        if self.default_validator:
0192            data = self.default_validator.from_python(data, state)
0193        extra_ct = ''
0194        if self.unicode:
0195            assert isinstance(data, unicode), (
0196                "Did not get unicode data as expected; got: %r"
0197                % data)
0198            data = data.encode(self.default_encoding)
0199            extra_ct += '; charset=%s' % self.default_encoding
0200        else:
0201            assert isinstance(data, str), (
0202                "Did not get str data as expected; got: %r"
0203                % data)
0204        content_type = self.content_type + extra_ct
0205        length = str(len(data))
0206        start_response('200 OK',
0207                       [('Content-Type', content_type),
0208                        ('Content-Length', length)])
0209        return [data]
0210
0211    def method_PUT(self, obj, environ, start_response):
0212        input = environ['wsgi.input']
0213        content_length = int(environ.get('CONTENT_LENGTH', '0'))
0214        data = input.read(content_length)
0215        if self.unicode:
0216            # @@: Should at least try to read from environ
0217            data = data.decode(self.default_encoding)
0218        state = bunch(object=obj, attr=self.attr)
0219        if self.default_validator:
0220            data = self.default_validator.to_python(data, state)
0221        if self.validator:
0222            data = self.validator.to_python(data, state)
0223        self.setter(obj, data)
0224        start_response('204 No Content', [])
0225        return []
0226
0227    def method_DELETE(self, obj, environ, start_response):
0228        self.deleter(obj)
0229        start_response('204 No Content', [])
0230        return []
0231
0232    def method_POST(self, obj, environ, start_response):
0233        if not self.POST:
0234            exc = httpexceptions.HTTPNotImplemented(
0235                "No POST methods have been defined")
0236            return exc(environ, start_response)
0237        qs = cgi.parse_qsl(environ.get('QUERY_STRING', ''),
0238                           keep_blank_values=True)
0239        command = ''
0240        if qs and not qs[0][1]:
0241            command = qs[0][0]
0242        for key, value in qs:
0243            if key == 'command':
0244                command = value
0245                break
0246        if command not in self.POST:
0247            if not command:
0248                command_desc = '(empty)'
0249            else:
0250                command_desc = repr(command)
0251            exc = httpexceptions.HTTPBadRequest(
0252                "No POST method %s defined (need one of %s)"
0253                % (command_desc,
0254                   ', '.join([repr(k) for k in self.POST.keys()])))
0255            return exc(environ, start_response)
0256        command = self.POST[command]
0257        headers = EnvironHeaders(environ)
0258        content_length = int(environ.get('CONTENT_LENGTH', '0'))
0259        input = environ['wsgi.input']
0260        body = input.read(content_length)
0261        response = self.call_POST(obj, command, (headers, body))
0262        if response is None or response == '':
0263            start_response('204 No Content', [])
0264            return ['']
0265        headers, body = self._coerce_POST_response(response)
0266        if not body:
0267            status = '204 No Content'
0268            body = ''
0269        else:
0270            status = '200 OK'
0271        headers.append(('Content-Length', str(len(body))))
0272        start_response(status, headers)
0273        return [body]
0274
0275    def call_POST(self, obj, command, request):
0276        if isinstance(command, basestring):
0277            if self.default_validator:
0278                command = (self.default_validator, command)
0279            else:
0280                command = (SimplePostConverter(), command)
0281        if (not isinstance(command, (list, tuple))
0282            or not len(command) == 2):
0283            raise TypeError(
0284                "Commands must be in the form (validator, method) not %r"
0285                % command)
0286        validator, method = command
0287        if validator is None:
0288            validator = SimplePostIdentity()
0289        state = bunch(object=obj, method=method)
0290        args = to_python_headers(validator, request, state)
0291        if isinstance(args, dict):
0292            posargs, kwargs = (), args
0293        elif isinstance(args, tuple):
0294            posargs, kwargs = args, {}
0295        else:
0296            posargs, kwargs = (args,), {}
0297        if isinstance(method, basestring):
0298            method = getattr(obj, method)
0299        else:
0300            posargs = (obj,) + posargs
0301        response = method(*posargs, **kwargs)
0302        response = from_python_headers(validator, response, state)
0303        return response
0304
0305    _text_re = re.compile(r'^[^\x00-\x1f]+$')
0306    _mime_re = re.compile(r'^[a-z]+/[a-zA-Z0-9._-]_$')
0307
0308    def _coerce_POST_response(self, response):
0309        if isinstance(response, basestring):
0310            if response.lstrip().startswith('<?xml'):
0311                content_type = 'application/xml'
0312            elif response.lstrip()[:5].lower() == '<html':
0313                content_type = 'text/html'
0314            elif (isinstance(response, unicode)
0315                  or self._text_re.search(response)):
0316                content_type = 'text/plain'
0317            else:
0318                content_type = 'application/octet-stream'
0319            if isinstance(response, unicode):
0320                content_type += '; charset=utf8'
0321                response = response.encode('utf8')
0322            return [('Content-Type', content_type)], response
0323        if (isinstance(response, tuple)
0324            and len(response) == 2
0325            and isinstance(response[0], basestring)
0326            and self._mime_re.search(response[0])):
0327            return [('Content-Type', response[0])], response[1]
0328        if (isinstance(response, tuple)
0329            and len(response) == 2
0330            and (hasattr(response[0], 'items')
0331                 or isinstance(response[0], list))):
0332            if not isinstance(response[0], list):
0333                response = (response[0].items(), response[1])
0334            return response
0335        raise ValueError(
0336            "I don't know how to turn %r into a WSGI response"
0337            % response)
0338
0339class MethodNotAllowed(object):
0340    """
0341    Function placeholder that always raises HTTPMethodNotAllowed
0342    """
0343
0344    def __init__(self, msg="Method not allowed", allow='GET'):
0345        self.msg = msg
0346        self.allow = allow
0347
0348    def __call__(self, *args, **kw):
0349        raise httpexceptions.HTTPMethodNotAllowed(
0350            self.msg,
0351            headers=[('Allow', self.allow)])
0352
0353
0354class JSONSetter(Setter):
0355
0356    default_validator = JSONConverter()
0357    # simplejson does the encoding:
0358    content_type = 'application/json; charset=utf8'
0359
0360def appfactory(uri_path=None):
0361    """
0362    Decorator that decorates a function that produces a WSGI
0363    application
0364    """
0365    def decorator(func):
0366        return FuncFactory(func, uri_path=uri_path)
0367    return decorator
0368
0369class FuncFactory(Setter):
0370
0371    def __init__(self, func, uri_path=None):
0372        self.func = func
0373        Setter.__init__(self, uri_path=uri_path)
0374
0375    def __call__(self, environ, start_response):
0376        obj = environ['ohm.object_wrapped']
0377        app = self.func(obj)
0378        return app(environ, start_response)