Skip to content

Commit d49c367

Browse files
committed
Add callback component (#245)
Add support for OpenAPI v3 callback components: - Register callback in COMPONENT_SUBSECTIONS for OpenAPI v3. - Add Components.callback() for registering reusable callbacks. - Support lazy registration and resolution of referenced callbacks in operations. - Add callback_helper to BasePlugin and MarshmallowPlugin. - Add test coverage and validation against OpenAPI schema.
1 parent 0b66a26 commit d49c367

9 files changed

Lines changed: 243 additions & 7 deletions

File tree

‎CHANGELOG.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Changelog
44
unreleased
55
**********
66

7+
Features:
8+
9+
- Add ``callback`` component (:issue:`245`).
10+
711
Bug fixes:
812

913
- Recognize ``trace`` operations defined in view docstrings, instead of

‎src/apispec/core.py‎

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ def __init__(
5353
self.examples: dict[str, dict] = {}
5454
self.links: dict[str, dict] = {}
5555
self.security_schemes: dict[str, dict] = {}
56+
self.callbacks: dict[str, dict] = {}
5657
self.schemas_lazy: dict[str, dict] = {}
5758
self.responses_lazy: dict[str, dict] = {}
5859
self.parameters_lazy: dict[str, dict] = {}
5960
self.headers_lazy: dict[str, dict] = {}
6061
self.examples_lazy: dict[str, dict] = {}
6162
self.links_lazy: dict[str, dict] = {}
63+
self.callbacks_lazy: dict[str, dict] = {}
6264

6365
self._subsections = {
6466
"schema": self.schemas,
@@ -68,6 +70,7 @@ def __init__(
6870
"example": self.examples,
6971
"link": self.links,
7072
"security_scheme": self.security_schemes,
73+
"callback": self.callbacks,
7174
}
7275
self._subsections_lazy = {
7376
"schema": self.schemas_lazy,
@@ -76,6 +79,7 @@ def __init__(
7679
"header": self.headers_lazy,
7780
"example": self.examples_lazy,
7881
"link": self.links_lazy,
82+
"callback": self.callbacks_lazy,
7983
}
8084

8185
def to_dict(self) -> dict[str, dict]:
@@ -329,6 +333,38 @@ def security_scheme(self, component_id: str, component: dict) -> Components:
329333
self._register_component("security_scheme", component_id, component)
330334
return self
331335

336+
def callback(
337+
self,
338+
component_id: str,
339+
component: dict | None = None,
340+
*,
341+
lazy: bool = False,
342+
**kwargs: typing.Any,
343+
) -> Components:
344+
"""Add a callback which can be referenced.
345+
346+
:param str component_id: identifier by which callback may be referenced
347+
:param dict component: callback fields
348+
:param bool lazy: register component only when referenced in the spec
349+
:param kwargs: plugin-specific arguments
350+
351+
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#callbackObject
352+
"""
353+
if component_id in self.callbacks:
354+
raise DuplicateComponentNameError(
355+
f'Another callback with name "{component_id}" is already registered.'
356+
)
357+
ret = deepcopy(component) or {}
358+
# Execute all helpers from plugins
359+
for plugin in self._plugins:
360+
try:
361+
ret.update(plugin.callback_helper(ret, **kwargs) or {})
362+
except PluginMethodNotImplementedError:
363+
continue
364+
self._resolve_refs_in_callback(ret)
365+
self._register_component("callback", component_id, ret, lazy=lazy)
366+
return self
367+
332368
def _resolve_schema(self, obj) -> None:
333369
"""Replace schema reference as string with a $ref if needed
334370
@@ -375,6 +411,13 @@ def _resolve_refs_in_request_body(self, request_body) -> None:
375411
self._resolve_schema(media_type)
376412
self._resolve_examples(media_type)
377413

414+
def _resolve_refs_in_callback(self, callback) -> None:
415+
# callback is OpenAPI v3+
416+
if "$ref" not in callback:
417+
for path in callback.values():
418+
if isinstance(path, dict):
419+
self.resolve_refs_in_path(path)
420+
378421
def _resolve_refs_in_response(self, response) -> None:
379422
if self.openapi_version.major < 3:
380423
self._resolve_schema(response)
@@ -397,10 +440,12 @@ def _resolve_refs_in_operation(self, operation) -> None:
397440
parameters.append(parameter)
398441
operation["parameters"] = parameters
399442
if "callbacks" in operation:
400-
for callback in operation["callbacks"].values():
401-
if isinstance(callback, dict):
402-
for path in callback.values():
403-
self.resolve_refs_in_path(path)
443+
callbacks = {}
444+
for name, callback in operation["callbacks"].items():
445+
callback = self.get_ref("callback", callback)
446+
self._resolve_refs_in_callback(callback)
447+
callbacks[name] = callback
448+
operation["callbacks"] = callbacks
404449
if "requestBody" in operation:
405450
self._resolve_refs_in_request_body(operation["requestBody"])
406451
if "responses" in operation:

‎src/apispec/ext/marshmallow/__init__.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,16 @@ def header_helper(self, header: dict, **kwargs: typing.Any):
222222
self.resolver.resolve_schema(header)
223223
return header
224224

225+
def callback_helper(self, callback: dict, **kwargs: typing.Any):
226+
"""Callback component helper that allows using a marshmallow
227+
:class:`Schema <marshmallow.Schema>` in callback definition.
228+
229+
:param dict callback: callback fields.
230+
"""
231+
assert self.resolver # needed for mypy
232+
self.resolver.resolve_callback({"": callback})
233+
return callback
234+
225235
def operation_helper(
226236
self,
227237
path: str | None = None,

‎src/apispec/ext/marshmallow/schema_resolver.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ def resolve_callback(self, callbacks):
7575
7676
"""
7777
for callback in callbacks.values():
78-
if isinstance(callback, dict):
78+
if isinstance(callback, dict) and "$ref" not in callback:
7979
for path in callback.values():
80-
self.resolve_operations(path)
80+
if isinstance(path, dict):
81+
self.resolve_operations(path)
8182

8283
def resolve_parameters(self, parameters):
8384
"""Resolve marshmallow Schemas in a list of OpenAPI `Parameter Objects

‎src/apispec/plugin.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ def response_helper(self, response: dict, **kwargs: typing.Any) -> dict | None:
3636
"""
3737
raise PluginMethodNotImplementedError
3838

39+
def callback_helper(self, callback: dict, **kwargs: typing.Any) -> dict | None:
40+
"""May return callback component description as a dict.
41+
42+
:param dict callback: Callback fields
43+
:param kwargs: All additional keywords arguments sent to `APISpec.callback()`
44+
"""
45+
raise PluginMethodNotImplementedError
46+
3947
def parameter_helper(self, parameter: dict, **kwargs: typing.Any) -> dict | None:
4048
"""May return parameter component description as a dict.
4149

‎src/apispec/utils.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"example": "examples",
2222
"link": "links",
2323
"security_scheme": "securitySchemes",
24+
"callback": "callbacks",
2425
},
2526
}
2627

@@ -30,7 +31,7 @@ def build_reference(
3031
) -> dict[str, str]:
3132
"""Return path to reference
3233
33-
:param str component_type: Component type (schema, parameter, response, security_scheme)
34+
:param str component_type: Component type (schema, parameter, response, security_scheme, callback)
3435
:param int openapi_major_version: OpenAPI major version (2 or 3)
3536
:param str component_name: Name of component to reference
3637
"""

‎tests/test_core.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .utils import (
1616
build_ref,
17+
get_callbacks,
1718
get_examples,
1819
get_headers,
1920
get_links,
@@ -22,6 +23,7 @@
2223
get_responses,
2324
get_schemas,
2425
get_security_schemes,
26+
validate_spec,
2527
)
2628

2729
description = "This is a sample Petstore server. You can find out more "
@@ -359,6 +361,40 @@ def test_links_duplicate_name(self, spec):
359361
with pytest.raises(APISpecError, match='Another link with name "GetUser"'):
360362
spec.components.link("GetUser", link)
361363

364+
@pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
365+
def test_callbacks(self, spec):
366+
callback = {
367+
"{$request.query.callbackUrl}": {
368+
"post": {
369+
"requestBody": {
370+
"content": {"application/json": {"schema": {"type": "string"}}}
371+
},
372+
"responses": {"200": {"description": "callback received"}},
373+
}
374+
}
375+
}
376+
spec.components.callback("myCallback", callback)
377+
callbacks = get_callbacks(spec)
378+
assert "myCallback" in callbacks
379+
assert callbacks["myCallback"] == callback
380+
assert validate_spec(spec)
381+
382+
@pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
383+
def test_callback_is_chainable(self, spec):
384+
spec.components.callback("cb1", {}).callback("cb2", {})
385+
callbacks = get_callbacks(spec)
386+
assert "cb1" in callbacks
387+
assert "cb2" in callbacks
388+
389+
@pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
390+
def test_callback_duplicate_name(self, spec):
391+
spec.components.callback("myCallback", {})
392+
with pytest.raises(
393+
DuplicateComponentNameError,
394+
match='Another callback with name "myCallback" is already registered.',
395+
):
396+
spec.components.callback("myCallback", {})
397+
362398
def test_security_scheme(self, spec):
363399
sec_scheme = {"type": "apiKey", "in": "header", "name": "X-API-Key"}
364400
spec.components.security_scheme("ApiKeyAuth", sec_scheme)
@@ -642,6 +678,62 @@ def test_links_lazy(self, spec):
642678
assert "GetUser" in links
643679
assert links["GetUser"] == link
644680

681+
# "callbacks" components section only exists in OAS 3
682+
@pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
683+
def test_components_resolve_refs_in_callback(self, spec):
684+
callback = {
685+
"{$request.query.callbackUrl}": {
686+
"post": {
687+
"parameters": [
688+
{"name": "param", "in": "query", "schema": "ParamSchema"}
689+
],
690+
"requestBody": {
691+
"content": {"application/json": {"schema": "BodySchema"}}
692+
},
693+
"responses": {"200": {"description": "OK"}},
694+
}
695+
}
696+
}
697+
spec.components.callback("myCallback", callback)
698+
resolved_cb = get_callbacks(spec)["myCallback"]
699+
op = resolved_cb["{$request.query.callbackUrl}"]["post"]
700+
assert op["parameters"][0]["schema"] == build_ref(spec, "schema", "ParamSchema")
701+
assert op["requestBody"]["content"]["application/json"]["schema"] == build_ref(
702+
spec, "schema", "BodySchema"
703+
)
704+
705+
# Referenced callbacks are only supported in OAS 3.x
706+
@pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
707+
def test_callbacks_lazy(self, spec):
708+
callback = {
709+
"{$request.query.callbackUrl}": {
710+
"post": {
711+
"responses": {"200": {"description": "OK"}},
712+
}
713+
}
714+
}
715+
spec.components.callback("Callback_1", callback, lazy=False)
716+
spec.components.callback("Callback_2", callback, lazy=True)
717+
callbacks = get_callbacks(spec)
718+
assert "Callback_1" in callbacks
719+
assert "Callback_2" not in callbacks
720+
spec.path(
721+
"/path",
722+
operations={
723+
"post": {
724+
"callbacks": {"myCallback": "Callback_2"},
725+
"responses": {"200": {"description": "OK"}},
726+
}
727+
},
728+
)
729+
callbacks = get_callbacks(spec)
730+
assert "Callback_2" in callbacks
731+
post_op = spec.to_dict()["paths"]["/path"]["post"]
732+
assert post_op["callbacks"]["myCallback"] == build_ref(
733+
spec, "callback", "Callback_2"
734+
)
735+
assert validate_spec(spec)
736+
645737

646738
class TestPath(RefsSchemaTestMixin):
647739
paths = {
@@ -987,6 +1079,23 @@ def test_path_resolve_callbacks(self, spec):
9871079
== schema_ref
9881080
)
9891081

1082+
# callbacks only exists in OAS 3
1083+
@pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
1084+
def test_path_resolve_callback_ref(self, spec):
1085+
spec.path(
1086+
"/pet/{petId}",
1087+
operations={
1088+
"get": {
1089+
"callbacks": {
1090+
"onEvent": "EventCallback",
1091+
}
1092+
}
1093+
},
1094+
)
1095+
assert get_paths(spec)["/pet/{petId}"]["get"]["callbacks"][
1096+
"onEvent"
1097+
] == build_ref(spec, "callback", "EventCallback")
1098+
9901099
# requestBody only exists in OAS 3
9911100
@pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
9921101
def test_path_resolve_request_body(self, spec):
@@ -1139,6 +1248,11 @@ def header_helper(self, header, **kwargs):
11391248
if not return_none:
11401249
return {"description": "some header"}
11411250

1251+
def callback_helper(self, callback, **kwargs):
1252+
callback.pop("dummy", None)
1253+
if not return_none:
1254+
return {"description": "some callback"}
1255+
11421256
def path_helper(self, path, operations, parameters, **kwargs):
11431257
if not return_none:
11441258
if path == "/path_1":
@@ -1234,6 +1348,27 @@ def test_plugin_header_helper_is_used(self, openapi_version, return_none):
12341348
# Check original header is not modified
12351349
assert header == {"dummy": "dummy"}
12361350

1351+
@pytest.mark.parametrize("openapi_version", ("3.0.0",))
1352+
@pytest.mark.parametrize("return_none", (True, False))
1353+
def test_plugin_callback_helper_is_used(self, openapi_version, return_none):
1354+
spec = APISpec(
1355+
title="Swagger Petstore",
1356+
version="1.0.0",
1357+
openapi_version=openapi_version,
1358+
plugins=(self.make_test_plugin(return_none),),
1359+
)
1360+
callback = {"dummy": "dummy"}
1361+
spec.components.callback("myCallback", callback)
1362+
callbacks = get_callbacks(spec)
1363+
if return_none:
1364+
assert callbacks["myCallback"] == {}
1365+
else:
1366+
assert callbacks["myCallback"] == {
1367+
"description": "some callback",
1368+
}
1369+
# Check original callback is not modified
1370+
assert callback == {"dummy": "dummy"}
1371+
12371372
@pytest.mark.parametrize("openapi_version", ("2.0", "3.0.0"))
12381373
@pytest.mark.parametrize("return_none", (True, False))
12391374
def test_plugin_path_helper_is_used(self, openapi_version, return_none):

‎tests/test_ext_marshmallow.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232
from .utils import (
3333
build_ref,
34+
get_callbacks,
3435
get_headers,
3536
get_parameters,
3637
get_paths,
@@ -317,6 +318,33 @@ def test_can_use_schema_in_header(self, spec, schema):
317318
assert resolved_schema["properties"]["id"]["type"] == "integer"
318319

319320

321+
class TestComponentCallbackHelper:
322+
@pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
323+
@pytest.mark.parametrize("schema", [PetSchema, PetSchema()])
324+
def test_can_use_schema_in_callback(self, spec, schema):
325+
cb = {
326+
"{$request.query.callbackUrl}": {
327+
"post": {
328+
"requestBody": {
329+
"content": {"application/json": {"schema": schema}}
330+
},
331+
"responses": {"200": {"description": "success"}},
332+
}
333+
}
334+
}
335+
spec.components.callback("myCallback", cb)
336+
resolved_cb = get_callbacks(spec)["myCallback"]
337+
reference = resolved_cb["{$request.query.callbackUrl}"]["post"]["requestBody"][
338+
"content"
339+
]["application/json"]["schema"]
340+
assert reference == build_ref(spec, "schema", "Pet")
341+
342+
resolved_schema = spec.components.schemas["Pet"]
343+
assert resolved_schema["properties"]["name"]["type"] == "string"
344+
assert resolved_schema["properties"]["password"]["type"] == "string"
345+
assert resolved_schema["properties"]["id"]["type"] == "integer"
346+
347+
320348
class TestCustomField:
321349
def test_can_use_custom_field_decorator(self, spec_fixture):
322350
class CustomNameA(Field):

0 commit comments

Comments
 (0)