app: Refactor Application class

This commit breaks up the `Application.__call__` method into smaller methods
that can be overridden by subclasses. These methods allow customization of
various steps of the request/response handling process:

* `make_request`: Create the `Request` object from the WSGI environment
  dictionary. The default implementation creates a `milla.Request` object,
  copies the application configuration to its `config` attribute, and handles
  "emulated" HTTP methods from POST data.
* `resolve_path`: Locates a controller callable from the given path info. The
  default implementation calls the `resolve` method on the application's
  `dispatcher` attribute. If `UnresolvePath` is raised, it returns a callable
  that raises `HTTPNotFound`.
* `handle_error`: Called inside the exception handler when a controller
  callable raises an exception. The method should return a callable WSGI
  application (such as a `Response` or `WSGIHTTPException` object). To access
  the exception that was raised, use the `sys.exc_info` function. The default
  implementation returns the exception if it is an instance of
  `WSGIHTTPException`, or re-raises the exception otherwise. This allows
  middleware applications to handle the exception, if desired.
master
Dustin 2015-04-25 12:42:42 -05:00
parent 922a82e4e8
commit da406fcce8
3 changed files with 89 additions and 35 deletions

View File

@ -28,6 +28,22 @@ except ImportError: # pragma: no cover
urllib.parse.urlencode = urllib.urlencode
class _AllowAll(object):
def __contains__(self, other):
return True
ALL_METHODS = _AllowAll()
'''Allow all HTTP methods (even non-standard ones)'''
DEFAULT_METHODS = ['GET', 'HEAD']
'''Default methods allowed on controller callables'''
STANDARD_METHODS = ['OPTIONS', 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'TRACE']
'''All standard HTTP methods'''
def allow(*methods):
'''Specify the allowed HTTP verbs for a controller callable

View File

@ -25,6 +25,8 @@ from milla.controllers import FaviconController
from milla.util import asbool
from webob.exc import HTTPNotFound, WSGIHTTPException, HTTPMethodNotAllowed
import milla.dispatch.traversal
import sys
__all__ = ['Application']
@ -47,8 +49,6 @@ class Application(object):
configuration is copied and assigned to ``request.config``.
'''
DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD']
def __init__(self, obj):
if not hasattr(obj, 'resolve'):
# Object is not a dispatcher, but the root object for traversal
@ -57,48 +57,31 @@ class Application(object):
self.config = {'milla.favicon': True}
def __call__(self, environ, start_response):
path_info = environ['PATH_INFO']
try:
func = self.dispatcher.resolve(path_info)
except milla.dispatch.UnresolvedPath:
if asbool(self.config.get('milla.favicon')) and \
path_info == '/favicon.ico':
func = FaviconController()
else:
return HTTPNotFound()(environ, start_response)
request = milla.Request(environ)
request.config = self.config.copy()
# Sometimes, hacky applications will try to "emulate" some HTTP
# method like POST or DELETE by specifying an _method parameter
# in a POST request.
if request.method == 'POST' and '_method' in request.POST:
request.method = request.POST.pop('_method')
start_response = StartResponseWrapper(start_response)
func = self.resolve_path(environ['PATH_INFO'])
request = self.make_request(environ)
request.__dict__['start_response'] = start_response
try:
allowed_methods = self._find_attr(func, 'allowed_methods')
except AttributeError:
allowed_methods = self.DEFAULT_ALLOWED_METHODS
allowed_methods = milla.DEFAULT_METHODS
if request.method not in allowed_methods:
allow_header = {'Allow': ', '.join(allowed_methods)}
if request.method == 'OPTIONS':
def options_response(request, *args, **kwargs):
def func(request):
response = request.ResponseClass()
response.headers = allow_header
return response
func = options_response
else:
func = HTTPMethodNotAllowed(headers=allow_header)
return func(environ, start_response)
def func(request):
raise HTTPMethodNotAllowed(headers=allow_header)
start_response_wrapper = StartResponseWrapper(start_response)
request.start_response = start_response_wrapper
try:
self._call_before(func)(request)
response = func(request)
except WSGIHTTPException as e:
return e(environ, start_response)
except:
response = self.handle_error(request)
finally:
self._call_after(func)(request)
@ -113,12 +96,11 @@ class Application(object):
if isinstance(response, _string) or not response:
response = request.ResponseClass(response)
if not start_response_wrapper.called:
start_response(response.status, response.headerlist)
if environ['REQUEST_METHOD'] == 'HEAD':
start_response(response.status, response.headerlist)
return ''
else:
return response.app_iter
return response(environ, start_response)
def _call_after(self, func):
try:
@ -145,6 +127,61 @@ class Application(object):
return self._find_attr(obj.func, attr)
raise
def make_request(self, environ):
'''Create a :py:class:`~milla.Request` from a WSGI environment
:param environ: WSGI environment dictionary
:returns: :py:class:`milla.Request` object for this request
'''
request = milla.Request(environ)
request.__dict__['config'] = self.config.copy()
# Sometimes, hacky applications will try to "emulate" some HTTP
# methods like PUT or DELETE by specifying an _method parameter
# in a POST request.
if request.method == 'POST' and '_method' in request.POST:
request.method = request.POST.pop('_method')
return request
def resolve_path(self, path_info):
'''Find the controller for a given path
:param path_info: The request path, relative to the application
:returns: Controller callable
If no controller could be resolved for the path, a function
that raises :py:exc:`HTTPNotFound` will be returned.
'''
def path_not_found(request):
raise HTTPNotFound
path_not_found.allowed_methods = milla.ALL_METHODS
try:
return self.dispatcher.resolve(path_info)
except milla.dispatch.UnresolvedPath:
if (path_info == '/favicon.ico' and
asbool(self.config.get('milla.favicon'))):
return FaviconController()
else:
return path_not_found
def handle_error(self, request):
'''Handle errors raised by controller callables
Subclasses can override this method to customize the error
handling behavior of applications. The default implementation
only handles :py:exc:`WSGIHTTPException` exceptions, by calling
them as WSGI applications
'''
typ, value, tb = sys.exc_info()
if issubclass(typ, WSGIHTTPException):
return value
raise
class StartResponseWrapper():
def __init__(self, start_response):
@ -152,5 +189,6 @@ class StartResponseWrapper():
self.called = False
def __call__(self, *args, **kwargs):
if not self.called:
self.called = True
return self.start_response(*args, **kwargs)
self.start_response(*args, **kwargs)

View File

@ -206,7 +206,7 @@ def test_return_unicode():
response.finish_response(app_iter)
assert response.body == unicode('Hello, world'), response.body
@nose.tools.raises(AttributeError)
@nose.tools.raises(TypeError)
@python3_only
def test_return_bytes():
'''Controllers cannot return bytes objects