diff --git a/docs/router.md b/docs/router.md index 5a2545f..564481d 100644 --- a/docs/router.md +++ b/docs/router.md @@ -150,8 +150,10 @@ def update_item(request: Request, item_id: int, item: Item): return {"item_name": item.name, "item_id": item_id} -router = Router() +router = Router(dispatcher=handler_dispatcher()) router.add(read_root) router.add(read_item) router.add(update_item) ``` + +Pydantic support in the Router is automatically enabled if rolo finds that pydantic is installed. \ No newline at end of file diff --git a/rolo/client.py b/rolo/client.py index 749e429..7c23007 100644 --- a/rolo/client.py +++ b/rolo/client.py @@ -41,9 +41,9 @@ def __exit__(self, *args): class _VerifyRespectingSession(requests.Session): """ - A class which wraps requests.Session to circumvent https://github.com/psf/requests/issues/3829. + A class which wraps ``requests.Session`` to circumvent https://github.com/psf/requests/issues/3829. This ensures that if `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` are set, the request does not perform the TLS - verification if `session.verify` is set to `False. + verification if ``session.verify`` is set to ``False``. """ def merge_environment_settings(self, url, proxies, stream, verify, *args, **kwargs): @@ -56,10 +56,27 @@ def merge_environment_settings(self, url, proxies, stream, verify, *args, **kwar class SimpleRequestsClient(HttpClient): + """ + A ``HttpClient`` implementation that uses the ``requests`` library. Specifically it manages a ``requests.Session`` + object that is used to make HTTP requests according to the passed ``rolo.Request`` object. + """ + session: requests.Session follow_redirects: bool def __init__(self, session: requests.Session = None, follow_redirects: bool = True): + """ + Creates a new ``SimpleRequestsClient``. Use it to make HTTP requests with the requests library. Example use:: + + with SimpleRequestsClient() as client: + response = client.request(Request("GET", "https://httpbin.org/get")) + + You may also pass your own Session object, but note that will be closed if you call ``client.close()``. + + :param session: An optional ``requests.Session`` object. If none is passed, one will be created. Note that + calling ``client.close()`` will also close the session. + :param follow_redirects: whether to follow HTTP redirects when making http calls. + """ self.session = session or _VerifyRespectingSession() self.follow_redirects = follow_redirects @@ -97,7 +114,6 @@ def request(self, request: Request, server: str | None = None) -> Response: :param request: the request to perform :param server: the URL to send the request to, which defaults to the host component of the original Request. - :param allow_redirects: allow the request to follow redirects :return: the response. """ diff --git a/rolo/gateway/asgi.py b/rolo/gateway/asgi.py index 3372e09..9cb1a30 100644 --- a/rolo/gateway/asgi.py +++ b/rolo/gateway/asgi.py @@ -1,9 +1,11 @@ +"""This module provides adapter code to expose a ``Gateway`` as an ASGI compatible application.""" import asyncio import concurrent.futures.thread from asyncio import AbstractEventLoop from typing import Optional from rolo.asgi import ASGIAdapter, ASGILifespanListener +from rolo.websocket.adapter import WebSocketListener from rolo.websocket.request import WebSocketRequest from .gateway import Gateway @@ -31,7 +33,8 @@ def _adjust_thread_count(self) -> None: class AsgiGateway: """ - Exposes a Gateway as an ASGI3 application. Under the hood, it uses a WsgiGateway with a threading async/sync bridge. + Exposes a Gateway as an ASGI3 application. Under the hood, it uses a ``WsgiGateway`` with a threading async/sync + bridge. """ default_thread_count = 1000 @@ -44,8 +47,22 @@ def __init__( event_loop: Optional[AbstractEventLoop] = None, threads: int = None, lifespan_listener: Optional[ASGILifespanListener] = None, - websocket_listener=None, + websocket_listener: Optional[WebSocketListener] = None, ) -> None: + """ + Wrap a ``Gateway`` and expose it as an ASGI3 application. + + :param gateway: The Gateway instance to serve + :param event_loop: optionally, you can pass your own event loop that is used by the gateway to process + requests. By default, the global event loop via ``asyncio.get_event_loop()`` will be used. + :param threads: Max number of threads used by the thread pool that is used to execute co-routines. Defaults to + ``AsgiGateway.default_thread_count`` set to 1000. + :param lifespan_listener: Optional ``ASGILifespanListener`` callback that is called on ASGI webserver lifecycle + events. + :param websocket_listener: Optional ``WebSocketListener``, a rolo callback that handles incoming websocket + connections. By default, the listener invokes ``Gateway.accept``, so there's rarely a reason you would need + a custom one. + """ self.gateway = gateway self.event_loop = event_loop or asyncio.get_event_loop() diff --git a/rolo/gateway/chain.py b/rolo/gateway/chain.py index ce4575f..c36cb1f 100644 --- a/rolo/gateway/chain.py +++ b/rolo/gateway/chain.py @@ -18,9 +18,31 @@ class RequestContext: """ A request context holds the original incoming HTTP Request and arbitrary data. It is passed through the handler chain and allows handlers to communicate. + + You can use a ``RequestContext`` instance to store any attributes you want. Example:: + + def handle(chain: HandlerChain, context: RequestContext, response: Response): + if context.request.headers.get("x-some-flag") == "true": + context.some_flag = True + else: + context.some_flag = False + + Note though that, unless ``some_flag`` was set earlier, accessing ``context.some_flag`` will raise an + ``AttributeError``. You can safely get the attribute via ``context.get("some_flag")``, which will returns + ``None`` if the attribute does not exist. + + If you want type hints, you can subclass the ``RequestContext`` and then set the context class in your + ``Gateway``. Example:: + + class MyRequestContext(RequestContext): + some_flag: bool + + gateway = Gateway(context_class=MyRequestContext) + """ request: Request + """The underlying HTTP request coming from the web server.""" def __init__(self, request: Request = None): self.request = request @@ -36,6 +58,12 @@ def __getattr__(self, item): raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}") def get(self, key: str) -> t.Optional[t.Any]: + """ + Safely access an arbitrary attribute of the ``RequestContext``. + + :param key: The key of the attribute (like ``some_flag``) + :return: The value of the attribute, or None if the attribute does not exist. + """ return self.__dict__.get(key) @@ -277,12 +305,13 @@ def _call_exception_handlers(self, e, response): class CompositeHandler: """ A handler that sequentially invokes a list of Handlers, forming a stripped-down version of a handler - chain. + chain. Stop and termination conditions are determined by the ``HandlerChain`` instance that is being called. """ handlers: list[Handler] + """List of handlers in this composite handler. Handlers are invoked in order they appear in the list.""" - def __init__(self, return_on_stop=True) -> None: + def __init__(self, return_on_stop: bool = True) -> None: """ Creates a new composite handler with an empty handler list. @@ -321,8 +350,8 @@ def __call__(self, chain: HandlerChain, context: RequestContext, response: Respo class CompositeExceptionHandler: """ - A exception handler that sequentially invokes a list of ExceptionHandler instances, forming a - stripped-down version of a handler chain for exception handlers. + An exception handler that sequentially invokes a list of ExceptionHandler instances, forming a + stripped-down version of a handler chain for exception handlers. Works analogous to the ``CompositeHandler``. """ handlers: t.List[ExceptionHandler] @@ -361,7 +390,7 @@ def __call__( class CompositeResponseHandler(CompositeHandler): """ - A CompositeHandler that by default does not return on stop, meaning that all handlers in the composite + A ``CompositeHandler`` that by default does not return on stop, meaning that all handlers in the composite will be executed, even if one of the handlers has called ``chain.stop()``. This mimics how response handlers are executed in the ``HandlerChain``. """ diff --git a/rolo/gateway/gateway.py b/rolo/gateway/gateway.py index 538b062..33b5461 100644 --- a/rolo/gateway/gateway.py +++ b/rolo/gateway/gateway.py @@ -11,7 +11,7 @@ class Gateway: """ - A gateway creates new HandlerChain instances for each request and processes requests through them. + A Gateway creates new ``HandlerChain`` instances for each request and processes requests through them. """ request_handlers: list[Handler] @@ -35,6 +35,12 @@ def __init__( self.context_class = context_class or RequestContext def new_chain(self) -> HandlerChain: + """ + Factory method for ``HandlerChain`` instances. This is called by ``process`` on every request, and can be + overwritten by subclasses if they have custom ``HandlerChains``. + + :return: A new HandlerChain instance to handle one single request/response cycle. + """ return HandlerChain( self.request_handlers, self.response_handlers, @@ -43,6 +49,14 @@ def new_chain(self) -> HandlerChain: ) def process(self, request: Request, response: Response): + """ + Called by the webserver integration, process creates a new ``HandlerChain``, a new ``RequestContext``, wraps + the given request in the context, and then hands it to the handler chain via ``HandlerChain.handle``. + + :param request: The HTTP request coming from the webserver. + :param response: The response object to be populated for + :return: + """ chain = self.new_chain() context = self.context_class(request) @@ -50,6 +64,11 @@ def process(self, request: Request, response: Response): chain.handle(context, response) def accept(self, request: WebSocketRequest): + """ + Similar to ``process``, this method is called by webservers specifically for ``WebSocketRequest``. + + :param request: The incoming websocket request. + """ response = Response(status=101) self.process(request, response) diff --git a/rolo/gateway/handlers.py b/rolo/gateway/handlers.py index 43b8cdb..f6bb4de 100644 --- a/rolo/gateway/handlers.py +++ b/rolo/gateway/handlers.py @@ -1,7 +1,7 @@ """Several gateway handlers""" import typing as t -from werkzeug.datastructures import Headers +from werkzeug.datastructures import Headers, MultiDict from werkzeug.exceptions import HTTPException, NotFound from rolo.response import Response @@ -12,7 +12,15 @@ class RouterHandler: """ - Adapter to serve a ``Router`` as a ``Handler``. + Adapter to serve a ``Router`` as a ``Handler``. The handler takes from the ``RequestContext`` the ``Request`` + object, and dispatches it via ``Router.dispatch``. The ``Response`` object that call returns, is then merged into + the ``Response`` object managed by the handler chain. If the router returns a response, the ``HandlerChain`` is + stopped. + + If the dispatching raises a ``NotFound`` (because there is no route in the Router to match the request), the chain + will respond with 404 and "not found" as string, given that ``respond_not_found`` is set to True. This is to + provide a simple, default way to handle 404 messages. In most cases, you will want your own 404 error handling + in the handler chain, which is why ``respond_not_found`` is set to ``False`` by default. """ router: Router @@ -34,14 +42,40 @@ def __call__(self, chain: HandlerChain, context: RequestContext, response: Respo class EmptyResponseHandler: """ - Handler that creates a default response if the response in the context is empty. + Handler that creates a default response if the response in the context is empty. A response is considered empty + if its status code is set to 0 or None, and the response body is empty. Since ``Response`` is initialized with a + 200 status code by default, you'll have to explicitly set the status code to 0 or None in your handler chain. + For example:: + + def init_response(chain, context, response): + response.status_code = 0 + + def handle_request(chain, context, response): + if context.request.path == "/hello" + chain.respond("hello world") + + gateway = Gateway(request_handlers=[ + init_response, + handle_request, + EmptyResponseHandler(404, body=b"not found") + ]) + + This handler chain will return 404 for all requests except those going to ``http:///hello``. """ status_code: int body: bytes - headers: dict + headers: t.Mapping[str, t.Any] | MultiDict[str, t.Any] | Headers def __init__(self, status_code: int = 404, body: bytes = None, headers: Headers = None): + """ + Creates a new EmptyResponseHandler that will populate the ``Response`` object with the given values, if the + response was previously considered empty. + + :param status_code: The HTTP status code to use (defaults to 404) + :param body: The body to use as response (defaults to empty string) + :param headers: The additional headers to set for the response + """ self.status_code = status_code self.body = body or b"" self.headers = headers or Headers() @@ -60,7 +94,52 @@ def populate_default_response(self, response: Response): class WerkzeugExceptionHandler: + """ + Convenience handler that translates werkzeug exceptions into HTML or JSON responses. Werkzeug exceptions are + raised by ``Router`` instances, but can also be useful to use in your own handlers. These exceptions already + contain a human-readable name, description, and an HTML template that can be rendered. The handler also supports + a rolo-specific JSON format. + + For example, this handler chain:: + + from werkzeug.exceptions import NotFound + + def raise_not_found(chain, context, response): + raise NotFound() + + gateway = Gateway( + request_handlers=[ + raise_not_found, + ], + exception_handlers=[ + WerkzeugExceptionHandler(output_format="html"), + ] + ) + + Would always yield the following HTML:: + + + + 404 Not Found +

Not Found

+ The requested URL was not found on the server. If you entered the URL manually please check + your spelling and try again. + + Or if you use JSON (via ``WerkzeugExceptionHandler(output_format="json")``):: + + { + "code": 404, + "description": "The requested URL was not found on the server. [...]" + } + """ + def __init__(self, output_format: t.Literal["json", "html"] = None) -> None: + """ + Create a new ``WerkzeugExceptionHandler`` to use as exception handler in a handler chain. + + :param output_format: The output format in which to render the exception into the response (either ``html`` + or ``json``), defaults to ``json``. + """ self.format = output_format or "json" def __call__( @@ -78,6 +157,7 @@ def __call__( chain.respond( status_code=exception.code, headers=headers, + # TODO: add name payload={"code": exception.code, "description": exception.description}, ) else: diff --git a/rolo/gateway/wsgi.py b/rolo/gateway/wsgi.py index 9852c5b..d88d39f 100644 --- a/rolo/gateway/wsgi.py +++ b/rolo/gateway/wsgi.py @@ -1,3 +1,4 @@ +"""This module contains adapter code that exposes a ``Gateway`` as a WSGI application.""" import typing as t from werkzeug.datastructures import Headers, MultiDict @@ -17,7 +18,9 @@ class WsgiGateway: """ - Exposes a ``Gateway`` as a WSGI application. + Exposes a ``Gateway`` as a WSGI application. This adapter creates from an incoming WSGIEnvironment dictionary a + ``Request`` object, as well as new ``Response`` object, and invokes ``Gateway.process(request, response)``. + The populated ``Response`` object is then used to invoke the ``start_response`` handler. """ gateway: Gateway @@ -29,6 +32,14 @@ def __init__(self, gateway: Gateway) -> None: def __call__( self, environ: "WSGIEnvironment", start_response: "StartResponse" ) -> t.Iterable[bytes]: + """ + Implements the WSGI application interface, which takes a WSGI environment dictionary, and the start response + callback. These are all WSGI concepts. + + :param environ: The WSGI environment. + :param start_response: The WSGI StartResponse callback. + :return: + """ # create request from environment LOG.debug( "%s %s%s", diff --git a/rolo/routing/handler.py b/rolo/routing/handler.py index cd83e3e..3ffb413 100644 --- a/rolo/routing/handler.py +++ b/rolo/routing/handler.py @@ -25,6 +25,7 @@ dict[str, t.Any], # a JSON dict list[t.Any], ] +"""All types that can be returned by a handler that can be serialized into a ``Response`` object.""" class Handler(t.Protocol): @@ -38,7 +39,7 @@ class Handler(t.Protocol): def my_route(request: Request, organization: str, repo: str): return {"something": "returned as json response"} - router = Router(dispatcher=handler_dispatcher) + router = Router(dispatcher=handler_dispatcher()) router.add("//", endpoint=my_route) """ @@ -55,6 +56,11 @@ def __call__(self, request: Request, **kwargs) -> ResultValue: class HandlerDispatcher: + """ + Adapter code between the default ``Router.dispatch`` interface, and the ``Handler`` protocol. See ``Handler`` for + more information. + """ + def __init__(self, json_encoder: t.Type[json.JSONEncoder] = None): self.json_encoder = json_encoder @@ -73,6 +79,14 @@ def invoke_endpoint( return endpoint(request, **request_args) def to_response(self, value: ResultValue) -> Response: + """ + Serializes the given ``ResultValue`` (the thing a handler returns) to a ``Response`` object. If the value + is a string or byte value, it will be set directly as response body. If the value is a dict or a list, + they will be serialized into JSON data and an ``application/json`` response will be returned. + + :param value: The value the handler returned + :return: a werkzeug-compatible Response object + """ if isinstance(value, WerkzeugResponse): return value @@ -95,7 +109,25 @@ def populate_response(self, response: Response, value: ResultValue): def handler_dispatcher(json_encoder: t.Type[json.JSONEncoder] = None) -> Dispatcher[Handler]: """ - Creates a Dispatcher that treats endpoints like callables of the ``Handler`` Protocol. + Creates a Dispatcher that treats endpoints like callables of the ``Handler`` Protocol. Example:: + + def my_route(request: Request, organization: str, repo: str): + return {"something": "returned as json response"} + + router = Router(dispatcher=handler_dispatcher()) + router.add("//", endpoint=my_route) + + Handlers added to a Router that uses a handler dispatcher can return a variety of types. For example, you can + also return string or byte content directly, which will use a 200 status code by default:: + + def my_route(request: Request, organization: str, repo: str): + return "returned as text response" + + Or, you can also return a manually created ``Response`` object: + + def my_route(request: Request, organization: str, repo: str): + return Response("returned as text response", status_code=200) + :param json_encoder: optionally the json encoder class to use for translating responses :return: a new dispatcher diff --git a/rolo/routing/pydantic.py b/rolo/routing/pydantic.py index cc86ba1..6163fe3 100644 --- a/rolo/routing/pydantic.py +++ b/rolo/routing/pydantic.py @@ -60,7 +60,10 @@ def _try_parse_pydantic_request_body( class PydanticHandlerDispatcher(HandlerDispatcher): """ - Special HandlerDispatcher that knows how to serialize and deserialize pydantic models. + Special ``HandlerDispatcher`` that knows how to serialize and deserialize pydantic models. Using this dispatcher + in your router will make it possible to use pydantic models in your handlers's signature. The user does not need to + instantiate this dispatcher directly. It's returned by ``handler_dispatcher()`` if ``config.ENABLE_PYDANTIC=True``, + which is the case if pydantic is in the system path. """ def invoke_endpoint( diff --git a/rolo/routing/router.py b/rolo/routing/router.py index 54a7bf1..0511d23 100644 --- a/rolo/routing/router.py +++ b/rolo/routing/router.py @@ -127,6 +127,29 @@ def __init__( dispatcher: Dispatcher[E] = None, converters: t.Mapping[str, t.Type[BaseConverter]] = None, ): + """ + Create a new Router that dispatches requests based on rules to endpoints (methods). How endpoints are invoked + is controlled by dispatchers. + + The default dispatcher ``call_endpoint`` invokes the endpoint with the request and the arguments extracted from + the URL, and expects a ``Response`` object in return:: + + @route("/greet/") + def handler(request: Request, args: dict[str, Any]) -> Response: + name = args["name"] # if called with ``/greet/foo`` will contain "foo" + return Response(f"hello {name}", 200) + + If you want a flask-style dispatching, you can use ``handler_dispatcher()`` as dispatcher. This would be + equivalent to above:: + + @route("/greet/") + def handler(request: Request, name: string) -> Response: + return f"hello {name}" + + :param dispatcher: The dispatcher to use, the default is ``call_endpoint``. + :param converters: Werkzeug converters convert URL patterns to endpoint arguments. Default converters defined + in ``default_converters`` will always be added. + """ if converters is None: converters = dict(self.default_converters) else: diff --git a/rolo/routing/rules.py b/rolo/routing/rules.py index 182f342..a8cffc0 100644 --- a/rolo/routing/rules.py +++ b/rolo/routing/rules.py @@ -23,6 +23,10 @@ def __call__(self, *args, **kwargs): class WithHost(RuleFactory): + """ + Rule that dispatches requests based on host headers. + """ + def __init__(self, host: str, rules: t.Iterable[RuleFactory]) -> None: self.host = host self.rules = rules @@ -36,6 +40,10 @@ def get_rules(self, map: Map) -> t.Iterator[Rule]: class RuleGroup(RuleFactory): + """ + Wraps an iterable of ``RuleFactory`` objects into a single ``RuleFactory``. + """ + def __init__(self, rules: t.Iterable[RuleFactory]): self.rules = rules @@ -221,7 +229,7 @@ def get_rules(self, map: Map) -> t.Iterable[Rule]: class _EndpointsObject(RuleFactory): """ - Scans the given object for members that can be used as a `RouteEndpoint` and yields them as rules. + Scans the given object for members that can be used as a ``RouteEndpoint`` and yields them as rules. """ def __init__(self, obj: object): diff --git a/rolo/testing/pytest.py b/rolo/testing/pytest.py index 5148235..3ea7a4c 100644 --- a/rolo/testing/pytest.py +++ b/rolo/testing/pytest.py @@ -21,6 +21,7 @@ from rolo.websocket.adapter import WebSocketListener if typing.TYPE_CHECKING: + from _typeshed.wsgi import WSGIApplication from hypercorn.typing import ASGIFramework @@ -31,6 +32,8 @@ class ServerInfo(Protocol): class Server(ServerInfo): + """the BaseWSGIServer satisfies this protocol.""" + def shutdown(self): ... @@ -44,9 +47,15 @@ class _ServerInfo: @pytest.fixture def serve_wsgi_app(): + """ + Factory fixture that can be called to serve WSGI apps for the duration of the fixture. They are served on the + host and port passed to the factory. The factory returns a ``BaseWSGIServer``. + """ servers: list[serving.BaseWSGIServer] = [] - def _serve(app, host: str = "localhost", port: int = None) -> serving.BaseWSGIServer | Server: + def _serve( + app: "WSGIApplication", host: str = "localhost", port: int = None + ) -> serving.BaseWSGIServer | Server: srv = serving.make_server(host, port or 0, app, threaded=True) name = threading._newname("test-server-%d") threading.Thread(target=srv.serve_forever, name=name, daemon=True).start() @@ -72,6 +81,9 @@ def wsgi_router_server(serve_wsgi_app) -> tuple[Router, serving.BaseWSGIServer | @pytest.fixture() def serve_asgi_app(): + """ + Factory fixture to serve an ASGI app in a hypercorn server. + """ import hypercorn import hypercorn.asyncio diff --git a/rolo/websocket/adapter.py b/rolo/websocket/adapter.py index 49d7e0f..928867e 100644 --- a/rolo/websocket/adapter.py +++ b/rolo/websocket/adapter.py @@ -6,7 +6,7 @@ from werkzeug.datastructures import Headers WebSocketEnvironment: t.TypeAlias = t.Dict[str, t.Any] -"""Special WSGIEnvironment that has a ``rolo.websocket`` key that stores a `Websocket` instance.""" +"""Special WSGIEnvironment that has a ``rolo.websocket`` key that stores a ``Websocket`` instance.""" class Event: