from __future__ import annotations from collections.abc import Iterable from typing import ( TYPE_CHECKING, Any, Callable, ) from sanic.models.handler_types import RouteHandler from sanic.request.types import Request if TYPE_CHECKING: from sanic import Sanic from sanic.blueprints import Blueprint class HTTPMethodView: """Class based implementation for creating and grouping handlers Class-based views (CBVs) are an alternative to function-based views. They allow you to reuse common logic, and group related views, while keeping the flexibility of function-based views. To use a class-based view, subclass the method handler, and implement methods (`get`, `post`, `put`, `patch`, `delete`) for the class to correspond to each HTTP method you want to support. For example: ```python class DummyView(HTTPMethodView): def get(self, request: Request): return text('I am get method') def put(self, request: Request): return text('I am put method') ``` If someone tries to use a non-implemented method, they will reveive a 405 response. If you need any url params just include them in method signature, like you would for function-based views. ```python class DummyView(HTTPMethodView): def get(self, request: Request, my_param_here: str): return text(f"I am get method with {my_param_here}") ``` Next, you need to attach the view to the app or blueprint. You can do this in the exact same way as you would for a function-based view, except you should you use `MyView.as_view()` instead of `my_view_handler`. ```python app.add_route(DummyView.as_view(), "/") ``` Alternatively, you can use the `attach` method: ```python DummyView.attach(app, "/") ``` Or, at the time of subclassing: ```python class DummyView(HTTPMethodView, attach=app, uri="/"): ... ``` To add a decorator, you can either: 1. Add it to the `decorators` list on the class, which will apply it to all methods on the class; or 2. Add it to the method directly, which will only apply it to that method. ```python class DummyView(HTTPMethodView): decorators = [my_decorator] ... # or class DummyView(HTTPMethodView): @my_decorator def get(self, request: Request): ... ``` One catch is that you need to be mindful that the call inside the decorator may need to account for the `self` argument, which is passed to the method as the first argument. Alternatively, you may want to also mark your method as `staticmethod` to avoid this. Available attributes at the time of subclassing: - **attach** (Optional[Union[Sanic, Blueprint]]): The app or blueprint to attach the view to. - **uri** (str): The uri to attach the view to. - **methods** (Iterable[str]): The HTTP methods to attach the view to. Defaults to `{"GET"}`. - **host** (Optional[str]): The host to attach the view to. - **strict_slashes** (Optional[bool]): Whether to add a redirect rule for the uri with a trailing slash. - **version** (Optional[int]): The version to attach the view to. - **name** (Optional[str]): The name to attach the view to. - **stream** (bool): Whether the view is a stream handler. - **version_prefix** (str): The prefix to use for the version. Defaults to `"/v"`. """ decorators: list[Callable[[Callable[..., Any]], Callable[..., Any]]] = [] def __init_subclass__( cls, attach: Sanic | Blueprint | None = None, uri: str = "", methods: Iterable[str] = frozenset({"GET"}), host: str | None = None, strict_slashes: bool | None = None, version: int | None = None, name: str | None = None, stream: bool = False, version_prefix: str = "/v", **kwargs: Any, ) -> None: super().__init_subclass__(**kwargs) if attach: cls.attach( attach, uri=uri, methods=methods, host=host, strict_slashes=strict_slashes, version=version, name=name, stream=stream, version_prefix=version_prefix, ) def dispatch_request(self, request: Request, *args, **kwargs): """Dispatch request to appropriate handler method.""" method = request.method.lower() handler = getattr(self, method, None) if not handler and method == "head": handler = getattr(self, "get") if not handler: # The router will never allow us to get here, but this is # included as a fallback and for completeness. raise NotImplementedError( f"{request.method} is not supported for this endpoint." ) return handler(request, *args, **kwargs) @classmethod def as_view(cls, *class_args: Any, **class_kwargs: Any) -> RouteHandler: """Return view function for use with the routing system, that dispatches request to appropriate handler method. If you need to pass arguments to the class's constructor, you can pass the arguments to `as_view` and they will be passed to the class `__init__` method. Args: *class_args: Variable length argument list for the class instantiation. **class_kwargs: Arbitrary keyword arguments for the class instantiation. Returns: RouteHandler: The view function. Examples: ```python class DummyView(HTTPMethodView): def __init__(self, foo: MyFoo): self.foo = foo async def get(self, request: Request): return text(self.foo.bar) app.add_route(DummyView.as_view(foo=MyFoo()), "/") ``` """ # noqa: E501 def view(*args, **kwargs): self = view.view_class(*class_args, **class_kwargs) return self.dispatch_request(*args, **kwargs) if cls.decorators: view.__module__ = cls.__module__ for decorator in cls.decorators: view = decorator(view) view.view_class = cls # type: ignore view.__doc__ = cls.__doc__ view.__module__ = cls.__module__ view.__name__ = cls.__name__ return view @classmethod def attach( cls, to: Sanic | Blueprint, uri: str, methods: Iterable[str] = frozenset({"GET"}), host: str | None = None, strict_slashes: bool | None = None, version: int | None = None, name: str | None = None, stream: bool = False, version_prefix: str = "/v", ) -> None: """Attaches the view to a Sanic app or Blueprint at the specified URI. Args: cls: The class that this method is part of. to (Union[Sanic, Blueprint]): The Sanic application or Blueprint to attach to. uri (str): The URI to bind the view to. methods (Iterable[str], optional): A collection of HTTP methods that the view should respond to. Defaults to `frozenset({"GET"})`. host (Optional[str], optional): A specific host or hosts to bind the view to. Defaults to `None`. strict_slashes (Optional[bool], optional): Enforce or not the trailing slash. Defaults to `None`. version (Optional[int], optional): Version of the API if versioning is used. Defaults to `None`. name (Optional[str], optional): Unique name for the route. Defaults to `None`. stream (bool, optional): Enable or disable streaming for the view. Defaults to `False`. version_prefix (str, optional): The prefix for the version, if versioning is used. Defaults to `"/v"`. """ # noqa: E501 to.add_route( cls.as_view(), uri=uri, methods=methods, host=host, strict_slashes=strict_slashes, version=version, name=name, stream=stream, version_prefix=version_prefix, ) def stream(func): """Decorator to mark a function as a stream handler.""" func.is_stream = True return func