diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 1c7a17c4ca..6d67d451b0 100644 --- a/fastapi/openapi/utils.py +++ b/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 diff --git a/tests/test_openapi_examples.py b/tests/test_openapi_examples.py index e27dd2be08..be81530e59 100644 --- a/tests/test_openapi_examples.py +++ b/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