From 4fffe81a7113c7ae846ea3404fcb1710588c57b7 Mon Sep 17 00:00:00 2001 From: DJ Melisso Date: Fri, 5 Dec 2025 00:38:49 -0800 Subject: [PATCH 1/8] fix(openapi): avoid duplicated anyOf refs from shared app-level responses The OpenAPI generator performed only a shallow copy of `additional_response`, causing nested objects (e.g., `content`) to be shared across all routes during openapi generation. When `deep_dict_update()` merged schemas for each route, the shared `anyOf` array accumulated duplicate $ref entries. Switching to a deep copy ensures each route processes an isolated response definition and prevents duplication. --- fastapi/openapi/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 9fe2044f2..278adb1dc 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -1,3 +1,4 @@ +import copy import http.client import inspect import warnings @@ -378,7 +379,7 @@ def get_openapi_path( additional_status_code, additional_response, ) in route.responses.items(): - process_response = additional_response.copy() + process_response = copy.deepcopy(additional_response) process_response.pop("model", None) status_code_key = str(additional_status_code).upper() if status_code_key == "DEFAULT": From 926f334a6259edbc7a93e00df2830948275b1a88 Mon Sep 17 00:00:00 2001 From: DJ Melisso Date: Sun, 14 Dec 2025 17:05:12 -0800 Subject: [PATCH 2/8] Added test coverage to additional_response issue --- .../test_additional_responses_no_mutation.py | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 tests/test_additional_responses_no_mutation.py diff --git a/tests/test_additional_responses_no_mutation.py b/tests/test_additional_responses_no_mutation.py new file mode 100644 index 000000000..c18d80b19 --- /dev/null +++ b/tests/test_additional_responses_no_mutation.py @@ -0,0 +1,165 @@ +""" +Regression test: Ensure app-level responses with Union models and content/examples +don't accumulate duplicate $ref entries in anyOf arrays. +See https://github.com/fastapi/fastapi/pull/14463 +""" + +from fastapi import FastAPI +from pydantic import BaseModel + +from fastapi.testclient import TestClient + + +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" + + +client = TestClient(app) + + +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" + } + } + } + } From a63fc911829e6664bf9a8322155c923ceb0f3022 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 01:05:56 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_additional_responses_no_mutation.py | 106 +++++------------- 1 file changed, 31 insertions(+), 75 deletions(-) diff --git a/tests/test_additional_responses_no_mutation.py b/tests/test_additional_responses_no_mutation.py index c18d80b19..173192f6e 100644 --- a/tests/test_additional_responses_no_mutation.py +++ b/tests/test_additional_responses_no_mutation.py @@ -5,9 +5,8 @@ See https://github.com/fastapi/fastapi/pull/14463 """ from fastapi import FastAPI -from pydantic import BaseModel - from fastapi.testclient import TestClient +from pydantic import BaseModel class ModelA(BaseModel): @@ -21,21 +20,19 @@ class ModelB(BaseModel): app = FastAPI( responses={ 500: { - 'model': ModelA | ModelB, - 'content': {"application/json": { - 'examples': {"Case A": {"value": "a"}} - }} + "model": ModelA | ModelB, + "content": {"application/json": {"examples": {"Case A": {"value": "a"}}}}, } } ) -@app.get('/route1') +@app.get("/route1") def route1(): return "test" -@app.get('/route2') +@app.get("/route2") def route2(): return "test" @@ -48,10 +45,7 @@ def test_openapi_schema(): assert response.status_code == 200, response.text assert response.json() == { "openapi": "3.1.0", - "info": { - "title": "FastAPI", - "version": "0.1.0" - }, + "info": {"title": "FastAPI", "version": "0.1.0"}, "paths": { "/route1": { "get": { @@ -60,11 +54,7 @@ def test_openapi_schema(): "responses": { "200": { "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } + "content": {"application/json": {"schema": {}}}, }, "500": { "description": "Internal Server Error", @@ -72,24 +62,16 @@ def test_openapi_schema(): "application/json": { "schema": { "anyOf": [ - { - "$ref": "#/components/schemas/ModelA" - }, - { - "$ref": "#/components/schemas/ModelB" - } + {"$ref": "#/components/schemas/ModelA"}, + {"$ref": "#/components/schemas/ModelB"}, ], - "title": "Response 500 Route1 Route1 Get" + "title": "Response 500 Route1 Route1 Get", }, - "examples": { - "Case A": { - "value": "a" - } - } + "examples": {"Case A": {"value": "a"}}, } - } - } - } + }, + }, + }, } }, "/route2": { @@ -99,11 +81,7 @@ def test_openapi_schema(): "responses": { "200": { "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } + "content": {"application/json": {"schema": {}}}, }, "500": { "description": "Internal Server Error", @@ -111,55 +89,33 @@ def test_openapi_schema(): "application/json": { "schema": { "anyOf": [ - { - "$ref": "#/components/schemas/ModelA" - }, - { - "$ref": "#/components/schemas/ModelB" - } + {"$ref": "#/components/schemas/ModelA"}, + {"$ref": "#/components/schemas/ModelB"}, ], - "title": "Response 500 Route2 Route2 Get" + "title": "Response 500 Route2 Route2 Get", }, - "examples": { - "Case A": { - "value": "a" - } - } + "examples": {"Case A": {"value": "a"}}, } - } - } - } + }, + }, + }, } - } + }, }, "components": { "schemas": { "ModelA": { - "properties": { - "a": { - "type": "string", - "title": "A" - } - }, + "properties": {"a": {"type": "string", "title": "A"}}, "type": "object", - "required": [ - "a" - ], - "title": "ModelA" + "required": ["a"], + "title": "ModelA", }, "ModelB": { - "properties": { - "b": { - "type": "string", - "title": "B" - } - }, + "properties": {"b": {"type": "string", "title": "B"}}, "type": "object", - "required": [ - "b" - ], - "title": "ModelB" - } + "required": ["b"], + "title": "ModelB", + }, } - } + }, } From b409e2fe43fd5fdaebb88c0f6bf80627ded57ed4 Mon Sep 17 00:00:00 2001 From: DJ Melisso Date: Sun, 14 Dec 2025 17:10:51 -0800 Subject: [PATCH 4/8] use Union rather than | operator --- tests/test_additional_responses_no_mutation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_additional_responses_no_mutation.py b/tests/test_additional_responses_no_mutation.py index 173192f6e..589b893ed 100644 --- a/tests/test_additional_responses_no_mutation.py +++ b/tests/test_additional_responses_no_mutation.py @@ -4,6 +4,7 @@ don't accumulate duplicate $ref entries in anyOf arrays. See https://github.com/fastapi/fastapi/pull/14463 """ +from typing import Union from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel @@ -20,7 +21,7 @@ class ModelB(BaseModel): app = FastAPI( responses={ 500: { - "model": ModelA | ModelB, + "model": Union[ModelA, ModelB], "content": {"application/json": {"examples": {"Case A": {"value": "a"}}}}, } } From fce2632f219ba49326a29099361ef3e649adf9c2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 01:11:50 +0000 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_additional_responses_no_mutation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_additional_responses_no_mutation.py b/tests/test_additional_responses_no_mutation.py index 589b893ed..95abbc457 100644 --- a/tests/test_additional_responses_no_mutation.py +++ b/tests/test_additional_responses_no_mutation.py @@ -5,6 +5,7 @@ See https://github.com/fastapi/fastapi/pull/14463 """ from typing import Union + from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel From a17f4c2d625e1c490dac0e5d641d721efb27517a Mon Sep 17 00:00:00 2001 From: DJ Melisso Date: Sun, 14 Dec 2025 17:13:42 -0800 Subject: [PATCH 6/8] Clarified test filename --- ...tion.py => test_additional_responses_union_duplicate_anyof.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_additional_responses_no_mutation.py => test_additional_responses_union_duplicate_anyof.py} (100%) diff --git a/tests/test_additional_responses_no_mutation.py b/tests/test_additional_responses_union_duplicate_anyof.py similarity index 100% rename from tests/test_additional_responses_no_mutation.py rename to tests/test_additional_responses_union_duplicate_anyof.py From 1183114f3a3cf1c7a13ffc9ced99e474a4d2b052 Mon Sep 17 00:00:00 2001 From: DJ Melisso Date: Sun, 14 Dec 2025 17:25:34 -0800 Subject: [PATCH 7/8] Refactor route handlers to be asynchronous and prevent coverage issues --- tests/test_additional_responses_union_duplicate_anyof.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_additional_responses_union_duplicate_anyof.py b/tests/test_additional_responses_union_duplicate_anyof.py index 95abbc457..0e2074a43 100644 --- a/tests/test_additional_responses_union_duplicate_anyof.py +++ b/tests/test_additional_responses_union_duplicate_anyof.py @@ -30,13 +30,13 @@ app = FastAPI( @app.get("/route1") -def route1(): - return "test" +async def route1(): + pass # pragma: no cover @app.get("/route2") -def route2(): - return "test" +async def route2(): + pass # pragma: no cover client = TestClient(app) From b8b89ca7ed8b926d08f40e43881486669f959271 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 01:26:29 +0000 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_additional_responses_union_duplicate_anyof.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_additional_responses_union_duplicate_anyof.py b/tests/test_additional_responses_union_duplicate_anyof.py index 0e2074a43..f5d987ca3 100644 --- a/tests/test_additional_responses_union_duplicate_anyof.py +++ b/tests/test_additional_responses_union_duplicate_anyof.py @@ -31,12 +31,12 @@ app = FastAPI( @app.get("/route1") async def route1(): - pass # pragma: no cover + pass # pragma: no cover @app.get("/route2") async def route2(): - pass # pragma: no cover + pass # pragma: no cover client = TestClient(app)