Evan Yang 2 days ago
committed by GitHub
parent
commit
4338cf601c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      fastapi/dependencies/utils.py
  2. 183
      tests/test_union_forms.py

14
fastapi/dependencies/utils.py

@ -871,7 +871,11 @@ def request_params_to_args(
def is_union_of_base_models(field_type: Any) -> bool:
"""Check if field type is a Union where all members are BaseModel subclasses."""
"""Check if field type is a Union where all members are BaseModel subclasses.
`None` members are ignored, so `Optional[SomeModel]` (i.e. `SomeModel | None`)
is still treated as a top-level model union.
"""
from fastapi.types import UnionType
origin = get_origin(field_type)
@ -882,11 +886,17 @@ def is_union_of_base_models(field_type: Any) -> bool:
union_args = get_args(field_type)
found_base_model = False
for arg in union_args:
# Ignore `None`, so that `Optional[SomeModel]` keeps being treated as a
# top-level model union instead of being forced to embed.
if arg is type(None):
continue
if not lenient_issubclass(arg, BaseModel):
return False
found_base_model = True
return True
return found_base_model
def _should_embed_body_fields(fields: list[ModelField]) -> bool:

183
tests/test_union_forms.py

@ -23,6 +23,23 @@ def post_union_form(data: Annotated[UserForm | CompanyForm, Form()]):
return {"received": data}
@app.post("/form-optional-required/")
def post_optional_required_form(data: Annotated[UserForm | None, Form()]):
return {"received": data}
@app.post("/form-optional/")
def post_optional_form(data: Annotated[UserForm | None, Form()] = None):
return {"received": data}
@app.post("/form-optional-union/")
def post_optional_union_form(
data: Annotated[UserForm | CompanyForm | None, Form()] = None,
):
return {"received": data}
client = TestClient(app)
@ -59,6 +76,61 @@ def test_empty_form():
assert response.status_code == 422, response.text
def test_post_optional_required_form():
# `UserForm | None` without a default is still required, but its top-level
# fields must be collected into the model instead of being silently dropped.
response = client.post(
"/form-optional-required/",
data={"name": "John Doe", "email": "[email protected]"},
)
assert response.status_code == 200, response.text
assert response.json() == {
"received": {"name": "John Doe", "email": "[email protected]"}
}
def test_post_optional_required_form_missing():
response = client.post("/form-optional-required/")
assert response.status_code == 422, response.text
def test_post_optional_form():
# `UserForm | None = None`: submitted fields must still populate the model
# (previously they were silently discarded and the value resolved to `None`).
response = client.post(
"/form-optional/", data={"name": "Jane Doe", "email": "[email protected]"}
)
assert response.status_code == 200, response.text
assert response.json() == {
"received": {"name": "Jane Doe", "email": "[email protected]"}
}
def test_post_optional_form_empty():
# An empty form is equivalent to an empty object (`{}`), so the required model
# fields are still validated (mirroring a JSON `{}` body), giving a 422.
response = client.post("/form-optional/")
assert response.status_code == 422, response.text
def test_post_optional_union_form():
response = client.post(
"/form-optional-union/",
data={"company_name": "Tech Corp", "industry": "Technology"},
)
assert response.status_code == 200, response.text
assert response.json() == {
"received": {"company_name": "Tech Corp", "industry": "Technology"}
}
def test_post_optional_union_form_empty():
# As above, an empty form validates the (required) members, so it is a 422
# rather than silently resolving to the `None` default.
response = client.post("/form-optional-union/")
assert response.status_code == 422, response.text
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
@ -104,7 +176,116 @@ def test_openapi_schema():
},
},
}
}
},
"/form-optional-required/": {
"post": {
"summary": "Post Optional Required Form",
"operationId": "post_optional_required_form_form_optional_required__post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"anyOf": [
{"$ref": "#/components/schemas/UserForm"},
{"type": "null"},
],
"title": "Data",
}
}
},
"required": True,
},
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/form-optional/": {
"post": {
"summary": "Post Optional Form",
"operationId": "post_optional_form_form_optional__post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"anyOf": [
{"$ref": "#/components/schemas/UserForm"},
{"type": "null"},
],
"title": "Data",
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/form-optional-union/": {
"post": {
"summary": "Post Optional Union Form",
"operationId": "post_optional_union_form_form_optional_union__post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"anyOf": [
{"$ref": "#/components/schemas/UserForm"},
{
"$ref": "#/components/schemas/CompanyForm"
},
{"type": "null"},
],
"title": "Data",
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
},
"components": {
"schemas": {

Loading…
Cancel
Save