Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 19 additions & 3 deletions rolo/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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.
"""

Expand Down
21 changes: 19 additions & 2 deletions rolo/gateway/asgi.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
39 changes: 34 additions & 5 deletions rolo/gateway/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)


Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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``.
"""
Expand Down
21 changes: 20 additions & 1 deletion rolo/gateway/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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,
Expand All @@ -43,13 +49,26 @@ 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)

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)

Expand Down
88 changes: 84 additions & 4 deletions rolo/gateway/handlers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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://<server>/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()
Expand All @@ -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::

<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
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__(
Expand All @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion rolo/gateway/wsgi.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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",
Expand Down
Loading