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} |
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) |
client = TestClient(app) |
||||
|
|
||||
|
|
||||
@ -59,6 +76,61 @@ def test_empty_form(): |
|||||
assert response.status_code == 422, response.text |
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(): |
def test_openapi_schema(): |
||||
response = client.get("/openapi.json") |
response = client.get("/openapi.json") |
||||
assert response.status_code == 200, response.text |
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": { |
"components": { |
||||
"schemas": { |
"schemas": { |
||||
|
|||||
Loading…
Reference in new issue