diff --git a/drf_spectacular/generators.py b/drf_spectacular/generators.py index 9ae374ee..ac53f253 100644 --- a/drf_spectacular/generators.py +++ b/drf_spectacular/generators.py @@ -15,7 +15,7 @@ from drf_spectacular.plumbing import ( ComponentRegistry, alpha_operation_sorter, build_root_object, camelize_operation, get_class, is_versioning_supported, modify_for_versioning, normalize_result_object, - operation_matches_version, sanitize_result_object, + operation_matches_version, process_webhooks, sanitize_result_object, ) from drf_spectacular.settings import spectacular_settings @@ -280,6 +280,7 @@ def get_schema(self, request=None, public=False): result = build_root_object( paths=self.parse(request, public), components=self.registry.build(spectacular_settings.APPEND_COMPONENTS), + webhooks=process_webhooks(spectacular_settings.WEBHOOKS, self.registry), version=self.api_version or getattr(request, 'version', None), ) for hook in spectacular_settings.POSTPROCESSING_HOOKS: diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index c5a6a03b..3b3a99aa 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -57,8 +57,8 @@ _KnownPythonTypes, ) from drf_spectacular.utils import ( - OpenApiExample, OpenApiParameter, _FieldType, _ListSerializerType, _ParameterLocationType, - _SchemaType, _SerializerType, + OpenApiExample, OpenApiParameter, OpenApiWebhook, _FieldType, _ListSerializerType, + _ParameterLocationType, _SchemaType, _SerializerType, ) try: @@ -477,7 +477,7 @@ def build_bearer_security_scheme_object(header_name, token_prefix, bearer_format } -def build_root_object(paths, components, version) -> _SchemaType: +def build_root_object(paths, components, webhooks, version) -> _SchemaType: settings = spectacular_settings if settings.VERSION and version: version = f'{settings.VERSION} ({version})' @@ -508,6 +508,8 @@ def build_root_object(paths, components, version) -> _SchemaType: root['tags'] = settings.TAGS if settings.EXTERNAL_DOCS: root['externalDocs'] = settings.EXTERNAL_DOCS + if webhooks: + root['webhooks'] = webhooks return root @@ -1404,3 +1406,62 @@ def build_serializer_context(view) -> typing.Dict[str, Any]: return view.get_serializer_context() except: # noqa return {'request': view.request} + + +def process_webhooks(webhooks: List[OpenApiWebhook], registry: ComponentRegistry): + """ + Creates a mocked view for every webhook. The given extend_schema decorator then + specifies the expectations on the receiving end of the callback. Effectively + simulates a sub-schema from the opposing perspective via a virtual view definition. + """ + result = {} + + for webhook in webhooks: + if isinstance(webhook.decorator, dict): + methods = webhook.decorator + else: + methods = {'post': webhook.decorator} + + path_items = {} + + for method, decorator in methods.items(): + # a dict indicates a raw schema; use directly + if isinstance(decorator, dict): + path_items[method.lower()] = decorator + continue + + mocked_view = build_mocked_view( + method=method, + path="/", + extend_schema_decorator=decorator, + registry=registry, + ) + operation = {} + + description = mocked_view.schema.get_description() + if description: + operation['description'] = description + + summary = mocked_view.schema.get_summary() + if summary: + operation['summary'] = summary + + request_body = mocked_view.schema._get_request_body('response') + if request_body: + operation['requestBody'] = request_body + + deprecated = mocked_view.schema.is_deprecated() + if deprecated: + operation['deprecated'] = deprecated + + operation['responses'] = mocked_view.schema._get_response_bodies('request') + + extensions = mocked_view.schema.get_extensions() + if extensions: + operation.update(sanitize_specification_extensions(extensions)) + + path_items[method.lower()] = operation + + result[webhook.name] = path_items + + return result diff --git a/drf_spectacular/settings.py b/drf_spectacular/settings.py index fc29b95f..629b6114 100644 --- a/drf_spectacular/settings.py +++ b/drf_spectacular/settings.py @@ -199,6 +199,9 @@ 'SERVERS': [], # Tags defined in the global scope 'TAGS': [], + # Optional: List of OpenAPI 3.1 webhooks. Each entry should be an import path to an + # OpenApiWebhook instance. + 'WEBHOOKS': [], # Optional: MUST contain 'url', may contain "description" 'EXTERNAL_DOCS': {}, @@ -232,6 +235,7 @@ 'AUTHENTICATION_WHITELIST', 'RENDERER_WHITELIST', 'PARSER_WHITELIST', + 'WEBHOOKS', ] diff --git a/drf_spectacular/utils.py b/drf_spectacular/utils.py index da009d0a..6fef226b 100644 --- a/drf_spectacular/utils.py +++ b/drf_spectacular/utils.py @@ -316,6 +316,33 @@ def __init__( self.decorator = decorator +class OpenApiWebhook(OpenApiSchemaBase): + """ + Helper class to document webhook definitions. A webhook specifies a possible out-of-band + request initiated by the API provider and the expected responses from the consumer. + + Please note that this particular :func:`@extend_schema <.extend_schema>` instance operates + from the perspective of the webhook origin, which means that ``request`` specifies the + outgoing request. + + For convenience sake, we assume the API provider sends a POST request with a body of type + ``application/json`` and the receiver responds with ``200`` if the event was successfully + received. + + :param name: Name under which this webhook is listed in the schema. + :param decorator: :func:`@extend_schema <.extend_schema>` decorator that specifies the receiving + endpoint. In this special context the allowed parameters are ``requests``, ``responses``, + ``summary``, ``description``, ``deprecated``. + """ + def __init__( + self, + name: _StrOrPromise, + decorator: Union[Callable[[F], F], Dict[str, Callable[[F], F]], Dict[str, Any]], + ): + self.name = name + self.decorator = decorator + + def extend_schema( operation_id: Optional[str] = None, parameters: Optional[Sequence[Union[OpenApiParameter, _SerializerType]]] = None, diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 00000000..327919cb --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,42 @@ +from unittest import mock + +import pytest +from rest_framework import serializers + +from drf_spectacular.generators import SchemaGenerator +from drf_spectacular.utils import OpenApiResponse, OpenApiWebhook, extend_schema +from tests import assert_schema + + +class EventSerializer(serializers.Serializer): + id = serializers.CharField(read_only=True) + change = serializers.CharField() + external_id = serializers.CharField(write_only=True) + + +urlpatterns = [] # type: ignore + +subscription_event = OpenApiWebhook( + name='SubscriptionEvent', + decorator=extend_schema( + summary="some summary", + description='pushes events to a webhook url as "application/x-www-form-urlencoded"', + request={ + 'application/x-www-form-urlencoded': EventSerializer, + }, + responses={ + 200: OpenApiResponse(description='event was successfully received'), + '4XX': OpenApiResponse(description='event will be retried shortly'), + }, + ), +) + + +@pytest.mark.urls(__name__) +@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') +@mock.patch('drf_spectacular.settings.spectacular_settings.WEBHOOKS', [subscription_event]) +def test_webhooks_settings(no_warnings): + assert_schema( + SchemaGenerator().get_schema(request=None, public=True), + 'tests/test_webhooks.yml' + ) diff --git a/tests/test_webhooks.yml b/tests/test_webhooks.yml new file mode 100644 index 00000000..6199bb8c --- /dev/null +++ b/tests/test_webhooks.yml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: '' + version: 0.0.0 +paths: {} +components: + schemas: + Event: + type: object + properties: + id: + type: string + readOnly: true + change: + type: string + external_id: + type: string + writeOnly: true + required: + - change + - external_id + - id +webhooks: + SubscriptionEvent: + post: + description: pushes events to a webhook url as "application/x-www-form-urlencoded" + summary: some summary + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Event' + required: true + responses: + '200': + description: event was successfully received + 4XX: + description: event will be retried shortly