Browse Source
When a form body parameter was annotated as `Optional[SomeModel]`
(i.e. `SomeModel | None`), `is_union_of_base_models()` returned `False`
because `NoneType` is not a `BaseModel` subclass. This forced the field to
be embedded, so the submitted top-level form fields were never collected
into the model and the value silently resolved to the default (`None`),
with no validation error — unlike the equivalent JSON body, which parses
correctly.
Ignore `None` members when checking a union of models so that
`Optional[SomeModel]` (and `SomeModel | OtherModel | None`) keeps being
treated as a top-level model union. An empty form still validates the
required fields (a 422, consistent with an empty JSON `{}` body).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
pull/16024/head
2 changed files with 194 additions and 3 deletions
@ -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…
Reference in new issue