diff --git a/docs/intro.rst b/docs/intro.rst index aa76f05..f84122c 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -445,7 +445,7 @@ Mounting a Sub-Application ^^^^^^^^^^^^^^^^^^^^^^^^^^ Small Microdot applications can be written as a single source file, but this -is not the best option for applications that past a certain size. To make it +is not the best option for applications that pass a certain size. To make it simpler to write large applications, Microdot supports the concept of sub-applications that can be "mounted" on a larger application, possibly with a common URL prefix applied to all of its routes. For developers familiar with @@ -501,11 +501,25 @@ The resulting application will have the customer endpoints available at */customers/* and the order endpoints available at */orders/*. .. note:: - Before-request, after-request and error handlers defined in the - sub-application are also copied over to the main application at mount time. - Once installed in the main application, these handlers will apply to the - whole application and not just the sub-application in which they were - created. + During the handling of a request, the + :attr:`Request.url_prefix ` attribute is + set to the URL prefix under which the sub-application was mounted, or an + empty string if the endpoint did not come from a sub-application or the + sub-application was mounted without a URL prefix. It is possible to issue a + redirect that is relative to the sub-application as follows:: + + return redirect(request.url_prefix + '/relative-url') + +When mounting an application as shown above, before-request, after-request and +error handlers defined in the sub-application are copied over to the main +application at mount time. Once installed in the main application, these +handlers will apply to the whole application and not just the sub-application +in which they were created. + +The :func:`mount() ` method has a ``local`` argument +that defaults to ``False``. When this argument is set to ``True``, the +before-request, after-request and error handlers defined in the sub-application +will only apply to the sub-application. Shutting Down the Server ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/microdot/microdot.py b/src/microdot/microdot.py index 541aeaf..4703567 100644 --- a/src/microdot/microdot.py +++ b/src/microdot/microdot.py @@ -329,7 +329,8 @@ class Request: pass def __init__(self, app, client_addr, method, url, http_version, headers, - body=None, stream=None, sock=None): + body=None, stream=None, sock=None, url_prefix='', + subapp=None): #: The application instance to which this request belongs. self.app = app #: The address of the client, as a tuple (host, port). @@ -338,6 +339,12 @@ class Request: self.method = method #: The request URL, including the path and query string. self.url = url + #: The URL prefix, if the endpoint comes from a mounted + #: sub-application, or else ''. + self.url_prefix = url_prefix + #: The sub-application instance, or `None` if this isn't a mounted + #: endpoint. + self.subapp = subapp #: The path portion of the URL. self.path = url #: The query string portion of the URL. @@ -959,7 +966,7 @@ class Microdot: def decorated(f): self.url_map.append( ([m.upper() for m in (methods or ['GET'])], - URLPattern(url_pattern), f)) + URLPattern(url_pattern), f, '', None)) return f return decorated @@ -1127,24 +1134,33 @@ class Microdot: return f return decorated - def mount(self, subapp, url_prefix=''): + def mount(self, subapp, url_prefix='', local=False): """Mount a sub-application, optionally under the given URL prefix. :param subapp: The sub-application to mount. :param url_prefix: The URL prefix to mount the application under. + :param local: When set to ``True``, the before, after and error request + handlers only apply to endpoints defined in the + sub-application. When ``False``, they apply to the entire + application. The default is ``False``. """ - for methods, pattern, handler in subapp.url_map: + for methods, pattern, handler, _prefix, _subapp in subapp.url_map: self.url_map.append( (methods, URLPattern(url_prefix + pattern.url_pattern), - handler)) - for handler in subapp.before_request_handlers: - self.before_request_handlers.append(handler) - for handler in subapp.after_request_handlers: - self.after_request_handlers.append(handler) - for handler in subapp.after_error_request_handlers: - self.after_error_request_handlers.append(handler) - for status_code, handler in subapp.error_handlers.items(): - self.error_handlers[status_code] = handler + handler, url_prefix + _prefix, _subapp or subapp)) + if not local: + for handler in subapp.before_request_handlers: + self.before_request_handlers.append(handler) + subapp.before_request_handlers = [] + for handler in subapp.after_request_handlers: + self.after_request_handlers.append(handler) + subapp.after_request_handlers = [] + for handler in subapp.after_error_request_handlers: + self.after_error_request_handlers.append(handler) + subapp.after_error_request_handlers = [] + for status_code, handler in subapp.error_handlers.items(): + self.error_handlers[status_code] = handler + subapp.error_handlers = {} @staticmethod def abort(status_code, reason=None): @@ -1302,23 +1318,28 @@ class Microdot: def find_route(self, req): method = req.method.upper() if method == 'OPTIONS' and self.options_handler: - return self.options_handler(req) + return self.options_handler(req), '', None if method == 'HEAD': method = 'GET' f = 404 - for route_methods, route_pattern, route_handler in self.url_map: + p = '' + s = None + for route_methods, route_pattern, route_handler, url_prefix, subapp \ + in self.url_map: req.url_args = route_pattern.match(req.path) if req.url_args is not None: + p = url_prefix + s = subapp if method in route_methods: f = route_handler break else: f = 405 - return f + return f, p, s def default_options_handler(self, req): allow = [] - for route_methods, route_pattern, route_handler in self.url_map: + for route_methods, route_pattern, _, _, _ in self.url_map: if route_pattern.match(req.path) is not None: allow.extend(route_methods) if 'GET' in allow: @@ -1349,43 +1370,76 @@ class Microdot: method=req.method, path=req.path, status_code=res.status_code)) + def get_request_handlers(self, req, attr, local_first=True): + handlers = getattr(self, attr + '_handlers') + local_handlers = getattr(req.subapp, attr + '_handlers') \ + if req and req.subapp else [] + return local_handlers + handlers if local_first \ + else handlers + local_handlers + + async def error_response(self, req, status_code, reason=None): + if req and req.subapp and status_code in req.subapp.error_handlers: + return await invoke_handler( + req.subapp.error_handlers[status_code], req) + elif status_code in self.error_handlers: + return await invoke_handler(self.error_handlers[status_code], req) + return reason or 'N/A', status_code + async def dispatch_request(self, req): after_request_handled = False if req: if req.content_length > req.max_content_length: - if 413 in self.error_handlers: - res = await invoke_handler(self.error_handlers[413], req) - else: - res = 'Payload too large', 413 + # the request body is larger than allowed + res = await self.error_response(req, 413, 'Payload too large') else: - f = self.find_route(req) + # find the route in the app's URL map + f, req.url_prefix, req.subapp = self.find_route(req) + try: res = None if callable(f): - for handler in self.before_request_handlers: + # invoke the before request handlers + for handler in self.get_request_handlers( + req, 'before_request', False): res = await invoke_handler(handler, req) if res: break + + # invoke the endpoint handler if res is None: - res = await invoke_handler( - f, req, **req.url_args) + res = await invoke_handler(f, req, **req.url_args) + + # process the response if isinstance(res, int): + # an integer response is taken as a status code + # with an empty body res = '', res if isinstance(res, tuple): + # handle a tuple response if isinstance(res[0], int): + # a tuple that starts with an int has an empty + # body res = ('', res[0], res[1] if len(res) > 1 else {}) body = res[0] if isinstance(res[1], int): + # extract the status code and headers (if + # available) status_code = res[1] headers = res[2] if len(res) > 2 else {} else: + # if the status code is missing, assume 200 status_code = 200 headers = res[1] res = Response(body, status_code, headers) elif not isinstance(res, Response): + # any other response types are wrapped in a + # Response object res = Response(res) - for handler in self.after_request_handlers: + + # invoke the after request handlers + for handler in self.get_request_handlers( + req, 'after_request', True): res = await invoke_handler( handler, req, res) or res for handler in req.after_request_handlers: @@ -1393,50 +1447,62 @@ class Microdot: handler, req, res) or res after_request_handled = True elif isinstance(f, dict): + # the response from an OPTIONS request is a dict with + # headers res = Response(headers=f) - elif f in self.error_handlers: - res = await invoke_handler(self.error_handlers[f], req) else: - res = 'Not found', f + # if the route is not found, return a 404 or 405 + # response as appropriate + res = await self.error_response(req, f, 'Not found') except HTTPException as exc: - if exc.status_code in self.error_handlers: - res = self.error_handlers[exc.status_code](req) - else: - res = exc.reason, exc.status_code + # an HTTP exception was raised while handling this request + res = await self.error_response(req, exc.status_code, + exc.reason) except Exception as exc: + # an unexpected exception was raised while handling this + # request print_exception(exc) - exc_class = None + + # invoke the error handler for the exception class if one + # exists + handler = None res = None - if exc.__class__ in self.error_handlers: - exc_class = exc.__class__ + if req.subapp and exc.__class__ in \ + req.subapp.error_handlers: + handler = req.subapp.error_handlers[exc.__class__] + elif exc.__class__ in self.error_handlers: + handler = self.error_handlers[exc.__class__] else: + # walk up the exception class hierarchy to try to find + # a handler for c in mro(exc.__class__)[1:]: - if c in self.error_handlers: - exc_class = c + if req.subapp and c in req.subapp.error_handlers: + handler = req.subapp.error_handlers[c] break - if exc_class: + elif c in self.error_handlers: + handler = self.error_handlers[c] + break + if handler: try: - res = await invoke_handler( - self.error_handlers[exc_class], req, exc) + res = await invoke_handler(handler, req, exc) except Exception as exc2: # pragma: no cover print_exception(exc2) if res is None: - if 500 in self.error_handlers: - res = await invoke_handler( - self.error_handlers[500], req) - else: - res = 'Internal server error', 500 + # if there is still no response, issue a 500 error + res = await self.error_response( + req, 500, 'Internal server error') else: - if 400 in self.error_handlers: - res = await invoke_handler(self.error_handlers[400], req) - else: - res = 'Bad request', 400 + # if the request could not be parsed, issue a 400 error + res = await self.error_response(req, 400, 'Bad request') if isinstance(res, tuple): res = Response(*res) elif not isinstance(res, Response): res = Response(res) if not after_request_handled: - for handler in self.after_error_request_handlers: + # if the request did not finish due to an error, invoke the after + # error request handler + for handler in self.get_request_handlers( + req, 'after_error_request', True): res = await invoke_handler( handler, req, res) or res res.is_head = (req and req.method == 'HEAD') diff --git a/tests/test_microdot.py b/tests/test_microdot.py index ebd4d58..5816862 100644 --- a/tests/test_microdot.py +++ b/tests/test_microdot.py @@ -794,7 +794,7 @@ class TestMicrodot(unittest.TestCase): @subapp.route('/app') def index(req): - return req.g.before + ':foo' + return req.g.before + ':' + req.url_prefix app = Microdot() app.mount(subapp, url_prefix='/sub') @@ -811,4 +811,107 @@ class TestMicrodot(unittest.TestCase): self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'before:foo:after') + self.assertEqual(res.text, 'before:/sub:after') + + def test_mount_local(self): + subapp1 = Microdot() + subapp2 = Microdot() + + @subapp1.before_request + def before1(req): + req.g.before += ':before1' + + @subapp1.after_error_request + def after_error1(req, res): + res.body += b':errorafter' + + @subapp1.errorhandler(ValueError) + def value_error(req, exc): + return str(exc), 400 + + @subapp1.route('/') + def index1(req): + raise ZeroDivisionError() + + @subapp1.route('/foo') + def foo(req): + return req.g.before + ':foo:' + req.url_prefix + + @subapp1.route('/err') + def err(req): + raise ValueError('err') + + @subapp1.route('/err2') + def err2(req): + class MyErr(ValueError): + pass + + raise MyErr('err') + + @subapp2.before_request + def before2(req): + req.g.before += ':before2' + + @subapp2.after_request + def after2(req, res): + res.body += b':after' + + @subapp2.errorhandler(405) + def method_not_found2(req): + return '405', 405 + + @subapp2.route('/bar') + def bar(req): + return req.g.before + ':bar:' + req.url_prefix + + @subapp2.route('/baz') + def baz(req): + abort(405) + + app = Microdot() + + @app.before_request + def before(req): + req.g.before = 'before-app' + + @app.after_request + def after(req, res): + res.body += b':after-app' + + app.mount(subapp1, local=True) + app.mount(subapp2, url_prefix='/sub', local=True) + + client = TestClient(app) + + res = self._run(client.get('/')) + self.assertEqual(res.status_code, 500) + self.assertEqual(res.text, 'Internal server error:errorafter') + + res = self._run(client.get('/foo')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], + 'text/plain; charset=UTF-8') + self.assertEqual(res.text, 'before-app:before1:foo::after-app') + + res = self._run(client.get('/err')) + self.assertEqual(res.status_code, 400) + self.assertEqual(res.text, 'err:errorafter') + + res = self._run(client.get('/err2')) + self.assertEqual(res.status_code, 400) + self.assertEqual(res.text, 'err:errorafter') + + res = self._run(client.get('/sub/bar')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], + 'text/plain; charset=UTF-8') + self.assertEqual(res.text, + 'before-app:before2:bar:/sub:after:after-app') + + res = self._run(client.post('/sub/bar')) + self.assertEqual(res.status_code, 405) + self.assertEqual(res.text, '405') + + res = self._run(client.get('/sub/baz')) + self.assertEqual(res.status_code, 405) + self.assertEqual(res.text, '405')