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
0148
0149
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
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
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)