From da406fcce8f1c96fd582156b865f38b884f1bd6c Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Sat, 25 Apr 2015 12:42:42 -0500 Subject: [PATCH] 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. --- src/milla/__init__.py | 16 +++++++ src/milla/app.py | 106 ++++++++++++++++++++++++++++-------------- test/test_app.py | 2 +- 3 files changed, 89 insertions(+), 35 deletions(-) diff --git a/src/milla/__init__.py b/src/milla/__init__.py index 31d8afc..64e686d 100644 --- a/src/milla/__init__.py +++ b/src/milla/__init__.py @@ -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 diff --git a/src/milla/app.py b/src/milla/app.py index 92449c9..ad86df9 100644 --- a/src/milla/app.py +++ b/src/milla/app.py @@ -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): - self.called = True - return self.start_response(*args, **kwargs) + if not self.called: + self.called = True + self.start_response(*args, **kwargs) diff --git a/test/test_app.py b/test/test_app.py index 1866c72..c8e9f9b 100644 --- a/test/test_app.py +++ b/test/test_app.py @@ -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