🐛 Fix OpenAPI schemas for standalone nullable form unions (#16598)

This commit is contained in:
Sebastián Ramírez authored and GitHub committed 2026-10-22 21:02:05 +02:00
1 parent 80c8742d14
commit f90d036938
2 files changed
+87 -22

No files matched your search

+4 -2
View File
@@ -1366,8 +1366,7 @@ def _get_body_field(
return None
first_param = body_params[0]
has_nullable_form_model = any(
(model := _get_form_model(field)) is not None
and field.field_info.annotation is not model
_get_form_models(field) and type(None) in get_args(field.field_info.annotation)
for field in body_params
)
if not embed_body_fields and not has_nullable_form_model:
@@ -1402,6 +1401,9 @@ def _get_body_field(
if not embed_body_fields:
field_info = copy(first_param.field_info)
field_info.annotation = BodyModel
# The original form field already carries the validation metadata.
field_info.metadata = []
field_info.discriminator = None
return ModelField(name=first_param.name, field_info=field_info)
required = any(True for f in body_params if f.field_info.is_required())
BodyFieldInfo_kwargs: dict[str, Any] = {
+83 -20
View File
@@ -125,15 +125,27 @@ def test_nullable_union_without_default_is_required():
return data
client = TestClient(app)
response = client.post("/")
assert response.status_code == 422
assert response.json()["detail"] == [
{"type": "missing", "loc": ["body"], "msg": "Field required", "input": None}
]
for fields in ({}, {"unknown": "ignored"}):
response = client.post("/", data=fields)
assert response.status_code == 422
assert response.json()["detail"] == [
{"type": "missing", "loc": ["body"], "msg": "Field required", "input": None}
]
response = client.post("/", data={"count": "2"})
assert response.status_code == 200, response.text
assert response.json() == {"count": 2}
assert app.openapi()["paths"]["/"]["post"]["requestBody"]["required"] is True
schema = app.openapi()
assert schema["paths"]["/"]["post"]["requestBody"]["required"] is True
assert schema["components"]["schemas"]["Body_read__post"]["allOf"] == [
{"not": {"not": {"anyOf": [{"required": ["count"]}, {"required": ["label"]}]}}},
{
"anyOf": [
{"$ref": "#/components/schemas/Count"},
{"$ref": "#/components/schemas/Label"},
],
"title": "Data",
},
]
@pytest.mark.parametrize("extra", ["ignore", "allow", "forbid"])
@@ -173,6 +185,27 @@ def test_unknown_fields(extra):
assert response.json() == (
{"count": 1, "unknown": ["a", "b"]} if extra == "allow" else None
)
schema = app.openapi()
assert "required" not in schema["paths"]["/"]["post"]["requestBody"]
absent = (
{"maxProperties": 0}
if extra in ("allow", "forbid")
else {"not": {"anyOf": [{"required": ["count"]}, {"required": ["label"]}]}}
)
assert schema["components"]["schemas"]["Body_read__post"]["allOf"] == [
{
"anyOf": [
absent,
{
"anyOf": [
{"$ref": "#/components/schemas/Count"},
{"$ref": "#/components/schemas/Label"},
],
"title": "Data",
},
]
}
]
def test_mixed_extra_configurations_activate_union():
@@ -345,6 +378,10 @@ def test_defaults_factories_and_validators():
response = client.post("/validated-default")
assert response.status_code == 200, response.text
assert calls == ["validated"]
schema = app.openapi()
for path in ("/default", "/factory", "/validated-default"):
assert "required" not in schema["paths"][path]["post"]["requestBody"]
assert calls == ["validated"]
def test_optional_union_in_dependency():
@@ -370,24 +407,51 @@ def test_optional_union_in_dependency():
def test_openapi():
schema = app.openapi()
schemas = schema["components"]["schemas"]
absent = {
"not": {
"anyOf": [
{"required": ["enabled"]},
{"required": ["kind"]},
{"required": ["value"]},
]
}
}
assert schema["paths"]["/union"]["post"]["requestBody"] == {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"anyOf": [
{"$ref": "#/components/schemas/TextForm"},
{"$ref": "#/components/schemas/ListForm"},
{"type": "null"},
],
"title": "Data",
}
"schema": {"$ref": "#/components/schemas/Body_read_union_union_post"}
}
}
}
assert schemas["Body_read_union_union_post"]["allOf"] == [
{
"anyOf": [
absent,
{
"anyOf": [
{"$ref": "#/components/schemas/TextForm"},
{"$ref": "#/components/schemas/ListForm"},
],
"title": "Data",
},
]
}
]
assert schema["paths"]["/discriminated"]["post"]["requestBody"] == {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_read_discriminated_discriminated_post"
}
}
}
}
assert schemas["Body_read_discriminated_discriminated_post"]["allOf"] == [
{
"anyOf": [
absent,
{
"anyOf": [
{
"oneOf": [
@@ -402,12 +466,11 @@ def test_openapi():
},
},
},
{"type": "null"},
],
"title": "Data",
}
}
},
]
}
}
assert schema["components"]["schemas"]["TextForm"]["required"] == ["kind", "value"]
assert schema["components"]["schemas"]["ListForm"]["required"] == ["kind", "value"]
]
assert schemas["TextForm"] == TextForm.model_json_schema()
assert schemas["ListForm"] == ListForm.model_json_schema()