Skip to content

Commit

Permalink
feat!: Make route handlers functional decorators (#3436)
Browse files Browse the repository at this point in the history
* make route handlers functional decorators
  • Loading branch information
provinzkraut authored May 25, 2024
1 parent 8aae5d8 commit 4dc78a8
Show file tree
Hide file tree
Showing 37 changed files with 1,443 additions and 1,019 deletions.
10 changes: 5 additions & 5 deletions docs/release-notes/2.x-changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3057,7 +3057,7 @@
:pr: 1647

Dependencies can now be used in the
:class:`~litestar.handlers.websocket_listener` hooks
:func:`~litestar.handlers.websocket_listener` hooks
``on_accept``, ``on_disconnect`` and the ``connection_lifespan`` context
manager. The ``socket`` parameter is therefore also not mandatory anymore in
those callables.
Expand Down Expand Up @@ -3208,7 +3208,7 @@
:issue: 1615

A bug was fixed that would cause a type error when using a
:class:`websocket_listener <litestar.handlers.websocket_listener>`
:func:`websocket_listener <litestar.handlers.websocket_listener>`
in a ``Controller``

.. change:: Add ``connection_accept_handler`` to ``websocket_listener``
Expand All @@ -3217,7 +3217,7 @@
:issue: 1571

Add a new ``connection_accept_handler`` parameter to
:class:`websocket_listener <litestar.handlers.websocket_listener>`,
:func:`websocket_listener <litestar.handlers.websocket_listener>`,
which can be used to customize how a connection is accepted, for example to
add headers or subprotocols

Expand Down Expand Up @@ -3305,7 +3305,7 @@
appropriate event hooks - to use a context manager.

The ``connection_lifespan`` argument was added to the
:class:`WebSocketListener <litestar.handlers.websocket_listener>`, which accepts
:func:`WebSocketListener <litestar.handlers.websocket_listener>`, which accepts
an asynchronous context manager, which can be used to handle the lifespan of
the socket.

Expand Down Expand Up @@ -3419,7 +3419,7 @@
:pr: 1518

Support for DTOs has been added to :class:`WebSocketListener <litestar.handlers.WebsocketListener>` and
:class:`WebSocketListener <litestar.handlers.websocket_listener>`. A ``dto`` and ``return_dto`` parameter has
:func:`WebSocketListener <litestar.handlers.websocket_listener>`. A ``dto`` and ``return_dto`` parameter has
been added, providing the same functionality as their route handler counterparts.

.. change:: DTO based serialization plugin
Expand Down
36 changes: 36 additions & 0 deletions docs/release-notes/whats-new-3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,39 @@ If you were relying on this utility, you can define it yourself as follows:
def is_sync_or_async_generator(obj: Any) -> bool:
return isgeneratorfunction(obj) or isasyncgenfunction(obj)
Removal of semantic HTTP route handler classes
-----------------------------------------------

The semantic ``HTTPRouteHandler`` classes have been removed in favour of functional
decorators. ``route``, ``get``, ``post``, ``patch``, ``put``, ``head`` and ``delete``
are now all decorator functions returning :class:`~.handlers.HTTPRouteHandler`
instances.

As a result, customizing the decorators directly is not possible anymore. Instead, to
use a route handler decorator with a custom route handler class, the ``handler_class``
parameter to the decorator function can be used:

Before:

.. code-block:: python
class my_get_handler(get):
... # custom handler
@my_get_handler()
async def handler() -> Any:
...
After:

.. code-block:: python
class MyHTTPRouteHandler(HTTPRouteHandler):
... # custom handler
@get(handler_class=MyHTTPRouteHandler)
async def handler() -> Any:
...
54 changes: 28 additions & 26 deletions docs/usage/routing/handlers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ handler :term:`decorators <decorator>` exported from Litestar.
For example:

.. code-block:: python
:caption: Defining a route handler by decorating a function with the :class:`@get() <.handlers.get>` :term:`decorator`
:caption: Defining a route handler by decorating a function with the :func:`@get() <.handlers.get>` :term:`decorator`
from litestar import get
Expand Down Expand Up @@ -146,12 +146,11 @@ There are several reasons for why this limitation is enforced:
HTTP route handlers
-------------------
The most commonly used route handlers are those that handle HTTP requests and responses.
These route handlers all inherit from the :class:`~.handlers.HTTPRouteHandler` class, which is aliased as the
:term:`decorator` called :func:`~.handlers.route`:
The :class:`~.handlers.HTTPRouteHandler` is used to handle HTTP requests, and can be
created with the :func:`~.handlers.route` :term:`decorator`:
.. code-block:: python
:caption: Defining a route handler by decorating a function with the :class:`@route() <.handlers.route>`
:caption: Defining a route handler by decorating a function with the :func:`@route() <.handlers.route>`
:term:`decorator`
from litestar import HttpMethod, route
Expand All @@ -160,20 +159,24 @@ These route handlers all inherit from the :class:`~.handlers.HTTPRouteHandler` c
@route(path="/some-path", http_method=[HttpMethod.GET, HttpMethod.POST])
async def my_endpoint() -> None: ...
As mentioned above, :func:`@route() <.handlers.route>` is merely an alias for ``HTTPRouteHandler``,
thus the below code is equivalent to the one above:
The same can be achieved without a decorator, by using ``HTTPRouteHandler`` directly:
.. code-block:: python
:caption: Defining a route handler by decorating a function with the
:class:`HTTPRouteHandler <.handlers.HTTPRouteHandler>` class
:caption: Defining a route handler creating an instance of
:class:`HTTPRouteHandler <.handlers.HTTPRouteHandler>`
from litestar import HttpMethod
from litestar.handlers.http_handlers import HTTPRouteHandler
@HTTPRouteHandler(path="/some-path", http_method=[HttpMethod.GET, HttpMethod.POST])
async def my_endpoint() -> None: ...
handler = HTTPRouteHandler(
path="/some-path",
http_method=[HttpMethod.GET, HttpMethod.POST],
fn=my_endpoint
)
Semantic handler :term:`decorators <decorator>`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand All @@ -189,8 +192,8 @@ which correlates with their name:
* :func:`@post() <.handlers.post>`
* :func:`@put() <.handlers.put>`
These are used exactly like :func:`@route() <.handlers.route>` with the sole exception that you cannot configure the
:paramref:`~.handlers.HTTPRouteHandler.http_method` :term:`kwarg <argument>`:
These are used exactly like :func:`@route() <.handlers.route>` with the sole exception that you don't need to configure
the :paramref:`~.handlers.HTTPRouteHandler.http_method` :term:`kwarg <argument>`:
.. dropdown:: Click to see the predefined route handlers
Expand Down Expand Up @@ -240,11 +243,6 @@ These are used exactly like :func:`@route() <.handlers.route>` with the sole exc
@delete(path="/resources/{pk:int}")
async def delete_resource(pk: int) -> None: ...
Although these :term:`decorators <decorator>` are merely subclasses of :class:`~.handlers.HTTPRouteHandler` that pre-set
the :paramref:`~.handlers.HTTPRouteHandler.http_method`, using :func:`@get() <.handlers.get>`,
:func:`@patch() <.handlers.patch>`, :func:`@put() <.handlers.put>`, :func:`@delete() <.handlers.delete>`, or
:func:`@post() <.handlers.post>` instead of :func:`@route() <.handlers.route>` makes the code clearer and simpler.
Furthermore, in the OpenAPI specification each unique combination of HTTP verb (e.g. ``GET``, ``POST``, etc.) and path
is regarded as a distinct `operation <https://spec.openapis.org/oas/latest.html#operation-object>`_\ , and each
operation should be distinguished by a unique :paramref:`~.handlers.HTTPRouteHandler.operation_id` and optimally
Expand Down Expand Up @@ -277,22 +275,25 @@ A WebSocket connection can be handled with a :func:`@websocket() <.handlers.Webs
await socket.send_json({...})
await socket.close()
The :func:`@websocket() <.handlers.WebsocketRouteHandler>` :term:`decorator` is an alias of the
:class:`~.handlers.WebsocketRouteHandler` class. Thus, the below code is equivalent to the one above:
The :func:`@websocket() <.handlers.WebsocketRouteHandler>` :term:`decorator` can be used to create an instance of
:class:`~.handlers.WebsocketRouteHandler`. Therefore, the below code is equivalent to the one above:
.. code-block:: python
:caption: Using the :class:`~.handlers.WebsocketRouteHandler` class directly
from litestar import WebSocket
from litestar.handlers.websocket_handlers import WebsocketRouteHandler
@WebsocketRouteHandler(path="/socket")
async def my_websocket_handler(socket: WebSocket) -> None:
await socket.accept()
await socket.send_json({...})
await socket.close()
my_websocket_handler = WebsocketRouteHandler(
path="/socket",
fn=my_websocket_handler,
)
In difference to HTTP routes handlers, websocket handlers have the following requirements:
#. They **must** declare a ``socket`` :term:`kwarg <argument>`.
Expand Down Expand Up @@ -332,8 +333,8 @@ If you need to write your own ASGI application, you can do so using the :func:`@
)
await response(scope=scope, receive=receive, send=send)
Like other route handlers, the :func:`@asgi() <.handlers.asgi>` :term:`decorator` is an alias of the
:class:`~.handlers.ASGIRouteHandler` class. Thus, the code below is equivalent to the one above:
:func:`@asgi() <.handlers.asgi>` :term:`decorator` can be used to create an instance of
:class:`~.handlers.ASGIRouteHandler`. Therefore, the code below is equivalent to the one above:
.. code-block:: python
:caption: Using the :class:`~.handlers.ASGIRouteHandler` class directly
Expand All @@ -343,8 +344,6 @@ Like other route handlers, the :func:`@asgi() <.handlers.asgi>` :term:`decorator
from litestar.status_codes import HTTP_400_BAD_REQUEST
from litestar.types import Scope, Receive, Send
@ASGIRouteHandler(path="/my-asgi-app")
async def my_asgi_app(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http":
if scope["method"] == "GET":
Expand All @@ -356,7 +355,10 @@ Like other route handlers, the :func:`@asgi() <.handlers.asgi>` :term:`decorator
)
await response(scope=scope, receive=receive, send=send)
Limitations of ASGI route handlers
my_asgi_app = ASGIRouteHandler(path="/my-asgi-app", fn=my_asgi_app)
ASGI route handler considerations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In difference to the other route handlers, the :func:`@asgi() <.handlers.asgi>` route handler accepts only three
Expand Down
4 changes: 2 additions & 2 deletions docs/usage/websockets.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ exceptions, and parsing incoming and serializing outgoing data. In addition to t
low-level :class:`WebSocket route handler <.handlers.websocket>`, Litestar offers two
high level interfaces:

