Skip to content

Commit

Permalink
Merge pull request #1135 from federicobond/webhooks
Browse files Browse the repository at this point in the history
Add OpenAPI 3.1 webhook support
  • Loading branch information
tfranzel committed Jan 18, 2024
2 parents aeca119 + dbddbc3 commit d52c9f3
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 4 deletions.
3 changes: 2 additions & 1 deletion drf_spectacular/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
67 changes: 64 additions & 3 deletions drf_spectacular/plumbing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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})'
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -1416,3 +1418,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
4 changes: 4 additions & 0 deletions drf_spectacular/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,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': {},

Expand Down Expand Up @@ -235,6 +238,7 @@
'AUTHENTICATION_WHITELIST',
'RENDERER_WHITELIST',
'PARSER_WHITELIST',
'WEBHOOKS',
]


Expand Down
27 changes: 27 additions & 0 deletions drf_spectacular/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions tests/test_webhooks.py
Original file line number Diff line number Diff line change
@@ -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'
)
38 changes: 38 additions & 0 deletions tests/test_webhooks.yml
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit d52c9f3

Please sign in to comment.