Browse Source

🐛 Preserve `null` values in user-provided OpenAPI `example`/`examples`

pull/15646/head
AshNicolus 5 days ago
parent
commit
3638fd6d18
  1. 51
      fastapi/openapi/utils.py
  2. 95
      tests/test_openapi_examples.py

51
fastapi/openapi/utils.py

@ -2,7 +2,7 @@ import copy
import http.client
import inspect
import warnings
from collections.abc import Sequence
from collections.abc import Iterable, Sequence
from typing import Any, Literal, cast
from fastapi import routing
@ -603,4 +603,51 @@ def get_openapi(
output["tags"] = tags
if external_docs:
output["externalDocs"] = external_docs
return jsonable_encoder(OpenAPI(**output), by_alias=True, exclude_none=True) # type: ignore[no-any-return]
# Collect user-provided `example` / `examples` values from the pre-encoded
# `output` so we can restore them after the final encode below. The final
# `exclude_none=True` pass would otherwise strip `null` values from
# inside those user examples, where `null` may be a legitimate documented
# value (see e.g. github discussion #8401, issue #5559).
example_entries = list(_collect_example_paths(output))
encoded = jsonable_encoder(OpenAPI(**output), by_alias=True, exclude_none=True)
for example_path, value in example_entries:
preserved = jsonable_encoder(value, by_alias=True)
_set_path(encoded, example_path, preserved)
return cast(dict[str, Any], encoded)
_EXAMPLE_KEYS = frozenset({"example", "examples"})
def _collect_example_paths(
obj: Any, path: tuple[Any, ...] = ()
) -> "Iterable[tuple[tuple[Any, ...], Any]]":
"""Yield `(path, value)` for every non-None `example` / `examples` value
in `obj`. `None`-valued keys are skipped they come from FastAPI's
internal defaults (the OpenAPI Pydantic model has `example: Any = None`)
rather than the user, and re-attaching them would surface spurious
`"example": null` entries the historical strip pass hid. Subtrees under
these keys are treated as opaque user data and not recursed into."""
if isinstance(obj, dict):
for key, value in obj.items():
if key in _EXAMPLE_KEYS:
if value is not None:
yield (*path, key), value
else:
yield from _collect_example_paths(value, (*path, key))
elif isinstance(obj, list):
for i, item in enumerate(obj):
yield from _collect_example_paths(item, (*path, i))
def _set_path(obj: Any, path: tuple[Any, ...], value: Any) -> None:
"""Set `obj[path] = value`, creating intermediate dicts if the path was
stripped during the `exclude_none=True` encode."""
for key in path[:-1]:
if isinstance(obj, list):
obj = obj[key]
continue
if key not in obj:
obj[key] = {}
obj = obj[key]
obj[path[-1]] = value

95
tests/test_openapi_examples.py

@ -1,3 +1,5 @@
from typing import Annotated
from fastapi import Body, Cookie, FastAPI, Header, Path, Query
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
@ -420,3 +422,96 @@ def test_openapi_schema():
},
}
)
# Tests for the "null values discarded from OpenAPI examples" bug
# (see github discussions #8401, #12048 and issue #5559). The historical
# `exclude_none=True` pass at the end of `get_openapi` was stripping `null`
# values from inside user-provided `example` / `examples`, where `null` may
# be a legitimate documented value.
_null_app = FastAPI()
@_null_app.get(
"/r1",
responses={
200: {
"content": {
"application/json": {"example": {"absent": None, "present": "value"}}
}
}
},
)
def _r1() -> dict:
return {}
@_null_app.get(
"/r2",
responses={
200: {
"content": {
"application/json": {
"examples": {
"with_null": {
"summary": "has null",
"value": {"a": None, "b": 1},
},
}
}
}
}
},
)
def _r2() -> dict:
return {}
@_null_app.get("/r3")
def _r3(
q: Annotated[
str | None,
Query(examples={"nil": {"value": None}, "val": {"value": "x"}}),
] = None,
) -> dict:
return {}
def test_null_preserved_in_response_example():
schema = _null_app.openapi()
example = schema["paths"]["/r1"]["get"]["responses"]["200"]["content"][
"application/json"
]["example"]
assert example == {"absent": None, "present": "value"}
def test_null_preserved_in_response_examples_plural():
schema = _null_app.openapi()
examples = schema["paths"]["/r2"]["get"]["responses"]["200"]["content"][
"application/json"
]["examples"]
assert examples["with_null"]["value"] == {"a": None, "b": 1}
def test_null_preserved_in_parameter_examples():
schema = _null_app.openapi()
param = schema["paths"]["/r3"]["get"]["parameters"][0]
examples = param["schema"]["examples"]
assert examples["nil"]["value"] is None
assert examples["val"]["value"] == "x"
def test_unrelated_none_fields_still_stripped():
"""Regression: only `null`s inside `example` / `examples` are preserved.
Other internal `None` defaults (e.g. unset `summary`, `description`,
Pydantic-default `example: None` on parameter dicts) must still be
stripped, as the historical behavior."""
schema = _null_app.openapi()
# No parameter should carry a stray `example: null` from internal defaults.
for path_item in schema["paths"].values():
for op in path_item.values():
if not isinstance(op, dict):
continue
for parameter in op.get("parameters", []):
assert "example" not in parameter or parameter["example"] is not None

Loading…
Cancel
Save