From 08dad5c69fbeac83616226b4d56b6c79b8683767 Mon Sep 17 00:00:00 2001 From: DJ Melisso Date: Wed, 4 Feb 2026 05:23:08 -0800 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=90=9B=20Fix=20OpenAPI=20duplication?= =?UTF-8?q?=20of=20`anyOf`=20refs=20for=20app-level=20responses=20with=20s?= =?UTF-8?q?pecified=20`content`=20and=20`model`=20as=20`Union`=20(#14463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> --- fastapi/openapi/utils.py | 3 +- ...itional_responses_union_duplicate_anyof.py | 123 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/test_additional_responses_union_duplicate_anyof.py diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 75ff26102..d56027b50 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -1,3 +1,4 @@ +import copy import http.client import inspect import warnings @@ -377,7 +378,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": diff --git a/tests/test_additional_responses_union_duplicate_anyof.py b/tests/test_additional_responses_union_duplicate_anyof.py new file mode 100644 index 000000000..f5d987ca3 --- /dev/null +++ b/tests/test_additional_responses_union_duplicate_anyof.py @@ -0,0 +1,123 @@ +""" +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 typing import Union + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + + +class ModelA(BaseModel): + a: str + + +class ModelB(BaseModel): + b: str + + +app = FastAPI( + responses={ + 500: { + "model": Union[ModelA, ModelB], + "content": {"application/json": {"examples": {"Case A": {"value": "a"}}}}, + } + } +) + + +@app.get("/route1") +async def route1(): + pass # pragma: no cover + + +@app.get("/route2") +async def route2(): + pass # pragma: no cover + + +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 ca4692a8c6552de17646a4eabed8ea3cafce597b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 13:23:34 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 0c7d44049..59cf84c86 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* 🐛 Fix OpenAPI duplication of `anyOf` refs for app-level responses with specified `content` and `model` as `Union`. PR [#14463](https://github.com/fastapi/fastapi/pull/14463) by [@DJMcoder](https://github.com/DJMcoder). + ### Refactors * 💡 Update comment for Pydantic internals. PR [#14814](https://github.com/fastapi/fastapi/pull/14814) by [@tiangolo](https://github.com/tiangolo). From 41352de24c41f3df540ea410696aa2116ab59394 Mon Sep 17 00:00:00 2001 From: Anton <34218036+retwish@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:24:59 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=9A=B8=20Improve=20error=20message=20?= =?UTF-8?q?for=20invalid=20query=20parameter=20type=20annotations=20(#1447?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Anton.D Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> --- fastapi/dependencies/utils.py | 2 +- tests/test_invalid_sequence_param.py | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 2afc734ba..b647818c4 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -519,7 +519,7 @@ def analyze_param( # For Pydantic v1 and getattr(field, "shape", 1) == 1 ) - ) + ), f"Query parameter {param_name!r} must be one of the supported types" return ParamDetails(type_annotation=type_annotation, depends=depends, field=field) diff --git a/tests/test_invalid_sequence_param.py b/tests/test_invalid_sequence_param.py index 2b8fd059e..3695344f7 100644 --- a/tests/test_invalid_sequence_param.py +++ b/tests/test_invalid_sequence_param.py @@ -6,7 +6,10 @@ from pydantic import BaseModel def test_invalid_sequence(): - with pytest.raises(AssertionError): + with pytest.raises( + AssertionError, + match="Query parameter 'q' must be one of the supported types", + ): app = FastAPI() class Item(BaseModel): @@ -18,7 +21,10 @@ def test_invalid_sequence(): def test_invalid_tuple(): - with pytest.raises(AssertionError): + with pytest.raises( + AssertionError, + match="Query parameter 'q' must be one of the supported types", + ): app = FastAPI() class Item(BaseModel): @@ -30,7 +36,10 @@ def test_invalid_tuple(): def test_invalid_dict(): - with pytest.raises(AssertionError): + with pytest.raises( + AssertionError, + match="Query parameter 'q' must be one of the supported types", + ): app = FastAPI() class Item(BaseModel): @@ -42,7 +51,10 @@ def test_invalid_dict(): def test_invalid_simple_dict(): - with pytest.raises(AssertionError): + with pytest.raises( + AssertionError, + match="Query parameter 'q' must be one of the supported types", + ): app = FastAPI() class Item(BaseModel): From 9e0f4ca77ae27798764766fb5dfd48fb15812f86 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 13:25:27 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 59cf84c86..0f0b9dd0a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Features + +* 🚸 Improve error message for invalid query parameter type annotations. PR [#14479](https://github.com/fastapi/fastapi/pull/14479) by [@retwish](https://github.com/retwish). + ### Fixes * 🐛 Fix OpenAPI duplication of `anyOf` refs for app-level responses with specified `content` and `model` as `Union`. PR [#14463](https://github.com/fastapi/fastapi/pull/14463) by [@DJMcoder](https://github.com/DJMcoder). From 61f95c9606ff548d449e9d1e0612d93e2ec3c97f Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:26:02 +0300 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=93=9D=20Add=20banner=20to=20translat?= =?UTF-8?q?ed=20pages=20(#14809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add banner to translated pages * Add link to English version. Use modern syntax for details block * 🎨 Auto format * Move `translation-banner.md` inside `docs` directory * 🎨 Auto format --------- Co-authored-by: github-actions[bot] --- docs/en/docs/translation-banner.md | 11 +++++++++++ docs/ru/docs/translation-banner.md | 11 +++++++++++ scripts/mkdocs_hooks.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 docs/en/docs/translation-banner.md create mode 100644 docs/ru/docs/translation-banner.md diff --git a/docs/en/docs/translation-banner.md b/docs/en/docs/translation-banner.md new file mode 100644 index 000000000..142287074 --- /dev/null +++ b/docs/en/docs/translation-banner.md @@ -0,0 +1,11 @@ +/// details | 🌐 Translation by AI and humans + +This translation was made by AI guided by humans. 🤝 + +It could have mistakes of misunderstanding the original meaning, or looking unnatural, etc. 🤖 + +You can improve this translation by [helping us guide the AI LLM better](https://fastapi.tiangolo.com/contributing/#translations). + +[English version](ENGLISH_VERSION_URL) + +/// diff --git a/docs/ru/docs/translation-banner.md b/docs/ru/docs/translation-banner.md new file mode 100644 index 000000000..78ebd676c --- /dev/null +++ b/docs/ru/docs/translation-banner.md @@ -0,0 +1,11 @@ +/// details | 🌐 Перевод выполнен с помощью ИИ и людей + +Этот перевод был сделан ИИ под руководством людей. 🤝 + +В нем могут быть ошибки из-за неправильного понимания оригинального смысла или неестественности и т. д. 🤖 + +Вы можете улучшить этот перевод, [помогая нам лучше направлять ИИ LLM](https://fastapi.tiangolo.com/ru/contributing/#translations). + +[Английская версия](ENGLISH_VERSION_URL) + +/// diff --git a/scripts/mkdocs_hooks.py b/scripts/mkdocs_hooks.py index 4b781270a..567c0111d 100644 --- a/scripts/mkdocs_hooks.py +++ b/scripts/mkdocs_hooks.py @@ -26,6 +26,17 @@ def get_missing_translation_content(docs_dir: str) -> str: return missing_translation_path.read_text(encoding="utf-8") +@lru_cache +def get_translation_banner_content(docs_dir: str) -> str: + docs_dir_path = Path(docs_dir) + translation_banner_path = docs_dir_path / "translation-banner.md" + if not translation_banner_path.is_file(): + translation_banner_path = ( + docs_dir_path.parent.parent / "en" / "docs" / "translation-banner.md" + ) + return translation_banner_path.read_text(encoding="utf-8") + + @lru_cache def get_mkdocs_material_langs() -> list[str]: material_path = Path(material.__file__).parent @@ -151,4 +162,21 @@ def on_page_markdown( if markdown.startswith("#"): header, _, body = markdown.partition("\n\n") return f"{header}\n\n{missing_translation_content}\n\n{body}" - return markdown + + docs_dir_path = Path(config.docs_dir) + en_docs_dir_path = docs_dir_path.parent.parent / "en/docs" + + if docs_dir_path == en_docs_dir_path: + return markdown + + # For translated pages add translation banner + translation_banner_content = get_translation_banner_content(config.docs_dir) + en_url = "https://fastapi.tiangolo.com/" + page.url.lstrip("/") + translation_banner_content = translation_banner_content.replace( + "ENGLISH_VERSION_URL", en_url + ) + header = "" + body = markdown + if markdown.startswith("#"): + header, _, body = markdown.partition("\n\n") + return f"{header}\n\n{translation_banner_content}\n\n{body}" From a4297066c22071e89f55fcb9f720dec57bd95814 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 13:26:29 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 0f0b9dd0a..7658ab970 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -21,6 +21,7 @@ hide: ### Docs +* 📝 Add banner to translated pages. PR [#14809](https://github.com/fastapi/fastapi/pull/14809) by [@YuriiMotov](https://github.com/YuriiMotov). * 📝 Add links to related sections of docs to docstrings. PR [#14776](https://github.com/fastapi/fastapi/pull/14776) by [@YuriiMotov](https://github.com/YuriiMotov). * 📝 Update embedded code examples to Python 3.10 syntax. PR [#14758](https://github.com/fastapi/fastapi/pull/14758) by [@YuriiMotov](https://github.com/YuriiMotov). * 📝 Fix dependency installation command in `docs/en/docs/contributing.md`. PR [#14757](https://github.com/fastapi/fastapi/pull/14757) by [@YuriiMotov](https://github.com/YuriiMotov).