- :class:`websocket_listener <.handlers.websocket_listener>`
- :func:`websocket_listener <.handlers.websocket_listener>`
- :class:`WebSocketListener <.handlers.WebsocketListener>`


Expand Down Expand Up @@ -38,7 +38,7 @@ type of data which should be received, and it will be converted accordingly.

.. note::
Contrary to WebSocket route handlers, functions decorated with
:class:`websocket_listener <.handlers.websocket_listener>` don't have to be
:func:`websocket_listener <.handlers.websocket_listener>` don't have to be
asynchronous.


Expand Down
6 changes: 3 additions & 3 deletions litestar/channels/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ def on_app_init(self, app_config: AppConfig) -> AppConfig:
if self._create_route_handlers:
if self._arbitrary_channels_allowed:
path = self._handler_root_path + "{channel_name:str}"
route_handlers = [WebsocketRouteHandler(path)(self._ws_handler_func)]
route_handlers = [WebsocketRouteHandler(path, fn=self._ws_handler_func)]
else:
route_handlers = [
WebsocketRouteHandler(self._handler_root_path + channel_name)(
self._create_ws_handler_func(channel_name)
WebsocketRouteHandler(
self._handler_root_path + channel_name, fn=self._create_ws_handler_func(channel_name)
)
for channel_name in self._channels
]
Expand Down
2 changes: 1 addition & 1 deletion litestar/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def get_route_handlers(self) -> list[BaseRouteHandler]:
route_handler = deepcopy(self_handler)
# at the point we get a reference to the handler function, it's unbound, so
# we replace it with a regular bound method here
route_handler._fn = types.MethodType(route_handler._fn, self)
route_handler.fn = types.MethodType(route_handler.fn, self)
route_handler.owner = self
route_handlers.append(route_handler)

Expand Down
68 changes: 57 additions & 11 deletions litestar/handlers/asgi_handlers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence

from litestar.exceptions import ImproperlyConfiguredException
from litestar.handlers.base import BaseRouteHandler
Expand All @@ -13,24 +13,20 @@
if TYPE_CHECKING:
from litestar.connection import ASGIConnection
from litestar.types import (
AsyncAnyCallable,
ExceptionHandlersMap,
Guard,
MaybePartial, # noqa: F401
)


class ASGIRouteHandler(BaseRouteHandler):
"""ASGI Route Handler decorator.
Use this decorator to decorate ASGI applications.
"""

__slots__ = ("is_mount",)

def __init__(
self,
path: str | Sequence[str] | None = None,
*,
fn: AsyncAnyCallable,
exception_handlers: ExceptionHandlersMap | None = None,
guards: Sequence[Guard] | None = None,
name: str | None = None,
Expand All @@ -39,17 +35,20 @@ def __init__(
signature_namespace: Mapping[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize ``ASGIRouteHandler``.
"""Route handler for ASGI routes.
Args:
path: A path fragment for the route handler function or a list of path fragments. If not given defaults to
``/``.
fn: The handler function.
.. versionadded:: 3.0
exception_handlers: A mapping of status codes and/or exception types to handler functions.
guards: A sequence of :class:`Guard <.types.Guard>` callables.
name: A string identifying the route handler.
opt: A string key mapping of arbitrary values that can be accessed in :class:`Guards <.types.Guard>` or
wherever you have access to :class:`Request <.connection.Request>` or
:class:`ASGI Scope <.types.Scope>`.
path: A path fragment for the route handler function or a list of path fragments. If not given defaults to
``/``
is_mount: A boolean dictating whether the handler's paths should be regarded as mount paths. Mount path
accept any arbitrary paths that begin with the defined prefixed path. For example, a mount with the path
``/some-path/`` will accept requests for ``/some-path/`` and any sub path under this, e.g.
Expand All @@ -61,6 +60,7 @@ def __init__(
self.is_mount = is_mount
super().__init__(
path,
fn=fn,
exception_handlers=exception_handlers,
guards=guards,
name=name,
Expand Down Expand Up @@ -101,4 +101,50 @@ async def handle(self, connection: ASGIConnection[ASGIRouteHandler, Any, Any, An
await self.fn(scope=connection.scope, receive=connection.receive, send=connection.send)


asgi = ASGIRouteHandler
def asgi(
path: str | Sequence[str] | None = None,
*,
exception_handlers: ExceptionHandlersMap | None = None,
guards: Sequence[Guard] | None = None,
name: str | None = None,
opt: Mapping[str, Any] | None = None,
is_mount: bool = False,
signature_namespace: Mapping[str, Any] | None = None,
handler_class: type[ASGIRouteHandler] = ASGIRouteHandler,
**kwargs: Any,
) -> Callable[[AsyncAnyCallable], ASGIRouteHandler]:
"""Create an :class:`ASGIRouteHandler`.
Args:
path: A path fragment for the route handler function or a sequence of path fragments. If not given defaults
to ``/``
exception_handlers: A mapping of status codes and/or exception types to handler functions.
guards: A sequence of :class:`Guard <.types.Guard>` callables.
name: A string identifying the route handler.
opt: A string keyed mapping of arbitrary values that can be accessed in :class:`Guards <.types.Guard>` or
wherever you have access to :class:`Request <.connection.Request>` or
:class:`ASGI Scope <.types.Scope>`.
signature_namespace: A mapping of names to types for use in forward reference resolution during signature
modelling.
is_mount: A boolean dictating whether the handler's paths should be regarded as mount paths. Mount path
accept any arbitrary paths that begin with the defined prefixed path. For example, a mount with the path
``/some-path/`` will accept requests for ``/some-path/`` and any sub path under this, e.g.
``/some-path/sub-path/`` etc.
handler_class: Route handler class instantiated by the decorator
**kwargs: Any additional kwarg - will be set in the opt dictionary.
"""

def decorator(fn: AsyncAnyCallable) -> ASGIRouteHandler:
return handler_class(
fn=fn,
path=path,
exception_handlers=exception_handlers,
guards=guards,
name=name,
opt=opt,
is_mount=is_mount,
signature_namespace=signature_namespace,
**kwargs,
)

return decorator
Loading

0 comments on commit 4dc78a8

Please sign in to comment.