From 6f620e57948bd460cd7adfcf51b9be10bad2e29c Mon Sep 17 00:00:00 2001 From: tarsil Date: Tue, 25 Jul 2023 13:21:48 +0100 Subject: [PATCH 1/3] Add webhooks to OpenAPI * Added new handlers for webhooks * Added new WebhookGateway --- esmerald/applications.py | 19 + esmerald/conf/global_settings.py | 3 + esmerald/config/openapi.py | 5 +- esmerald/openapi/openapi.py | 60 ++- esmerald/routing/gateways.py | 96 ++++- esmerald/routing/router.py | 90 ++++- esmerald/routing/webhooks/__init__.py | 0 esmerald/routing/webhooks/handlers.py | 547 ++++++++++++++++++++++++++ esmerald/types.py | 8 +- 9 files changed, 804 insertions(+), 24 deletions(-) create mode 100644 esmerald/routing/webhooks/__init__.py create mode 100644 esmerald/routing/webhooks/handlers.py diff --git a/esmerald/applications.py b/esmerald/applications.py index 5919f54d..0d918d35 100644 --- a/esmerald/applications.py +++ b/esmerald/applications.py @@ -170,6 +170,7 @@ def __init__( pluggables: Optional[Dict[str, Pluggable]] = None, parent: Optional[Union["ParentType", "Esmerald", "ChildEsmerald"]] = None, root_path_in_servers: bool = None, + webhooks: Optional[Sequence["gateways.WebhookGateway"]] = None, openapi_url: Optional[str] = None, docs_url: Optional[str] = None, redoc_url: Optional[str] = None, @@ -272,6 +273,7 @@ def __init__( if not self.include_in_schema or not self.enable_openapi: self.root_path_in_servers = False + self.webhooks = self.load_settings_value("webhooks", webhooks) or [] self.openapi_url = self.load_settings_value("openapi_url", openapi_url) self.tags = self.load_settings_value("tags", tags) self.docs_url = self.load_settings_value("docs_url", docs_url) @@ -315,6 +317,12 @@ def __init__( self.pluggable_stack = self.build_pluggable_stack() self.template_engine = self.get_template_engine(self.template_config) + self._configure() + + def _configure(self) -> None: + """ + Starts the Esmerald configurations. + """ if self.static_files_config: for config in ( self.static_files_config @@ -328,6 +336,7 @@ def __init__( if self.enable_scheduler: self.activate_scheduler() + self.create_webhooks_signature_model(self.webhooks) self.activate_openapi() def load_settings_value( @@ -345,6 +354,13 @@ def load_settings_value( return value return self.get_settings_value(self.settings_config, esmerald_settings, name) + def create_webhooks_signature_model(self, webhooks: Sequence[gateways.WebhookGateway]) -> None: + """ + Creates the signature model for the webhooks. + """ + for route in self.webhooks: + self.router.create_signature_models(route) + def activate_scheduler(self) -> None: """ Makes sure the scheduler is accessible. @@ -407,6 +423,9 @@ def set_value(value: Any, name: str) -> Any: set_value(self.swagger_favicon_url, "swagger_favicon_url") set_value(self.openapi_url, "openapi_url") + if self.webhooks or not self.openapi_config.webhooks: + self.openapi_config.webhooks = self.webhooks + self.openapi_config.enable(self) def get_template_engine( diff --git a/esmerald/conf/global_settings.py b/esmerald/conf/global_settings.py index 85946377..c199c78c 100644 --- a/esmerald/conf/global_settings.py +++ b/esmerald/conf/global_settings.py @@ -13,6 +13,7 @@ from esmerald.interceptors.types import Interceptor from esmerald.permissions.types import Permission from esmerald.pluggables import Pluggable +from esmerald.routing import gateways from esmerald.types import ( APIGateHandler, Dependencies, @@ -59,6 +60,7 @@ class EsmeraldAPISettings(BaseSettings): enable_openapi: bool = True redirect_slashes: bool = True root_path_in_servers: bool = True + webhooks: Optional[Sequence[gateways.WebhookGateway]] = None openapi_url: Optional[str] = "/openapi.json" docs_url: Optional[str] = "/docs/swagger" redoc_url: Optional[str] = "/docs/redoc" @@ -281,6 +283,7 @@ def openapi_config(self) -> OpenAPIConfig: openapi_version=self.openapi_version, openapi_url=self.openapi_url, with_google_fonts=self.with_google_fonts, + webhooks=self.webhooks, ) @property diff --git a/esmerald/config/openapi.py b/esmerald/config/openapi.py index 7eb97ebe..3b282944 100644 --- a/esmerald/config/openapi.py +++ b/esmerald/config/openapi.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Sequence, Union from openapi_schemas_pydantic.v3_1_0.security_scheme import SecurityScheme from pydantic import AnyUrl, BaseModel @@ -40,10 +40,12 @@ class OpenAPIConfig(BaseModel): swagger_css_url: Optional[str] = None swagger_favicon_url: Optional[str] = None with_google_fonts: bool = True + webhooks: Optional[Sequence[Any]] = None def openapi(self, app: Any) -> Dict[str, Any]: """Loads the OpenAPI routing schema""" openapi_schema = get_openapi( + app=app, title=self.title, version=self.version, openapi_version=self.openapi_version, @@ -55,6 +57,7 @@ def openapi(self, app: Any) -> Dict[str, Any]: terms_of_service=self.terms_of_service, contact=self.contact, license=self.license, + webhooks=self.webhooks, ) app.openapi_schema = openapi_schema return openapi_schema diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index c4711a30..40168dcb 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -55,7 +55,9 @@ def get_fields_from_routes( request_fields.extend(get_fields_from_routes(route.routes, request_fields)) continue - if getattr(route, "include_in_schema", None) and isinstance(route, gateways.Gateway): + if getattr(route, "include_in_schema", None) and isinstance( + route, (gateways.Gateway, gateways.WebhookGateway) + ): handler = cast(router.HTTPHandler, route.handler) # Get the data_field @@ -170,9 +172,10 @@ def get_openapi_operation_request_body( def get_openapi_path( *, - route: gateways.Gateway, + route: Union[gateways.Gateway, gateways.WebhookGateway], operation_ids: Set[str], field_mapping: Dict[Tuple[FieldInfo, Literal["validation", "serialization"]], JsonSchemaValue], + is_deprecated: bool = False, ) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]: # pragma: no cover path: Dict[str, Any] = {} security_schemes: Dict[str, Any] = {} @@ -200,6 +203,10 @@ def get_openapi_path( operation = get_openapi_operation( route=handler, method=method, operation_ids=operation_ids ) + # If the parent if marked as deprecated, it takes precedence + if is_deprecated or route.deprecated: + operation["deprecated"] = is_deprecated if is_deprecated else route.deprecated + parameters: List[Dict[str, Any]] = [] security_definitions = {} for security in handler.security: @@ -344,6 +351,7 @@ def should_include_in_schema(route: router.Include) -> bool: def get_openapi( *, + app: Any, title: str, version: str, openapi_version: str = "3.1.0", @@ -355,6 +363,7 @@ def get_openapi( terms_of_service: Optional[Union[str, AnyUrl]] = None, contact: Optional[Contact] = None, license: Optional[License] = None, + webhooks: Optional[Sequence[BaseRoute]] = None, ) -> Dict[str, Any]: # pragma: no cover """ Builds the whole OpenAPI route structure and object @@ -383,8 +392,9 @@ def get_openapi( components: Dict[str, Dict[str, Any]] = {} paths: Dict[str, Dict[str, Any]] = {} + webhooks_paths: Dict[str, Dict[str, Any]] = {} operation_ids: Set[str] = set() - all_fields = get_fields_from_routes(list(routes or [])) + all_fields = get_fields_from_routes(list(routes or []) + list(webhooks or [])) schema_generator = GenerateJsonSchema(ref_template=REF_TEMPLATE) field_mapping, definitions = get_definitions( fields=all_fields, @@ -393,12 +403,18 @@ def get_openapi( # Iterate through the routes def iterate_routes( + app: Any, routes: Sequence[BaseRoute], definitions: Any = None, components: Any = None, prefix: Optional[str] = "", + is_webhook: bool = False, + is_deprecated: bool = False, ) -> Tuple[Dict[str, Any], Dict[str, Any]]: for route in routes: + if app.router.deprecated: + is_deprecated = True + if isinstance(route, router.Include): if hasattr(route, "app"): if not should_include_in_schema(route): @@ -410,27 +426,42 @@ def iterate_routes( if hasattr(route, "app") and isinstance(route.app, (Esmerald, ChildEsmerald)): route_path = clean_path(prefix + route.path) + definitions, components = iterate_routes( - route.app.routes, definitions, components, prefix=route_path + app, + route.app.routes, + definitions, + components, + prefix=route_path, + is_deprecated=is_deprecated if is_deprecated else route.deprecated, ) else: route_path = clean_path(prefix + route.path) definitions, components = iterate_routes( - route.routes, definitions, components, prefix=route_path + app, + route.routes, + definitions, + components, + prefix=route_path, + is_deprecated=is_deprecated if is_deprecated else route.deprecated, ) continue - if isinstance(route, gateways.Gateway): + if isinstance(route, (gateways.Gateway, gateways.WebhookGateway)): result = get_openapi_path( route=route, operation_ids=operation_ids, field_mapping=field_mapping, + is_deprecated=is_deprecated, ) if result: path, security_schemes, path_definitions = result if path: - route_path = clean_path(prefix + route.path_format) - paths.setdefault(route_path, {}).update(path) + if is_webhook: + webhooks_paths.setdefault(route.path, {}).update(path) + else: + route_path = clean_path(prefix + route.path_format) + paths.setdefault(route_path, {}).update(path) if security_schemes: components.setdefault("securitySchemes", {}).update(security_schemes) if path_definitions: @@ -439,14 +470,25 @@ def iterate_routes( return definitions, components definitions, components = iterate_routes( - routes=routes, definitions=definitions, components=components + app=app, routes=routes, definitions=definitions, components=components ) + if webhooks: + definitions, components = iterate_routes( + app=app, + routes=webhooks, + definitions=definitions, + components=components, + is_webhook=True, + ) + if definitions: components["schemas"] = {k: definitions[k] for k in sorted(definitions)} if components: output["components"] = components output["paths"] = paths + if webhooks_paths: + output["webhooks"] = webhooks_paths if tags: output["tags"] = tags diff --git a/esmerald/routing/gateways.py b/esmerald/routing/gateways.py index b5949aa9..2cbb4129 100644 --- a/esmerald/routing/gateways.py +++ b/esmerald/routing/gateways.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: # pragma: no cover from esmerald.interceptors.types import Interceptor from esmerald.permissions.types import Permission - from esmerald.routing.router import HTTPHandler, WebSocketHandler + from esmerald.routing.router import HTTPHandler, WebhookHandler, WebSocketHandler from esmerald.types import Dependencies, ExceptionHandlerMap, Middleware, ParentType @@ -203,3 +203,97 @@ async def handle(self, scope: "Scope", receive: "Receive", send: "Send") -> None await self.intercept(scope, receive, send) # pragma: no cover await self.handler.handle(scope, receive, send) + + +class WebhookGateway(StarletteRoute, BaseInterceptorMixin): + __slots__ = ( + "_interceptors", + "path", + "handler", + "name", + "include_in_schema", + "parent", + "dependencies", + "middleware", + "exception_handlers", + "interceptors", + "permissions", + ) + + def __init__( + self, + *, + handler: Union["WebhookHandler", APIView], + name: Optional[str] = None, + include_in_schema: bool = True, + parent: Optional["ParentType"] = None, + dependencies: Optional["Dependencies"] = None, + middleware: Optional[Sequence["Middleware"]] = None, + interceptors: Optional[Sequence["Interceptor"]] = None, + permissions: Optional[Sequence["Permission"]] = None, + exception_handlers: Optional["ExceptionHandlerMap"] = None, + deprecated: Optional[bool] = None, + ) -> None: + if is_class_and_subclass(handler, APIView): + handler = handler(parent=self) # type: ignore + + self.path = handler.path + self.methods = getattr(handler, "http_methods", None) + + if not name: + if not isinstance(handler, APIView): + name = clean_string(handler.fn.__name__) + else: + name = clean_string(handler.__class__.__name__) + + self.endpoint = cast("Callable", handler) + self.include_in_schema = include_in_schema + + self._interceptors: Union[List["Interceptor"], "VoidType"] = Void + self.name = name + self.handler = handler + self.dependencies = dependencies or {} + self.interceptors: Sequence["Interceptor"] = interceptors or [] + self.permissions: Sequence["Permission"] = permissions or [] + self.middleware = middleware or [] + self.exception_handlers = exception_handlers or {} + self.response_class = None + self.response_cookies = None + self.response_headers = None + self.deprecated = deprecated + self.parent = parent + ( + handler.path_regex, + handler.path_format, + handler.param_convertors, + ) = compile_path(self.path) + + if not is_class_and_subclass(self.handler, APIView) and not isinstance( + self.handler, APIView + ): + self.handler.name = self.name + self.handler.get_response_handler() + + if not handler.operation_id: + handler.operation_id = self.generate_operation_id() + + async def handle(self, scope: "Scope", receive: "Receive", send: "Send") -> None: + """ + Handles the interception of messages and calls from the API. + """ + if self.get_interceptors(): + await self.intercept(scope, receive, send) + + await self.handler.handle(scope, receive, send) + + def generate_operation_id(self) -> str: + """ + Generates an unique operation if for the handler + """ + operation_id = self.name + self.handler.path_format + operation_id = re.sub(r"\W", "_", operation_id) + methods = list(self.handler.methods) + + assert self.handler.methods + operation_id = f"{operation_id}_{methods[0].lower()}" + return operation_id diff --git a/esmerald/routing/router.py b/esmerald/routing/router.py index 32feac9b..9478f828 100644 --- a/esmerald/routing/router.py +++ b/esmerald/routing/router.py @@ -54,7 +54,7 @@ from esmerald.routing._internal import FieldInfoMixin from esmerald.routing.base import BaseHandlerMixin from esmerald.routing.events import handle_lifespan_events -from esmerald.routing.gateways import Gateway, WebSocketGateway +from esmerald.routing.gateways import Gateway, WebhookGateway, WebSocketGateway from esmerald.routing.views import APIView from esmerald.transformers.datastructures import EsmeraldSignature as SignatureModel from esmerald.transformers.model import TransformerModel @@ -100,9 +100,9 @@ def create_signature_models(self, route: "RouteParent") -> None: for _route in route.routes: self.create_signature_models(_route) - if isinstance(route, Gateway): + if isinstance(route, (Gateway, WebhookGateway)): if not route.handler.parent: - route.handler.parent = route # pragma: no cover + route.handler.parent = route # type: ignore if not is_class_and_subclass(route.handler, APIView) and not isinstance( route.handler, APIView @@ -114,7 +114,7 @@ def create_signature_models(self, route: "RouteParent") -> None: def validate_root_route_parent( self, - value: Union["Router", "Include", "Gateway", "WebSocketGateway"], + value: Union["Router", "Include", "Gateway", "WebSocketGateway", "WebhookGateway"], override: bool = False, ) -> None: """ @@ -123,16 +123,16 @@ def validate_root_route_parent( """ # Getting the value of the router for the path value.path = clean_path(self.path + getattr(value, "path", "/")) - if isinstance(value, (Include, Gateway, WebSocketGateway)): + if isinstance(value, (Include, Gateway, WebSocketGateway, WebSocketGateway)): if not value.parent and not override: value.parent = cast("Union[Router, Include, Gateway, WebSocketGateway]", self) - if isinstance(value, (Gateway, WebSocketGateway)): + if isinstance(value, (Gateway, WebSocketGateway, WebhookGateway)): if not is_class_and_subclass(value.handler, APIView) and not isinstance( value.handler, APIView ): if not value.handler.parent: - value.handler.parent = value + value.handler.parent = value # type: ignore else: if not value.handler.parent: # pragma: no cover value(parent=self) # type: ignore @@ -156,7 +156,7 @@ def validate_root_route_parent( exception_handlers=value.exception_handlers, ) - if isinstance(gate, Gateway): + if isinstance(gate, (Gateway, WebhookGateway)): include_in_schema = ( value.include_in_schema if value.include_in_schema is not None @@ -498,12 +498,13 @@ def __init__( operation_id: Optional[str] = None, raise_exceptions: Optional[List[Type["HTTPException"]]] = None, ) -> None: - if not path: - path = "/" - super().__init__(path=path, endpoint=endpoint, include_in_schema=include_in_schema) """ Handles the "handler" or "apiview" of the platform. A handler can be any get, put, patch, post, delete or route. """ + if not path: + path = "/" + super().__init__(path=path, endpoint=endpoint, include_in_schema=include_in_schema) + self._permissions: Union[List["Permission"], "VoidType"] = Void self._dependencies: "Dependencies" = {} @@ -754,6 +755,73 @@ async def to_response(self, app: "Esmerald", data: Any) -> StarletteResponse: return await response_handler(app=app, data=data) # type: ignore[call-arg] +class WebhookHandler(HTTPHandler, FieldInfoMixin, StarletteRoute): + """ + Base for a webhook handler. + """ + + def __init__( + self, + path: Optional[str] = None, + endpoint: Callable[..., Any] = None, + *, + methods: Optional[Sequence[str]] = None, + status_code: Optional[int] = None, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional["ExceptionHandlerMap"] = None, + permissions: Optional[List["Permission"]] = None, + middleware: Optional[List["Middleware"]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional["ResponseType"] = None, + response_cookies: Optional["ResponseCookies"] = None, + response_headers: Optional["ResponseHeaders"] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + response_description: Optional[str] = "Successful Response", + responses: Optional[Dict[int, OpenAPIResponse]] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + ) -> None: + _path: str = None + if not path: + _path = "/" + super().__init__( + path=_path, + endpoint=endpoint, + methods=methods, + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + self.path = path + + class WebSocketHandler(BaseHandlerMixin, StarletteWebSocketRoute): """ Websocket handler object representation. diff --git a/esmerald/routing/webhooks/__init__.py b/esmerald/routing/webhooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esmerald/routing/webhooks/handlers.py b/esmerald/routing/webhooks/handlers.py new file mode 100644 index 00000000..d60228bc --- /dev/null +++ b/esmerald/routing/webhooks/handlers.py @@ -0,0 +1,547 @@ +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Type, Union + +from starlette import status + +from esmerald.enums import HttpMethod, MediaType +from esmerald.exceptions import HTTPException, ImproperlyConfigured +from esmerald.openapi.datastructures import OpenAPIResponse +from esmerald.permissions.types import Permission +from esmerald.routing.router import WebhookHandler +from esmerald.types import ( + BackgroundTaskType, + Dependencies, + ExceptionHandlerMap, + Middleware, + ResponseCookies, + ResponseHeaders, + ResponseType, +) +from esmerald.utils.constants import AVAILABLE_METHODS + +if TYPE_CHECKING: # pragma: no cover + from openapi_schemas_pydantic.v3_1_0 import SecurityScheme + + +SUCCESSFUL_RESPONSE = "Successful response" + + +class wget(WebhookHandler): + def __init__( + self, + path: Optional[str] = None, + *, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_200_OK, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + super().__init__( + path=path, + methods=[HttpMethod.GET], + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + + +class whead(WebhookHandler): + def __init__( + self, + path: Optional[str] = None, + *, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_200_OK, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + super().__init__( + path=path, + methods=[HttpMethod.HEAD], + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + + +class woptions(WebhookHandler): + def __init__( + self, + path: Optional[str] = None, + *, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_200_OK, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + super().__init__( + path=path, + methods=[HttpMethod.OPTIONS], + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + + +class wtrace(WebhookHandler): # pragma: no cover + def __init__( + self, + path: Optional[str] = None, + *, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_200_OK, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + super().__init__( + path=path, + methods=[HttpMethod.TRACE], + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + + +class wpost(WebhookHandler): + def __init__( + self, + path: Optional[str] = None, + *, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_201_CREATED, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + super().__init__( + path=path, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + summary=summary, + description=description, + methods=[HttpMethod.POST], + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + + +class wput(WebhookHandler): + def __init__( + self, + path: Optional[str] = None, + *, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_200_OK, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + super().__init__( + path=path, + methods=[HttpMethod.PUT], + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + + +class wpatch(WebhookHandler): + def __init__( + self, + path: Optional[str] = None, + *, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_200_OK, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + super().__init__( + path=path, + methods=[HttpMethod.PATCH], + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + + +class wdelete(WebhookHandler): + def __init__( + self, + path: Optional[str] = None, + *, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_204_NO_CONTENT, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + super().__init__( + path=path, + methods=[HttpMethod.DELETE], + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) + + +class wroute(WebhookHandler): + def __init__( + self, + path: Optional[str] = None, + *, + methods: List[str] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + status_code: Optional[int] = status.HTTP_200_OK, + content_encoding: Optional[str] = None, + content_media_type: Optional[str] = None, + include_in_schema: bool = True, + background: Optional["BackgroundTaskType"] = None, + dependencies: Optional["Dependencies"] = None, + exception_handlers: Optional[ExceptionHandlerMap] = None, + permissions: Optional[List[Permission]] = None, + middleware: Optional[List[Middleware]] = None, + media_type: Union[MediaType, str] = MediaType.JSON, + response_class: Optional[ResponseType] = None, + response_cookies: Optional[ResponseCookies] = None, + response_headers: Optional[ResponseHeaders] = None, + tags: Optional[Sequence[str]] = None, + deprecated: Optional[bool] = None, + security: Optional[List["SecurityScheme"]] = None, + operation_id: Optional[str] = None, + raise_exceptions: Optional[List[Type["HTTPException"]]] = None, + response_description: Optional[str] = SUCCESSFUL_RESPONSE, + responses: Optional[Dict[int, OpenAPIResponse]] = None, + ) -> None: + if not methods or not isinstance(methods, list): + raise ImproperlyConfigured( + "http handler demands `methods` to be declared. " + "An example would be: @route(methods=['GET', 'PUT'])." + ) + + for method in methods: + if method.upper() not in AVAILABLE_METHODS: + raise ImproperlyConfigured( + f"Invalid method {method}. " + "An example would be: @route(methods=['GET', 'PUT'])." + ) + + methods = [method.upper() for method in methods] + if not status_code: # pragma: no cover + status_code = status.HTTP_200_OK + + super().__init__( + path=path, + methods=methods, + summary=summary, + description=description, + status_code=status_code, + content_encoding=content_encoding, + content_media_type=content_media_type, + include_in_schema=include_in_schema, + background=background, + dependencies=dependencies, + exception_handlers=exception_handlers, + permissions=permissions, + middleware=middleware, + media_type=media_type, + response_class=response_class, + response_cookies=response_cookies, + response_headers=response_headers, + tags=tags, + deprecated=deprecated, + security=security, + operation_id=operation_id, + raise_exceptions=raise_exceptions, + response_description=response_description, + responses=responses, + ) diff --git a/esmerald/types.py b/esmerald/types.py index a9c3c44a..5564cc60 100644 --- a/esmerald/types.py +++ b/esmerald/types.py @@ -41,7 +41,8 @@ from esmerald.protocols.middleware import MiddlewareProtocol from esmerald.requests import Request # noqa from esmerald.responses import Response # noqa - from esmerald.routing.router import Gateway, HTTPHandler, Router, WebSocketHandler # noqa + from esmerald.routing.gateways import Gateway, WebhookGateway # noqa + from esmerald.routing.router import HTTPHandler, Router, WebSocketHandler # noqa from esmerald.routing.views import APIView # noqa from esmerald.websockets import WebSocket # noqa else: @@ -62,6 +63,7 @@ MiddlewareProtocol = Any APIView = Any Gateway = Any + WebhookGateway = Any Esmerald = Any AsyncAnyCallable = Callable[..., Awaitable[Any]] @@ -108,7 +110,9 @@ WebSocketGateway, ] -RouteParent = Union["Router", "Include", "ASGIApp", "Gateway", "WebSocketGateway"] +RouteParent = Union[ + "Router", "Include", "ASGIApp", "Gateway", "WebSocketGateway", "WebhookGateway" +] BackgroundTaskType = Union[BackgroundTask, BackgroundTasks] SecurityScheme = Dict[str, List[str]] From a7d87863ff076fc6b7980e7b178f32b1e2b5a16c Mon Sep 17 00:00:00 2001 From: tarsil Date: Tue, 25 Jul 2023 14:55:27 +0100 Subject: [PATCH 2/3] Add support for Webhooks * Added tests --- esmerald/__init__.py | 23 ++++++- esmerald/applications.py | 38 +++++++++++- esmerald/routing/gateways.py | 24 ++------ esmerald/routing/router.py | 38 ++++++++++-- esmerald/routing/webhooks/__init__.py | 13 ++++ esmerald/routing/webhooks/handlers.py | 20 +++---- esmerald/testclient.py | 4 ++ tests/test_apiviews.py | 15 +++++ tests/webhooks/__init__.py | 0 tests/webhooks/test_webhooks.py | 86 +++++++++++++++++++++++++++ 10 files changed, 225 insertions(+), 36 deletions(-) create mode 100644 tests/webhooks/__init__.py create mode 100644 tests/webhooks/test_webhooks.py diff --git a/esmerald/__init__.py b/esmerald/__init__.py index b369a8f3..6d2473b6 100644 --- a/esmerald/__init__.py +++ b/esmerald/__init__.py @@ -29,10 +29,21 @@ from .protocols import AsyncDAOProtocol, DaoProtocol, MiddlewareProtocol from .requests import Request from .responses import JSONResponse, Response, TemplateResponse -from .routing.gateways import Gateway, WebSocketGateway +from .routing.gateways import Gateway, WebhookGateway, WebSocketGateway from .routing.handlers import delete, get, head, options, patch, post, put, route, trace, websocket from .routing.router import Include, Router from .routing.views import APIView +from .routing.webhooks import ( + whdelete, + whead, + whget, + whoptions, + whpatch, + whpost, + whput, + whroute, + whtrace, +) from .websockets import WebSocket, WebSocketDisconnect __all__ = [ @@ -87,6 +98,7 @@ "TemplateResponse", "UploadFile", "ValidationErrorException", + "WebhookGateway", "WebSocket", "WebSocketDisconnect", "WebSocketGateway", @@ -102,4 +114,13 @@ "status", "trace", "websocket", + "whdelete", + "whead", + "whget", + "whoptions", + "whpatch", + "whpost", + "whput", + "whroute", + "whtrace", ] diff --git a/esmerald/applications.py b/esmerald/applications.py index 0d918d35..2c4f8dee 100644 --- a/esmerald/applications.py +++ b/esmerald/applications.py @@ -43,7 +43,7 @@ from esmerald.permissions.types import Permission from esmerald.pluggables import Extension, Pluggable from esmerald.protocols.template import TemplateEngineProtocol -from esmerald.routing import gateways +from esmerald.routing import gateways, views from esmerald.routing.router import HTTPHandler, Include, Router, WebSocketHandler from esmerald.types import ( APIGateHandler, @@ -358,8 +358,44 @@ def create_webhooks_signature_model(self, webhooks: Sequence[gateways.WebhookGat """ Creates the signature model for the webhooks. """ + webhooks = [] for route in self.webhooks: + if not isinstance(route, gateways.WebhookGateway): + raise ImproperlyConfigured( + f"The webhooks should be an instances of 'WebhookGateway', got '{route.__class__.__name__}' instead." + ) + + if not is_class_and_subclass(route.handler, views.APIView) and not isinstance( + route.handler, views.APIView + ): + if not route.handler.parent: + route.handler.parent = route # type: ignore + webhooks.append(route) + else: + if not route.handler.parent: # pragma: no cover + route(parent=self) # type: ignore + + handler: views.APIView = cast("views.APIView", route.handler) + route_handlers = handler.get_route_handlers() + for route_handler in route_handlers: + gate = gateways.WebhookGateway( + handler=route_handler, + name=route_handler.fn.__name__, + ) + + include_in_schema = ( + route.include_in_schema + if route.include_in_schema is not None + else route_handler.include_in_schema + ) + gate.include_in_schema = include_in_schema + + webhooks.append(gate) + self.webhooks.pop(self.webhooks.index(route)) + + for route in webhooks: self.router.create_signature_models(route) + self.webhooks = webhooks def activate_scheduler(self) -> None: """ diff --git a/esmerald/routing/gateways.py b/esmerald/routing/gateways.py index 2cbb4129..c2561dfa 100644 --- a/esmerald/routing/gateways.py +++ b/esmerald/routing/gateways.py @@ -227,11 +227,6 @@ def __init__( name: Optional[str] = None, include_in_schema: bool = True, parent: Optional["ParentType"] = None, - dependencies: Optional["Dependencies"] = None, - middleware: Optional[Sequence["Middleware"]] = None, - interceptors: Optional[Sequence["Interceptor"]] = None, - permissions: Optional[Sequence["Permission"]] = None, - exception_handlers: Optional["ExceptionHandlerMap"] = None, deprecated: Optional[bool] = None, ) -> None: if is_class_and_subclass(handler, APIView): @@ -252,11 +247,11 @@ def __init__( self._interceptors: Union[List["Interceptor"], "VoidType"] = Void self.name = name self.handler = handler - self.dependencies = dependencies or {} - self.interceptors: Sequence["Interceptor"] = interceptors or [] - self.permissions: Sequence["Permission"] = permissions or [] - self.middleware = middleware or [] - self.exception_handlers = exception_handlers or {} + self.dependencies = {} + self.interceptors: Sequence["Interceptor"] = [] + self.permissions: Sequence["Permission"] = [] + self.middleware = [] + self.exception_handlers = {} self.response_class = None self.response_cookies = None self.response_headers = None @@ -277,15 +272,6 @@ def __init__( if not handler.operation_id: handler.operation_id = self.generate_operation_id() - async def handle(self, scope: "Scope", receive: "Receive", send: "Send") -> None: - """ - Handles the interception of messages and calls from the API. - """ - if self.get_interceptors(): - await self.intercept(scope, receive, send) - - await self.handler.handle(scope, receive, send) - def generate_operation_id(self) -> str: """ Generates an unique operation if for the handler diff --git a/esmerald/routing/router.py b/esmerald/routing/router.py index 9478f828..fabfffc8 100644 --- a/esmerald/routing/router.py +++ b/esmerald/routing/router.py @@ -101,7 +101,7 @@ def create_signature_models(self, route: "RouteParent") -> None: self.create_signature_models(_route) if isinstance(route, (Gateway, WebhookGateway)): - if not route.handler.parent: + if not route.handler.parent: # pragma: no cover route.handler.parent = route # type: ignore if not is_class_and_subclass(route.handler, APIView) and not isinstance( @@ -232,10 +232,10 @@ def __init__( Host, Router, ), - ): + ) or isinstance(route, WebhookGateway): raise ImproperlyConfigured( - f"The route {route} must be of type Gateway or Include" - ) # pragma: no cover + f"The route {route} must be of type Gateway, WebSocketGateway or Include" + ) assert lifespan is None or ( on_startup is None and on_shutdown is None @@ -760,6 +760,34 @@ class WebhookHandler(HTTPHandler, FieldInfoMixin, StarletteRoute): Base for a webhook handler. """ + _slots__ = ( + "path", + "_permissions", + "_dependencies", + "_response_handler", + "methods", + "status_code", + "content_encoding", + "media_type", + "content_media_type", + "summary", + "description", + "include_in_schema", + "dependencies", + "exception_handlers", + "permissions", + "middleware", + "response_class", + "response_cookies", + "response_headers", + "parent", + "tags", + "deprecated", + "security", + "operation_id", + "raise_exceptions", + ) + def __init__( self, path: Optional[str] = None, @@ -791,7 +819,7 @@ def __init__( ) -> None: _path: str = None if not path: - _path = "/" + _path = "/" # pragma: no covergit add super().__init__( path=_path, endpoint=endpoint, diff --git a/esmerald/routing/webhooks/__init__.py b/esmerald/routing/webhooks/__init__.py index e69de29b..0aa49347 100644 --- a/esmerald/routing/webhooks/__init__.py +++ b/esmerald/routing/webhooks/__init__.py @@ -0,0 +1,13 @@ +from .handlers import whdelete, whead, whget, whoptions, whpatch, whpost, whput, whroute, whtrace + +__all__ = [ + "whdelete", + "whead", + "whget", + "whoptions", + "whpatch", + "whpost", + "whput", + "whroute", + "whtrace", +] diff --git a/esmerald/routing/webhooks/handlers.py b/esmerald/routing/webhooks/handlers.py index d60228bc..8a58fd46 100644 --- a/esmerald/routing/webhooks/handlers.py +++ b/esmerald/routing/webhooks/handlers.py @@ -25,7 +25,7 @@ SUCCESSFUL_RESPONSE = "Successful response" -class wget(WebhookHandler): +class whget(WebhookHandler): def __init__( self, path: Optional[str] = None, @@ -137,7 +137,7 @@ def __init__( ) -class woptions(WebhookHandler): +class whoptions(WebhookHandler): def __init__( self, path: Optional[str] = None, @@ -193,7 +193,7 @@ def __init__( ) -class wtrace(WebhookHandler): # pragma: no cover +class whtrace(WebhookHandler): # pragma: no cover def __init__( self, path: Optional[str] = None, @@ -249,7 +249,7 @@ def __init__( ) -class wpost(WebhookHandler): +class whpost(WebhookHandler): def __init__( self, path: Optional[str] = None, @@ -305,7 +305,7 @@ def __init__( ) -class wput(WebhookHandler): +class whput(WebhookHandler): def __init__( self, path: Optional[str] = None, @@ -361,7 +361,7 @@ def __init__( ) -class wpatch(WebhookHandler): +class whpatch(WebhookHandler): def __init__( self, path: Optional[str] = None, @@ -417,7 +417,7 @@ def __init__( ) -class wdelete(WebhookHandler): +class whdelete(WebhookHandler): def __init__( self, path: Optional[str] = None, @@ -473,7 +473,7 @@ def __init__( ) -class wroute(WebhookHandler): +class whroute(WebhookHandler): def __init__( self, path: Optional[str] = None, @@ -502,14 +502,14 @@ def __init__( response_description: Optional[str] = SUCCESSFUL_RESPONSE, responses: Optional[Dict[int, OpenAPIResponse]] = None, ) -> None: - if not methods or not isinstance(methods, list): + if not methods or not isinstance(methods, list): # pragma: no cover raise ImproperlyConfigured( "http handler demands `methods` to be declared. " "An example would be: @route(methods=['GET', 'PUT'])." ) for method in methods: - if method.upper() not in AVAILABLE_METHODS: + if method.upper() not in AVAILABLE_METHODS: # pragma: no cover raise ImproperlyConfigured( f"Invalid method {method}. " "An example would be: @route(methods=['GET', 'PUT'])." diff --git a/esmerald/testclient.py b/esmerald/testclient.py index 8f4771ae..01d9143b 100644 --- a/esmerald/testclient.py +++ b/esmerald/testclient.py @@ -6,6 +6,7 @@ Dict, List, Optional, + Sequence, Union, cast, ) @@ -31,6 +32,7 @@ ) from esmerald.interceptors.types import Interceptor from esmerald.permissions.types import Permission + from esmerald.routing.gateways import WebhookGateway from esmerald.types import ( APIGateHandler, Dependencies, @@ -119,6 +121,7 @@ def create_client( cookies: Optional[CookieTypes] = None, redirect_slashes: Optional[bool] = None, tags: Optional[List[Tag]] = None, + webhooks: Optional[Sequence["WebhookGateway"]] = None, ) -> EsmeraldTestClient: return EsmeraldTestClient( app=Esmerald( @@ -161,6 +164,7 @@ def create_client( openapi_version=openapi_version, include_in_schema=include_in_schema, tags=tags, + webhooks=webhooks, ), base_url=base_url, backend=backend, diff --git a/tests/test_apiviews.py b/tests/test_apiviews.py index df4b8332..af810d53 100644 --- a/tests/test_apiviews.py +++ b/tests/test_apiviews.py @@ -15,6 +15,21 @@ from tests.models import Individual, IndividualFactory +class MyView(APIView): + @get("/event") + async def event(self) -> None: + """""" + + @get("/events") + async def events(self) -> None: + """""" + + +def test_can_generate_views(test_client_factory): + with create_client(routes=[Gateway(handler=MyView)], enable_openapi=False) as client: + assert len(client.app.routes) == 2 + + @pytest.mark.parametrize( "http_verb, http_method, expected_status_code, return_value, return_annotation", [ diff --git a/tests/webhooks/__init__.py b/tests/webhooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/webhooks/test_webhooks.py b/tests/webhooks/test_webhooks.py new file mode 100644 index 00000000..ced6b773 --- /dev/null +++ b/tests/webhooks/test_webhooks.py @@ -0,0 +1,86 @@ +import pytest + +from esmerald import ( + APIView, + Gateway, + ImproperlyConfigured, + WebhookGateway, + whdelete, + whead, + whget, + whoptions, + whpatch, + whpost, + whput, + whroute, + whtrace, +) +from esmerald.testclient import create_client + + +@whpost("new-event") +async def new_event() -> None: + """""" + + +@whpost("/event") +async def event() -> None: + """""" + + +class MyView(APIView): + @whpost("new-event") + async def new_event(self) -> None: + """""" + + @whpost("/event") + async def event(self) -> None: + """""" + + +def test_raise_improperly_configured_for_esmerald_routes(test_client_factory): + with pytest.raises(ImproperlyConfigured): + with create_client(routes=[WebhookGateway(handler=new_event)]): + """""" + + +def test_raise_improperly_configured_for_webhooks(test_client_factory): + with pytest.raises(ImproperlyConfigured): + with create_client(routes=[], webhooks=[Gateway(handler=event)]): + """""" + + +def test_can_generate_webhooks_from_apiview(test_client_factory): + with create_client(routes=[], webhooks=[WebhookGateway(handler=MyView)]) as client: + assert len(client.app.webhooks) == 2 + + +@pytest.mark.parametrize( + "verb,is_route", + [ + (whpost, False), + (whdelete, False), + (whead, False), + (whget, False), + (whoptions, False), + (whpatch, False), + (whput, False), + (whtrace, False), + (whroute, True), + ], +) +def test_verbs(verb, is_route): + if is_route: + + @verb("event", methods=["PUT", "POST", "DELETE", "GET"]) + async def event() -> None: + """""" + + else: + + @verb("event") + async def event() -> None: + """""" + + with create_client(routes=[], webhooks=[WebhookGateway(handler=event)]) as client: + assert len(client.app.webhooks) == 1 From 46022b1b6996c80cb0afc4ff0b48064a347e2d6e Mon Sep 17 00:00:00 2001 From: tarsil Date: Tue, 25 Jul 2023 15:02:01 +0100 Subject: [PATCH 3/3] Fix typing in applications for WebhookHandler --- esmerald/applications.py | 10 +++++----- esmerald/routing/gateways.py | 8 ++++---- esmerald/routing/router.py | 2 +- esmerald/routing/views.py | 20 ++++++++++++-------- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/esmerald/applications.py b/esmerald/applications.py index 2c4f8dee..69788ce7 100644 --- a/esmerald/applications.py +++ b/esmerald/applications.py @@ -44,7 +44,7 @@ from esmerald.pluggables import Extension, Pluggable from esmerald.protocols.template import TemplateEngineProtocol from esmerald.routing import gateways, views -from esmerald.routing.router import HTTPHandler, Include, Router, WebSocketHandler +from esmerald.routing.router import HTTPHandler, Include, Router, WebhookHandler, WebSocketHandler from esmerald.types import ( APIGateHandler, ASGIApp, @@ -61,9 +61,9 @@ ) from esmerald.utils.helpers import is_class_and_subclass -if TYPE_CHECKING: - from esmerald.conf import EsmeraldLazySettings # pragma: no cover - from esmerald.types import SettingsType, TemplateConfig # pragma: no cover +if TYPE_CHECKING: # pragma: no cover + from esmerald.conf import EsmeraldLazySettings + from esmerald.types import SettingsType, TemplateConfig AppType = TypeVar("AppType", bound="Esmerald") @@ -379,7 +379,7 @@ def create_webhooks_signature_model(self, webhooks: Sequence[gateways.WebhookGat route_handlers = handler.get_route_handlers() for route_handler in route_handlers: gate = gateways.WebhookGateway( - handler=route_handler, + handler=cast("WebhookHandler", route_handler), name=route_handler.fn.__name__, ) diff --git a/esmerald/routing/gateways.py b/esmerald/routing/gateways.py index c2561dfa..a3d1023c 100644 --- a/esmerald/routing/gateways.py +++ b/esmerald/routing/gateways.py @@ -1,5 +1,5 @@ import re -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union, cast +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence, Union, cast from starlette.routing import Route as StarletteRoute from starlette.routing import WebSocketRoute as StarletteWebSocketRoute @@ -247,11 +247,11 @@ def __init__( self._interceptors: Union[List["Interceptor"], "VoidType"] = Void self.name = name self.handler = handler - self.dependencies = {} + self.dependencies: Any = {} self.interceptors: Sequence["Interceptor"] = [] self.permissions: Sequence["Permission"] = [] - self.middleware = [] - self.exception_handlers = {} + self.middleware: Any = [] + self.exception_handlers: Any = {} self.response_class = None self.response_cookies = None self.response_headers = None diff --git a/esmerald/routing/router.py b/esmerald/routing/router.py index fabfffc8..c09d42d2 100644 --- a/esmerald/routing/router.py +++ b/esmerald/routing/router.py @@ -819,7 +819,7 @@ def __init__( ) -> None: _path: str = None if not path: - _path = "/" # pragma: no covergit add + _path = "/" # pragma: no cover super().__init__( path=_path, endpoint=endpoint, diff --git a/esmerald/routing/views.py b/esmerald/routing/views.py index bf658b2c..47e459c1 100644 --- a/esmerald/routing/views.py +++ b/esmerald/routing/views.py @@ -10,7 +10,7 @@ from esmerald.interceptors.types import Interceptor from esmerald.permissions.types import Permission from esmerald.routing.gateways import Gateway, WebSocketGateway - from esmerald.routing.router import HTTPHandler, WebSocketHandler + from esmerald.routing.router import HTTPHandler, WebhookHandler, WebSocketHandler from esmerald.transformers.model import TransformerModel from esmerald.types import ( Dependencies, @@ -80,7 +80,7 @@ def get_filtered_handler(self) -> List[str]: """ Filters out the names of the functions that are not part of the handler itself. """ - from esmerald.routing.router import HTTPHandler, WebSocketHandler + from esmerald.routing.router import HTTPHandler, WebhookHandler, WebSocketHandler filtered_handlers = [ attr for attr in dir(self) if not attr.startswith("__") and not attr.endswith("__") @@ -89,20 +89,22 @@ def get_filtered_handler(self) -> List[str]: for handler_name in filtered_handlers: if handler_name not in dir(APIView) and isinstance( - getattr(self, handler_name), (HTTPHandler, WebSocketHandler) + getattr(self, handler_name), (HTTPHandler, WebSocketHandler, WebhookHandler) ): route_handlers.append(handler_name) return route_handlers - def get_route_handlers(self) -> List[Union["HTTPHandler", "WebSocketHandler"]]: + def get_route_handlers( + self, + ) -> List[Union["HTTPHandler", "WebSocketHandler", "WebhookHandler"]]: """A getter for the apiview's route handlers that sets their parent. Returns: A list containing a copy of the route handlers defined inside the APIView. """ - from esmerald.routing.router import HTTPHandler, WebSocketHandler + from esmerald.routing.router import HTTPHandler, WebhookHandler, WebSocketHandler - route_handlers: List[Union[HTTPHandler, WebSocketHandler]] = [] + route_handlers: List[Union[HTTPHandler, WebSocketHandler, WebhookHandler]] = [] filtered_handlers = self.get_filtered_handler() for handler in filtered_handlers: @@ -129,7 +131,9 @@ def get_route_handlers(self) -> List[Union["HTTPHandler", "WebSocketHandler"]]: return route_handlers - def get_route_middleware(self, handler: Union["HTTPHandler", "WebSocketHandler"]) -> None: + def get_route_middleware( + self, handler: Union["HTTPHandler", "WebSocketHandler", "WebhookHandler"] + ) -> None: """ Gets the list of extended middlewares for the handler starting from the last to the first by reversing the list @@ -138,7 +142,7 @@ def get_route_middleware(self, handler: Union["HTTPHandler", "WebSocketHandler"] handler.middleware.insert(0, middleware) def get_exception_handlers( - self, handler: Union["HTTPHandler", "WebSocketHandler"] + self, handler: Union["HTTPHandler", "WebSocketHandler", "WebhookHandler"] ) -> "ExceptionHandlerMap": """ Gets the dict of extended exception handlers for the handler starting from the last