failed NO_PATCH NO_PATCH UNSUBMITTED empty_patch(None) · None tool calls · 0 s · fastapi/fastapi
🐛 Fix OpenAPI duplication of `anyOf` refs for app-level responses with specified `content` and `model` as `Union`
## Summary
Fixes an issue where defining app-level `responses={...}` with a `Union` of Pydantic models **and** specifying `["content"][...]["examples"]` causes the generated OpenAPI schema to accumulate duplicate `$ref` entries in the `anyOf` array.
## Minimal reproducible example
<details open>
<summary> <code>app.py</code> </summary>
```python
from fastapi import FastAPI
from pydantic import BaseModel
class ModelA(BaseModel):
a: str
class ModelB(BaseModel):
b: str
app = FastAPI(
responses={
500: {
'model': ModelA | ModelB,
'content': {"application/json": {
'examples': {"Case A": {"value": "a"}}
}}
}
}
)
@app.get('/route1')
def route1():
return "test"
@app.get('/route2')
def route2():
return "test"
@app.get('/route3')
def route2():
return "test"
```
</details>
<details open>
<summary>Generated <code>/openapi.json</code> excerpt (<code>paths["/route1"]["get"]["responses"]</code>)</summary>
```json
{
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"anyOf": [
{ "$ref": "#/components/schemas/ModelA" },
{ "$ref": "#/components/schemas/ModelB" },
{ "$ref": "#/components/schemas/ModelA" },
{ "$ref": "#/components/schemas/ModelB" },
{ "$ref": "#/components/schemas/ModelA" },
{ "$ref": "#/components/schemas/ModelB" }
]
}
}
}
}
}
```
</details>
As you can see, the `$ref`s are duplicated three times within `anyOf`.
This appears to be an unintended behavior, since FastAPI’s documentation does not indicate that specifying both a `model` and `content` is prohibited. The generated schema is still valid, but the duplication grows with teh route count and produces excessively large `anyOf` lists for bigger applications.
<details open>
<summary> <h2>Root Cause</h2> </summary>
[Relevant Code Snippet](https://github.com/fastapi/fastapi/blob/f0dd1046a688935ffd23666b3d4164b838a4d8fe/fastapi/openapi/utils.py#L376-L416)
First, `additional_response` is taken from the `route` object:
https://github.com/fastapi/fastapi/blob/f0dd1046a688935ffd23666b3d4164b838a4d8fe/fastapi/openapi/utils.py#L376-L379
<details>
<summary> Example <code>additional_response</code> </summary>
```python
{
'model': ModelA | ModelB,
'content': {"application/json": {
'examples': {"Case A": {"value": "a"}}
}}
}
```
</details>
`additional_response` is *shallowly* `.copy()`ed into `process_response`:
https://github.com/fastapi/fastapi/blob/f0dd1046a688935ffd23666b3d4164b838a4d8fe/fastapi/openapi/utils.py#L380
Now, `id(process_response['content']) == id(additional_response['content'])`, so when `process_response['content']` is modified, the `route.responses` object inadvertently modified as well. And, since the response was defined within `FastAPI(...)`, all the routes share the same `route.responses` object.
https://github.com/fastapi/fastapi/blob/f0dd1046a688935ffd23666b3d4164b838a4d8fe/fastapi/openapi/utils.py#L401-L406
`deep_dict_update()` will combine `anyOf` segments together:
https://github.com/fastapi/fastapi/blob/f0dd1046a688935ffd23666b3d4164b838a4d8fe/fastapi/utils.py#L227-L242
When this method is called the second time (for the second route), `additional_response` will now already contain the schema definition from the first route:
<details>
<summary> Example <code>additional_response</code> </summary>
```python
{
'model': ModelA | ModelB,
'content': {"application/json": {
'examples': {"Case A": {"value": "a"}}
}},
'schema": {
"anyOf": [
{ "$ref": "#/components/schemas/ModelA" },
{ "$ref": "#/components/schemas/ModelB" }
]
}
}
```
</details>
And, for each subsequent route, the additional "anyOf" elements will keep appending to the same array.
</details>
<details open>
<summary> <h2> Workaround (before fix) </h2> </summary>
Define the schema manually instead of using `model`:
```python
app = FastAPI(
responses={
500: {
'content': {"application/json": {
'examples': {"Case A": {"value": "a"}},
'schema': {
"anyOf": [
model.model_json_schema() for model in [ModelA, ModelB]
]
}
}}
}
}
)
```
</details>
## Additional Information
<details>
<summary> Full <code>openapi.json</code> </summary>
```json
{
"openapi": "3.1.0",
"info": {
"title": "FastAPI",
"version": "0.1.0"
},
"paths": {
"/route1": {
"get": {
"summary": "Route1",
"operationId": "route1_route1_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
},
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
},
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
}
],
"title": "Response 500 Route3 Route3 Get"
},
"examples": {
"Case A": {
"value": "a"
}
}
}
}
}
}
}
},
"/route2": {
"get": {
"summary": "Route2",
"operationId": "route2_route2_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
},
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
},
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
}
],
"title": "Response 500 Route3 Route3 Get"
},
"examples": {
"Case A": {
"value": "a"
}
}
}
}
}
}
}
},
"/route3": {
"get": {
"summary": "Route3",
"operationId": "route3_route3_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
},
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
},
{
"$ref": "#/components/schemas/ModelA"
},
{
"$ref": "#/components/schemas/ModelB"
}
],
"title": "Response 500 Route3 Route3 Get"
},
"examples": {
"Case A": {
"value": "a"
}
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ModelA": {
"properties": {
"a": {
"type": "string",
"title": "A"
}
},
"type": "object",
"required": [
"a"
],
"title": "ModelA"
},
"ModelB": {
"properties": {
"b": {
"type": "string",
"title": "B"
}
},
"type": "object",
"required": [
"b"
],
"title": "ModelB"
}
}
}
}
```
</details>| # | Tool | Arguments | Result |
|---|---|---|---|
| No trace captured. | |||
F
=================================== FAILURES ===================================
_____________________________ test_openapi_schema ______________________________
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
> assert response.json() == {
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/route1": {
"get": {
"summary": "Route1",
"operationId": "route1_route1_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"anyOf": [
{"$ref": "#/components/schemas/ModelA"},
{"$ref": "#/components/schemas/ModelB"},
],
"title": "Response 500 Route1 Route1 Get",
},
"examples": {"Case A": {"value": "a"}},
}
},
},
},
}
},
"/route2": {
"get": {
"summary": "Route2",
"operationId": "route2_route2_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"anyOf": [
{"$ref": "#/components/schemas/ModelA"},
{"$ref": "#/components/schemas/ModelB"},
],
"title": "Response 500 Route2 Route2 Get",
},
"examples": {"Case A": {"value": "a"}},
}
},
},
},
}
},
},
"components": {
"schemas": {
"ModelA": {
"properties": {"a": {"type": "string", "title": "A"}},
"type": "object",
"required": ["a"],
"title": "ModelA",
},
"ModelB": {
"properties": {"b": {"type": "string", "title": "B"}},
"type": "object",
"required": ["b"],
"title": "ModelB",
},
}
},
}
E AssertionError: assert {'openapi': '...: 'ModelB'}}}} == {'openapi': '...: 'ModelB'}}}}
E
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'paths': {'/route1': {'get': {'summary': 'Route1', 'operationId': 'route1_route1_get', 'responses': {'200': {'descrip...tion': 'Successful Response', 'content': {...}}, '500': {'description': 'Internal Server Error', 'content': {...}}}}}}} != {'paths': {'/route1': {'get': {'summary': 'Route1', 'operationId': 'route1_route1_get', 'responses': {'200': {'descrip...tion': 'Successful Response', 'content': {...}}, '500': {'description': 'Internal Server Error', 'content': {...}}}}}}}
E Use -v to get more diff
tests/test_additional_responses_union_duplicate_anyof.py:48: AssertionError
=============================== warnings summary ===============================
../../../../../../../Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.50.0-py3-none-any/starlette/testclient.py:45
/Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.50.0-py3-none-any/starlette/testclient.py:45: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 1 warning in 0.64s