From 1a5c03927285ef444ddb530ab5c0fa6fab59c8fc Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Mon, 25 Dec 2023 14:59:30 -0300 Subject: [PATCH 1/7] Add OpenAPI 3.1 webhook support --- drf_spectacular/generators.py | 13 +++++++- drf_spectacular/openapi.py | 61 ++++++++++++++++++++++++++++++++++- drf_spectacular/plumbing.py | 4 ++- drf_spectacular/settings.py | 2 ++ drf_spectacular/utils.py | 10 ++++++ tests/__init__.py | 4 +-- tests/test_webhooks.py | 51 +++++++++++++++++++++++++++++ tests/test_webhooks.yml | 38 ++++++++++++++++++++++ 8 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 tests/test_webhooks.py create mode 100644 tests/test_webhooks.yml diff --git a/drf_spectacular/generators.py b/drf_spectacular/generators.py index 9ae374ee..5c8ea2fe 100644 --- a/drf_spectacular/generators.py +++ b/drf_spectacular/generators.py @@ -11,7 +11,7 @@ add_trace_message, error, get_override, reset_generator_stats, warn, ) from drf_spectacular.extensions import OpenApiViewExtension -from drf_spectacular.openapi import AutoSchema +from drf_spectacular.openapi import AutoSchema, process_webhooks 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, @@ -102,6 +102,7 @@ def __init__(self, *args, **kwargs): self.registry = ComponentRegistry() self.api_version = kwargs.pop('api_version', None) self.inspector = None + self.webhooks = kwargs.pop('webhooks', None) super().__init__(*args, **kwargs) def coerce_path(self, path, method, view): @@ -183,6 +184,11 @@ def _initialise_endpoints(self): self.inspector = self.endpoint_inspector_cls(self.patterns, self.urlconf) self.endpoints = self.inspector.get_api_endpoints() + def _initialise_webhooks(self): + if self.webhooks is None: + if spectacular_settings.OAS_VERSION.startswith('3.1'): + self.webhooks = spectacular_settings.WEBHOOKS + def _get_paths_and_endpoints(self): """ Generate (path, method, view) given (path, method, callback) for paths. @@ -274,12 +280,17 @@ def parse(self, input_request, public): return result + def get_webhooks(self): + self._initialise_webhooks() + return self.webhooks or [] + def get_schema(self, request=None, public=False): """ Generate a OpenAPI schema. """ reset_generator_stats() result = build_root_object( paths=self.parse(request, public), components=self.registry.build(spectacular_settings.APPEND_COMPONENTS), + webhooks=process_webhooks(self.get_webhooks(), self.registry), version=self.api_version or getattr(request, 'version', None), ) for hook in spectacular_settings.POSTPROCESSING_HOOKS: diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index ca1967bb..a3f05939 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -44,7 +44,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( Direction, OpenApiCallback, OpenApiExample, OpenApiParameter, OpenApiRequest, OpenApiResponse, - _SchemaType, _SerializerType, + OpenApiWebhook, _SchemaType, _SerializerType, ) @@ -1619,3 +1619,62 @@ def resolve_serializer( del self.registry[component] return ResolvedComponent(None, None) # sentinel return component + + +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/plumbing.py b/drf_spectacular/plumbing.py index c5a6a03b..94c10979 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -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 diff --git a/drf_spectacular/settings.py b/drf_spectacular/settings.py index fc29b95f..a0547f57 100644 --- a/drf_spectacular/settings.py +++ b/drf_spectacular/settings.py @@ -199,6 +199,8 @@ 'SERVERS': [], # Tags defined in the global scope 'TAGS': [], + # Optional: List of OpenAPI 3.1 webhooks. + 'WEBHOOKS': None, # Optional: MUST contain 'url', may contain "description" 'EXTERNAL_DOCS': {}, diff --git a/drf_spectacular/utils.py b/drf_spectacular/utils.py index da009d0a..09769853 100644 --- a/drf_spectacular/utils.py +++ b/drf_spectacular/utils.py @@ -316,6 +316,16 @@ def __init__( self.decorator = decorator +class OpenApiWebhook(OpenApiSchemaBase): + 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/__init__.py b/tests/__init__.py index 75b2496a..8409cd1f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -61,7 +61,7 @@ def assert_equal(actual, expected): assert actual == expected and not diff, diff -def generate_schema(route, viewset=None, view=None, view_function=None, patterns=None): +def generate_schema(route, viewset=None, view=None, view_function=None, patterns=None, webhooks=None): from django.urls import path from rest_framework import routers from rest_framework.viewsets import ViewSetMixin @@ -80,7 +80,7 @@ def generate_schema(route, viewset=None, view=None, view_function=None, patterns else: assert route is None and isinstance(patterns, list) - generator = SchemaGenerator(patterns=patterns) + generator = SchemaGenerator(patterns=patterns, webhooks=webhooks) schema = generator.get_schema(request=None, public=True) validate_schema(schema) # make sure generated schemas are always valid return schema diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 00000000..b5285572 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,51 @@ +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, generate_schema + + +class EventSerializer(serializers.Serializer): + id = serializers.CharField(read_only=True) + change = serializers.CharField() + external_id = serializers.CharField(write_only=True) + + +urlpatterns = [] # type: ignore +openapi_webhooks = [ + OpenApiWebhook( + name='SubscriptionEvent', + decorator=extend_schema( + summary="some summary", + description='pushes events to callbackUrl 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'), + }, + ), + ) +] + + +@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') +def test_webhooks(no_warnings): + assert_schema( + generate_schema(None, patterns=[], webhooks=openapi_webhooks), + 'tests/test_webhooks.yml' + ) + + +@pytest.mark.urls(__name__) +@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') +@mock.patch('drf_spectacular.settings.spectacular_settings.WEBHOOKS', openapi_webhooks) +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..ffdb00ef --- /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 callbackUrl 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 From 79c34322dcb297bf714ee7b94766060be56ccf39 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Wed, 10 Jan 2024 20:59:33 -0300 Subject: [PATCH 2/7] Load webhooks from settings as import paths --- drf_spectacular/generators.py | 10 +++++++--- tests/test_webhooks.py | 35 +++++++++++++++++------------------ 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/drf_spectacular/generators.py b/drf_spectacular/generators.py index 5c8ea2fe..51a07276 100644 --- a/drf_spectacular/generators.py +++ b/drf_spectacular/generators.py @@ -2,6 +2,7 @@ import re from django.urls import URLPattern, URLResolver +from django.utils.module_loading import import_string from rest_framework import views, viewsets from rest_framework.schemas.generators import BaseSchemaGenerator from rest_framework.schemas.generators import EndpointEnumerator as BaseEndpointEnumerator @@ -185,9 +186,12 @@ def _initialise_endpoints(self): self.endpoints = self.inspector.get_api_endpoints() def _initialise_webhooks(self): - if self.webhooks is None: - if spectacular_settings.OAS_VERSION.startswith('3.1'): - self.webhooks = spectacular_settings.WEBHOOKS + if self.webhooks: + return + if not spectacular_settings.OAS_VERSION.startswith('3.1'): + return + webhooks = spectacular_settings.WEBHOOKS or [] + self.webhooks = [import_string(s) for s in webhooks] def _get_paths_and_endpoints(self): """ diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index b5285572..4317c0db 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -15,35 +15,34 @@ class EventSerializer(serializers.Serializer): urlpatterns = [] # type: ignore -openapi_webhooks = [ - OpenApiWebhook( - name='SubscriptionEvent', - decorator=extend_schema( - summary="some summary", - description='pushes events to callbackUrl 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'), - }, - ), - ) -] + +subscription_event = OpenApiWebhook( + name='SubscriptionEvent', + decorator=extend_schema( + summary="some summary", + description='pushes events to callbackUrl 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'), + }, + ), +) @mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') def test_webhooks(no_warnings): assert_schema( - generate_schema(None, patterns=[], webhooks=openapi_webhooks), + generate_schema(None, patterns=[], webhooks=[subscription_event]), 'tests/test_webhooks.yml' ) @pytest.mark.urls(__name__) @mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') -@mock.patch('drf_spectacular.settings.spectacular_settings.WEBHOOKS', openapi_webhooks) +@mock.patch('drf_spectacular.settings.spectacular_settings.WEBHOOKS', ["tests.test_webhooks.subscription_event"]) def test_webhooks_settings(no_warnings): assert_schema( SchemaGenerator().get_schema(request=None, public=True), From eb605301f9f3454c181b8b3ea1eab893920dfe0a Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Thu, 11 Jan 2024 21:20:26 -0300 Subject: [PATCH 3/7] Move process_webhooks function to plumbing module --- drf_spectacular/generators.py | 4 +-- drf_spectacular/openapi.py | 61 +-------------------------------- drf_spectacular/plumbing.py | 63 +++++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/drf_spectacular/generators.py b/drf_spectacular/generators.py index 51a07276..3aa2909c 100644 --- a/drf_spectacular/generators.py +++ b/drf_spectacular/generators.py @@ -12,11 +12,11 @@ add_trace_message, error, get_override, reset_generator_stats, warn, ) from drf_spectacular.extensions import OpenApiViewExtension -from drf_spectacular.openapi import AutoSchema, process_webhooks +from drf_spectacular.openapi import AutoSchema 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 diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index a3f05939..ca1967bb 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -44,7 +44,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( Direction, OpenApiCallback, OpenApiExample, OpenApiParameter, OpenApiRequest, OpenApiResponse, - OpenApiWebhook, _SchemaType, _SerializerType, + _SchemaType, _SerializerType, ) @@ -1619,62 +1619,3 @@ def resolve_serializer( del self.registry[component] return ResolvedComponent(None, None) # sentinel return component - - -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/plumbing.py b/drf_spectacular/plumbing.py index 94c10979..e9a22ad3 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: @@ -1406,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) -> _SchemaType: + """ + 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 From 50c15e778afac4265e17bc47658e7d100039d1f4 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Thu, 11 Jan 2024 21:29:46 -0300 Subject: [PATCH 4/7] Remove webhooks parameter from SchemaGenerator and pass settings directly --- drf_spectacular/generators.py | 16 +--------------- drf_spectacular/plumbing.py | 5 ++++- drf_spectacular/settings.py | 1 + tests/__init__.py | 4 ++-- tests/test_webhooks.py | 12 ++---------- 5 files changed, 10 insertions(+), 28 deletions(-) diff --git a/drf_spectacular/generators.py b/drf_spectacular/generators.py index 3aa2909c..ac53f253 100644 --- a/drf_spectacular/generators.py +++ b/drf_spectacular/generators.py @@ -2,7 +2,6 @@ import re from django.urls import URLPattern, URLResolver -from django.utils.module_loading import import_string from rest_framework import views, viewsets from rest_framework.schemas.generators import BaseSchemaGenerator from rest_framework.schemas.generators import EndpointEnumerator as BaseEndpointEnumerator @@ -103,7 +102,6 @@ def __init__(self, *args, **kwargs): self.registry = ComponentRegistry() self.api_version = kwargs.pop('api_version', None) self.inspector = None - self.webhooks = kwargs.pop('webhooks', None) super().__init__(*args, **kwargs) def coerce_path(self, path, method, view): @@ -185,14 +183,6 @@ def _initialise_endpoints(self): self.inspector = self.endpoint_inspector_cls(self.patterns, self.urlconf) self.endpoints = self.inspector.get_api_endpoints() - def _initialise_webhooks(self): - if self.webhooks: - return - if not spectacular_settings.OAS_VERSION.startswith('3.1'): - return - webhooks = spectacular_settings.WEBHOOKS or [] - self.webhooks = [import_string(s) for s in webhooks] - def _get_paths_and_endpoints(self): """ Generate (path, method, view) given (path, method, callback) for paths. @@ -284,17 +274,13 @@ def parse(self, input_request, public): return result - def get_webhooks(self): - self._initialise_webhooks() - return self.webhooks or [] - def get_schema(self, request=None, public=False): """ Generate a OpenAPI schema. """ reset_generator_stats() result = build_root_object( paths=self.parse(request, public), components=self.registry.build(spectacular_settings.APPEND_COMPONENTS), - webhooks=process_webhooks(self.get_webhooks(), self.registry), + 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 e9a22ad3..349aae4a 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -1408,7 +1408,7 @@ def build_serializer_context(view) -> typing.Dict[str, Any]: return {'request': view.request} -def process_webhooks(webhooks: List[OpenApiWebhook], registry: ComponentRegistry) -> _SchemaType: +def process_webhooks(webhooks: Union[List[OpenApiWebhook], None], registry: ComponentRegistry) -> _SchemaType: """ Creates a mocked view for every webhook. The given extend_schema decorator then specifies the expectations on the receiving end of the callback. Effectively @@ -1416,6 +1416,9 @@ def process_webhooks(webhooks: List[OpenApiWebhook], registry: ComponentRegistry """ result = {} + if webhooks is None: + return result + for webhook in webhooks: if isinstance(webhook.decorator, dict): methods = webhook.decorator diff --git a/drf_spectacular/settings.py b/drf_spectacular/settings.py index a0547f57..0f0b942f 100644 --- a/drf_spectacular/settings.py +++ b/drf_spectacular/settings.py @@ -234,6 +234,7 @@ 'AUTHENTICATION_WHITELIST', 'RENDERER_WHITELIST', 'PARSER_WHITELIST', + 'WEBHOOKS', ] diff --git a/tests/__init__.py b/tests/__init__.py index 8409cd1f..75b2496a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -61,7 +61,7 @@ def assert_equal(actual, expected): assert actual == expected and not diff, diff -def generate_schema(route, viewset=None, view=None, view_function=None, patterns=None, webhooks=None): +def generate_schema(route, viewset=None, view=None, view_function=None, patterns=None): from django.urls import path from rest_framework import routers from rest_framework.viewsets import ViewSetMixin @@ -80,7 +80,7 @@ def generate_schema(route, viewset=None, view=None, view_function=None, patterns else: assert route is None and isinstance(patterns, list) - generator = SchemaGenerator(patterns=patterns, webhooks=webhooks) + generator = SchemaGenerator(patterns=patterns) schema = generator.get_schema(request=None, public=True) validate_schema(schema) # make sure generated schemas are always valid return schema diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 4317c0db..a4de80ed 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -5,7 +5,7 @@ from drf_spectacular.generators import SchemaGenerator from drf_spectacular.utils import OpenApiResponse, OpenApiWebhook, extend_schema -from tests import assert_schema, generate_schema +from tests import assert_schema class EventSerializer(serializers.Serializer): @@ -32,17 +32,9 @@ class EventSerializer(serializers.Serializer): ) -@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') -def test_webhooks(no_warnings): - assert_schema( - generate_schema(None, patterns=[], webhooks=[subscription_event]), - 'tests/test_webhooks.yml' - ) - - @pytest.mark.urls(__name__) @mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') -@mock.patch('drf_spectacular.settings.spectacular_settings.WEBHOOKS', ["tests.test_webhooks.subscription_event"]) +@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), From 0e628a39419420ba6eee6dfb1e3ffa98da5d08f8 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Thu, 11 Jan 2024 21:48:13 -0300 Subject: [PATCH 5/7] Add documentation for OpenApiWebhook --- drf_spectacular/settings.py | 3 ++- drf_spectacular/utils.py | 17 +++++++++++++++++ tests/test_webhooks.py | 2 +- tests/test_webhooks.yml | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/drf_spectacular/settings.py b/drf_spectacular/settings.py index 0f0b942f..936c74dd 100644 --- a/drf_spectacular/settings.py +++ b/drf_spectacular/settings.py @@ -199,7 +199,8 @@ 'SERVERS': [], # Tags defined in the global scope 'TAGS': [], - # Optional: List of OpenAPI 3.1 webhooks. + # Optional: List of OpenAPI 3.1 webhooks. Each entry should be an import path to an + # OpenApiWebhook instance. 'WEBHOOKS': None, # Optional: MUST contain 'url', may contain "description" 'EXTERNAL_DOCS': {}, diff --git a/drf_spectacular/utils.py b/drf_spectacular/utils.py index 09769853..6fef226b 100644 --- a/drf_spectacular/utils.py +++ b/drf_spectacular/utils.py @@ -317,6 +317,23 @@ def __init__( 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, diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index a4de80ed..327919cb 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -20,7 +20,7 @@ class EventSerializer(serializers.Serializer): name='SubscriptionEvent', decorator=extend_schema( summary="some summary", - description='pushes events to callbackUrl as "application/x-www-form-urlencoded"', + description='pushes events to a webhook url as "application/x-www-form-urlencoded"', request={ 'application/x-www-form-urlencoded': EventSerializer, }, diff --git a/tests/test_webhooks.yml b/tests/test_webhooks.yml index ffdb00ef..6199bb8c 100644 --- a/tests/test_webhooks.yml +++ b/tests/test_webhooks.yml @@ -23,7 +23,7 @@ components: webhooks: SubscriptionEvent: post: - description: pushes events to callbackUrl as "application/x-www-form-urlencoded" + description: pushes events to a webhook url as "application/x-www-form-urlencoded" summary: some summary requestBody: content: From 25aacb7c2dd3f3a46b5d6d180d228668fda7e5c8 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Thu, 11 Jan 2024 21:56:10 -0300 Subject: [PATCH 6/7] Default WEBHOOKS setting to an empty list --- drf_spectacular/plumbing.py | 5 +---- drf_spectacular/settings.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index 349aae4a..e9a22ad3 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -1408,7 +1408,7 @@ def build_serializer_context(view) -> typing.Dict[str, Any]: return {'request': view.request} -def process_webhooks(webhooks: Union[List[OpenApiWebhook], None], registry: ComponentRegistry) -> _SchemaType: +def process_webhooks(webhooks: List[OpenApiWebhook], registry: ComponentRegistry) -> _SchemaType: """ Creates a mocked view for every webhook. The given extend_schema decorator then specifies the expectations on the receiving end of the callback. Effectively @@ -1416,9 +1416,6 @@ def process_webhooks(webhooks: Union[List[OpenApiWebhook], None], registry: Comp """ result = {} - if webhooks is None: - return result - for webhook in webhooks: if isinstance(webhook.decorator, dict): methods = webhook.decorator diff --git a/drf_spectacular/settings.py b/drf_spectacular/settings.py index 936c74dd..629b6114 100644 --- a/drf_spectacular/settings.py +++ b/drf_spectacular/settings.py @@ -201,7 +201,7 @@ 'TAGS': [], # Optional: List of OpenAPI 3.1 webhooks. Each entry should be an import path to an # OpenApiWebhook instance. - 'WEBHOOKS': None, + 'WEBHOOKS': [], # Optional: MUST contain 'url', may contain "description" 'EXTERNAL_DOCS': {}, From dbddbc33c2c4318922b56b732e20fb7c37adc3a9 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Thu, 18 Jan 2024 20:30:33 +0100 Subject: [PATCH 7/7] remove type again because it is too cumbersome --- drf_spectacular/plumbing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index e9a22ad3..3b3a99aa 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -1408,7 +1408,7 @@ def build_serializer_context(view) -> typing.Dict[str, Any]: return {'request': view.request} -def process_webhooks(webhooks: List[OpenApiWebhook], registry: ComponentRegistry) -> _SchemaType: +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