AshNicolus 3 weeks ago
committed by GitHub
parent
commit
ccba5f6f02
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  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 http.client
import inspect import inspect
import warnings import warnings
from collections.abc import Sequence from collections.abc import Iterable, Sequence
from typing import Any, Literal, cast from typing import Any, Literal, cast
from fastapi import routing from fastapi import routing
@ -603,4 +603,51 @@ def get_openapi(
output["tags"] = tags output["tags"] = tags
if external_docs: if external_docs:
output["externalDocs"] = 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 import Body, Cookie, FastAPI, Header, Path, Query
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from inline_snapshot import snapshot 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