diff --git a/.vscode/requests.http b/.vscode/requests.http index 7216f6e..63a96f6 100644 --- a/.vscode/requests.http +++ b/.vscode/requests.http @@ -8,6 +8,24 @@ POST http://localhost:5000/bound Content-Type: application/json X-Request-Id: bar +{ + "institution_id": ["1001"], + "filters": { + "account_id": "123456" + } +} + +### +GET http://localhost:5050/foo + +### +POST http://localhost:5050/broken + +### +POST http://localhost:5050/bound +Content-Type: application/json +X-Request-Id: bar + { "institution_id": ["1001"], "filters": { diff --git a/abstractions.py b/abstractions.py index 913b4f1..518275b 100644 --- a/abstractions.py +++ b/abstractions.py @@ -2,17 +2,27 @@ from dataclasses import dataclass, field -from tornado.httputil import HTTPServerRequest +from tornado.httputil import HTTPHeaders + + +@dataclass +class HTTPRequest: + url: str + path: str + query: str + method: str = 'GET' + headers: HTTPHeaders = field(default_factory=HTTPHeaders) + body: bytes = b'' @dataclass class HTTPResponse: status_code: int = 500 - headers: dict = field(default_factory=dict) + headers: HTTPHeaders = field(default_factory=HTTPHeaders) body: bytes = b'' error: Exception = None -AppDelegate = typing.Callable[[HTTPServerRequest], typing.Coroutine[typing.Any, typing.Any, HTTPResponse]] +AppDelegate = typing.Callable[[HTTPRequest], typing.Coroutine[typing.Any, typing.Any, HTTPResponse]] Middleware = typing.Callable[[AppDelegate], AppDelegate] diff --git a/core.py b/core.py index 047b2c3..1efc72d 100644 --- a/core.py +++ b/core.py @@ -1,59 +1,84 @@ -import asyncio import collections -from typing import Sequence +import typing -import tornado.httputil -import tornado.routing +import tornado.web -from abstractions import AppDelegate, Middleware +import abstractions from middleware.exception import convert_exception_to_response -class PipelineRouter(tornado.routing.ReversibleRuleRouter): - def __init__(self, rules=None, middleware: Sequence[Middleware] = None): - super().__init__(rules) - self.middleware = middleware if isinstance(middleware, collections.Sequence) else [] +def wrap_middleware(delegate: abstractions.AppDelegate, + middlewares: typing.Sequence[abstractions.Middleware]) -> abstractions.AppDelegate: + result = convert_exception_to_response(delegate) - def get_target_delegate(self, target, request, **target_params): - target_kwargs = target_params.get('target_kwargs') - return PipelineDelegate(request, middleware=self.middleware, **target_kwargs) + for middleware in reversed(middlewares): + result = convert_exception_to_response(middleware(result)) + return result -class PipelineDelegate(tornado.httputil.HTTPMessageDelegate): - request_handler: AppDelegate - def __init__(self, request: tornado.httputil.HTTPServerRequest, delegate: AppDelegate, - middleware: Sequence[Middleware], *args, **kwargs): - self.request = request +class BaseHandler(tornado.web.RequestHandler): + delegate: abstractions.AppDelegate + request_handler: abstractions.AppDelegate + + def initialize(self, **kwargs): + delegate = kwargs.pop('delegate') + middleware = self.settings.get('middleware') + + if not callable(delegate): + raise ValueError('delegate must be callable') + self.delegate = delegate - self.middleware = middleware - self._chunks = [] + self.request_handler = wrap_middleware(delegate, middleware) if isinstance(middleware, collections.Sequence) else delegate + + def _make_request(self) -> abstractions.HTTPRequest: + return abstractions.HTTPRequest( + url=self.request.uri, + path=self.request.path, + query=self.request.query, + method=self.request.method, + headers=dict(self.request.headers), + body=self.request.body + ) + + def _write_response(self, response: abstractions.HTTPResponse): + self.set_status(response.status_code) + + for k, v in response.headers.items(): + self.set_header(k, v) + + self.finish(response.body) + + async def _process_request(self, *args, **kwargs): + response = await self.request_handler(self._make_request()) + + self._write_response(response) + + async def get(self, *args, **kwargs): + await self._process_request(*args, **kwargs) + + async def post(self, *args, **kwargs): + await self._process_request(*args, **kwargs) + - self.load_middleware() +Route = typing.Tuple[str, abstractions.AppDelegate] - def load_middleware(self): - self.request_handler = convert_exception_to_response(self.delegate) - for middleware in reversed(self.middleware): - handler = middleware(self.request_handler) - self.request_handler = convert_exception_to_response(handler) +class TornadoService: + routes: typing.List[Route] - def data_received(self, chunk): - self._chunks.append(chunk) + def __init__(self, routes: typing.List[Route], middlewares: typing.Sequence[abstractions.Middleware] = None): + if not isinstance(routes, (list, tuple)): + raise ValueError('routes') - def finish(self): - self.request.body = b''.join(self._chunks) - self.request._parse_body() - asyncio.create_task(self.handle_request()) + self.routes = routes + self.middlewares = middlewares - def on_connection_close(self): - self._chunks = None + def make_application(self, **settings) -> tornado.web.Application: + handlers = [(path, BaseHandler, dict(delegate=delegate), next(iter(name), None)) for path, delegate, *name in + self.routes] + return tornado.web.Application(handlers, **{'middleware': self.middlewares, **settings}) - async def handle_request(self): - response = await self.request_handler(self.request) - reason = tornado.httputil.responses.get(response.status_code, 'Unknown') - await self.request.connection.write_headers( - tornado.httputil.ResponseStartLine('', response.status_code, reason), - tornado.httputil.HTTPHeaders(response.headers), - response.body) - self.request.connection.finish() + def run(self, port, host: str = "", **settings): + app = self.make_application(**settings) + app.listen(port, host) diff --git a/example/handlers/bound.py b/example/handlers/bound.py index a64d335..6280141 100644 --- a/example/handlers/bound.py +++ b/example/handlers/bound.py @@ -2,7 +2,7 @@ from marshmallow import Schema, fields -from abstractions import HTTPResponse +from abstractions import HTTPResponse, HTTPHeaders from handlers.bind_request import bind_arguments, Json, Header @@ -25,6 +25,6 @@ async def bound(request, data: RequestSchema, js: Json, message_id: Header('X-Re return HTTPResponse( status_code=200, - headers={'Contetnt-Type': 'application/json', 'Foo': message_id}, + headers=HTTPHeaders({'Content-Type': 'application/json', 'Foo': message_id}), body=json.dumps(data).encode() ) diff --git a/example/handlers/dependency.py b/example/handlers/dependency.py index d995402..8fc5048 100644 --- a/example/handlers/dependency.py +++ b/example/handlers/dependency.py @@ -1,14 +1,15 @@ -import tornado.httputil +import urllib.parse -from abstractions import HTTPResponse +from abstractions import HTTPResponse, HTTPRequest class HandlerWithDependency: def __init__(self, greeting: str): self.greeting = greeting - async def __call__(self, request: tornado.httputil.HTTPServerRequest) -> HTTPResponse: - name = request.query_arguments.get('name', [b''])[0].decode() + async def __call__(self, request: HTTPRequest) -> HTTPResponse: + query = urllib.parse.parse_qs(request.query) + name = query.get('name')[0] return HTTPResponse( status_code=200, body='{greeting}, {name}'.format(greeting=self.greeting, name=name).encode() diff --git a/example/handlers/wrapped.py b/example/handlers/wrapped.py index 1315e1c..cea1387 100644 --- a/example/handlers/wrapped.py +++ b/example/handlers/wrapped.py @@ -1,4 +1,4 @@ -from abstractions import HTTPResponse +from abstractions import HTTPResponse, HTTPHeaders from middleware.request_id import request_id_middleware @@ -6,5 +6,5 @@ async def wrapped(request): return HTTPResponse( status_code=200, - headers={'Content-Type': 'application/json', 'Foo': 'bar'}, + headers=HTTPHeaders({'Content-Type': 'application/json', 'Foo': 'bar'}), body=b'{"foo": "bar"}') diff --git a/example/main.py b/example/main.py index 6c096a8..7966bbd 100644 --- a/example/main.py +++ b/example/main.py @@ -1,20 +1,21 @@ import asyncio -from tornado.httpserver import HTTPServer - -from core import PipelineRouter, PipelineDelegate +from core import TornadoService from example.handlers.bound import bound from example.handlers.dependency import HandlerWithDependency from example.handlers.throwing import throw from example.handlers.wrapped import wrapped +from middleware import request_id if __name__ == "__main__": - HTTPServer(PipelineRouter([ - ('/hello', PipelineDelegate, {'delegate': HandlerWithDependency('hello')}), - ('/bound', PipelineDelegate, {'delegate': bound}), - ('/throw', PipelineDelegate, {'delegate': throw}), - ('.*', PipelineDelegate, {'delegate': wrapped}) - ])).listen(5000) + routes = [ + ('/hello', HandlerWithDependency('hello')), + ('/bound', bound), + ('/throw', throw), + ('.*', wrapped) + ] + + TornadoService(routes, [request_id.request_id_middleware]).run(5000) loop = asyncio.get_event_loop() loop.run_forever() diff --git a/example/routing_example.py b/example/routing_example.py index 5f5519f..ac2e442 100644 --- a/example/routing_example.py +++ b/example/routing_example.py @@ -3,11 +3,8 @@ import json import logging -import tornado.httputil -from tornado.httpserver import HTTPServer - -from abstractions import HTTPResponse, AppDelegate -from core import PipelineRouter +from abstractions import HTTPResponse, AppDelegate, HTTPRequest, HTTPHeaders +from core import TornadoService from handlers.bind_request import Json, Header, bind_arguments from middleware.request_id import request_id_middleware from routing import route @@ -34,19 +31,19 @@ async def bound(request, data: Json, message_id: Header('X-Request-Id')) -> HTTP data.update({'message_id': message_id}) return HTTPResponse( status_code=200, - headers={'Content-Type': 'application/json'}, + headers=HTTPHeaders({'Content-Type': 'application/json'}), body=json.dumps(data).encode() ) def logging_middleware(func: AppDelegate) -> AppDelegate: @functools.wraps(func) - async def wrapper(request: tornado.httputil.HTTPServerRequest) -> HTTPResponse: + async def wrapper(request: HTTPRequest) -> HTTPResponse: response = await func(request) logging.debug('{method} {path} {status} {error}'.format( method=request.method.upper(), - path=request.uri, + path=request.url, status=response.status_code, error=response.error )) @@ -57,10 +54,10 @@ async def wrapper(request: tornado.httputil.HTTPServerRequest) -> HTTPResponse: if __name__ == '__main__': - HTTPServer(PipelineRouter( + TornadoService( route.get_routes(), [logging_middleware, request_id_middleware] - )).listen(5050) + ).run(5050) loop = asyncio.get_event_loop() loop.run_forever() diff --git a/handlers/bind_request.py b/handlers/bind_request.py index 7838a64..d56718f 100644 --- a/handlers/bind_request.py +++ b/handlers/bind_request.py @@ -5,9 +5,8 @@ from typing import get_type_hints, Any, MutableSet from marshmallow.schema import SchemaMeta -from tornado.httputil import HTTPServerRequest -from abstractions import HTTPResponse, AppDelegate +from abstractions import HTTPRequest, HTTPResponse, AppDelegate class RequestArgumentResolver(metaclass=ABCMeta): @@ -16,7 +15,7 @@ def can_resolve_type(self, arg_type: type) -> bool: pass @abstractmethod - def resolve(self, request: HTTPServerRequest, arg_name: str, arg_type: type) -> Any: + def resolve(self, request: HTTPRequest, arg_name: str, arg_type: type) -> Any: pass @@ -42,7 +41,7 @@ class SchemaResolver(RequestArgumentResolver): def can_resolve_type(self, arg_type: type): return isinstance(arg_type, SchemaMeta) - def resolve(self, request: HTTPServerRequest, arg_name: str, arg_type: type): + def resolve(self, request: HTTPRequest, arg_name: str, arg_type: type): result, _ = arg_type(strict=True).loads(request.body) return result @@ -53,8 +52,8 @@ class JsonResolver(RequestArgumentResolver): def can_resolve_type(self, arg_type: type): return arg_type is Json - def resolve(self, request: HTTPServerRequest, arg_name: str, arg_type: type): - if request.headers['Content-Type'] == 'application/json' and request.method.upper() in self.ALLOWED_METHODS: + def resolve(self, request: HTTPRequest, arg_name: str, arg_type: type): + if request.headers.get('Content-Type') == 'application/json' and request.method.upper() in self.ALLOWED_METHODS: return json.loads(request.body) raise ArgumentResolveError(arg_type) @@ -64,7 +63,7 @@ class HeaderResolver(RequestArgumentResolver): def can_resolve_type(self, arg_type: type): return isinstance(arg_type, Header) - def resolve(self, request: HTTPServerRequest, arg_name: str, arg_type: Header): + def resolve(self, request: HTTPRequest, arg_name: str, arg_type: Header): return request.headers.get(arg_type.name, None) @@ -77,7 +76,7 @@ def __str__(self): return "Unexpected argument type: {arg_type}".format(arg_type=self.arg_type) -def _resolve_argument_value(request: HTTPServerRequest, arg_name, arg_type): +def _resolve_argument_value(request: HTTPRequest, arg_name, arg_type): for resolver in ARGUMENT_RESOLVERS: if resolver.can_resolve_type(arg_type): return resolver.resolve(request, arg_name, arg_type) @@ -92,7 +91,7 @@ def bind_arguments(func) -> AppDelegate: :return: An `AppDelegate` deriving inner handler arguments from type hints and `HTTPServerRequest`. """ @functools.wraps(func) - async def wrapper(request: HTTPServerRequest) -> HTTPResponse: + async def wrapper(request: HTTPRequest) -> HTTPResponse: annotations = get_type_hints(func) annotations.pop('return', None) diff --git a/middleware/exception.py b/middleware/exception.py index a278829..caeff50 100644 --- a/middleware/exception.py +++ b/middleware/exception.py @@ -1,12 +1,10 @@ import functools -import tornado.httputil - -from abstractions import HTTPResponse +from abstractions import HTTPResponse, HTTPRequest from handlers.bind_request import ArgumentResolveError -def response_for_exception(request: tornado.httputil.HTTPServerRequest, error: Exception) -> HTTPResponse: +def response_for_exception(request: HTTPRequest, error: Exception) -> HTTPResponse: if isinstance(error, ArgumentResolveError): return HTTPResponse(status_code=400, error=error) @@ -15,7 +13,7 @@ def response_for_exception(request: tornado.httputil.HTTPServerRequest, error: E def convert_exception_to_response(func): @functools.wraps(func) - async def wrapper(request: tornado.httputil.HTTPServerRequest) -> HTTPResponse: + async def wrapper(request: HTTPRequest) -> HTTPResponse: try: return await func(request) except Exception as e: diff --git a/middleware/request_id.py b/middleware/request_id.py index ce9845c..1038cff 100644 --- a/middleware/request_id.py +++ b/middleware/request_id.py @@ -1,14 +1,12 @@ import functools import uuid -import tornado.httputil - -from abstractions import AppDelegate, HTTPResponse +from abstractions import AppDelegate, HTTPResponse, HTTPRequest def request_id_middleware(delegate: AppDelegate) -> AppDelegate: @functools.wraps(delegate) - async def wrapper(request: tornado.httputil.HTTPServerRequest) -> HTTPResponse: + async def wrapper(request: HTTPRequest) -> HTTPResponse: request_id = request.headers.get('X-Request-Id', str(uuid.uuid4())) request.request_id = request_id response = await delegate(request) diff --git a/routing.py b/routing.py index f73b6a7..bb0e862 100644 --- a/routing.py +++ b/routing.py @@ -2,8 +2,6 @@ from tornado.routing import PathMatches -from core import PipelineDelegate - class MethodMatches(PathMatches): """Matches request path and maethod.""" @@ -27,9 +25,9 @@ class Router: def __call__(self, path: str, name=None, method: str = None): def wrapper(func): if method is None: - self._routes.append((path, PipelineDelegate, {'delegate': func}, name)) + self._routes.append((path, func, name)) else: - self._routes.append((MethodMatches(path, method), PipelineDelegate, {'delegate': func}, name)) + self._routes.append((MethodMatches(path, method), func, name)) return func return wrapper diff --git a/tests/test_bind_request.py b/tests/test_bind_request.py index f8f2abd..3094afd 100644 --- a/tests/test_bind_request.py +++ b/tests/test_bind_request.py @@ -1,9 +1,9 @@ import json from marshmallow import fields, Schema -from tornado.httputil import HTTPServerRequest, HTTPHeaders from tornado.testing import AsyncTestCase, gen_test +from abstractions import HTTPRequest from handlers.bind_request import bind_arguments, Json, Header @@ -28,14 +28,19 @@ async def foo1(request, data: RequestSchema, js: Json, message_id: Header('X-Req class BindArgumentsTests(AsyncTestCase): @gen_test async def test_foo(self): - - data = { "institution_id": ["1001"], "filters": { "account_id": "123456" } } - request = HTTPServerRequest(uri='/', body=json.dumps(data), headers=HTTPHeaders({'X-Request-Id': 'foo'})) + request = HTTPRequest( + url='/', + body=json.dumps(data), + headers={'X-Request-Id': 'foo', 'Content-Type': 'application/json'}, + path='', + query='', + method='POST' + ) await foo1(request)