Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OpenAPI 3.1 webhook support #1135

Merged
merged 7 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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_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 @@
root['tags'] = settings.TAGS
if settings.EXTERNAL_DOCS:
root['externalDocs'] = settings.EXTERNAL_DOCS
if webhooks:
root['webhooks'] = webhooks
tfranzel marked this conversation as resolved.
Show resolved Hide resolved
return root


Expand Down Expand Up @@ -1404,3 +1406,62 @@
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(

Check warning on line 1433 in drf_spectacular/plumbing.py

View check run for this annotation

Codecov / codecov/patch

drf_spectacular/plumbing.py#L1433

Added line #L1433 was not covered by tests
method=method,
path="/",
extend_schema_decorator=decorator,
registry=registry,
)
operation = {}

description = mocked_view.schema.get_description()
if description:
operation['description'] = description

Check warning on line 1443 in drf_spectacular/plumbing.py

View check run for this annotation

Codecov / codecov/patch

drf_spectacular/plumbing.py#L1442-L1443

Added lines #L1442 - L1443 were not covered by tests

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

Check warning on line 1467 in drf_spectacular/plumbing.py

View check run for this annotation

Codecov / codecov/patch

drf_spectacular/plumbing.py#L1467

Added line #L1467 was not covered by tests
4 changes: 4 additions & 0 deletions drf_spectacular/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': {},

Expand Down Expand Up @@ -232,6 +235,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'),
},
tfranzel marked this conversation as resolved.
Show resolved Hide resolved
),
)


@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