From 0ee8db931fc2ab5881656287fc6f60b51d96a9de Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 10 Dec 2025 17:06:38 +0100 Subject: [PATCH 1/4] Add test for nested model with computed field in response model --- ..._schema_fals_with_nested_computed_field.py | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/test_separate_input_output_schema_fals_with_nested_computed_field.py diff --git a/tests/test_separate_input_output_schema_fals_with_nested_computed_field.py b/tests/test_separate_input_output_schema_fals_with_nested_computed_field.py new file mode 100644 index 000000000..64aaa1471 --- /dev/null +++ b/tests/test_separate_input_output_schema_fals_with_nested_computed_field.py @@ -0,0 +1,234 @@ +from typing import List + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from .utils import needs_pydanticv2 + + +@pytest.fixture(name="client") +def get_client(): + from pydantic import computed_field + + class MyModel(BaseModel): + id: int + name: str + age: int + + @computed_field + @property + def is_adult(self) -> bool: + return self.age >= 18 + + app = FastAPI(separate_input_output_schemas=False) + + @app.get("/item") + def get_item() -> MyModel: + return MyModel(id=1, name="Alice", age=30) + + @app.get("/list") + def get_items() -> List[MyModel]: + return [MyModel(id=1, name="Alice", age=30), MyModel(id=2, name="Bob", age=17)] + + @app.post("/item") + def create_item(item: MyModel) -> MyModel: + return item + + yield TestClient(app) + + +@needs_pydanticv2 +def test_openapi(client: TestClient): + response = client.get("/openapi.json") + openapi_schema = response.json() + expected_schema = { + "info": { + "title": "FastAPI", + "version": "0.1.0", + }, + "openapi": "3.1.0", + "paths": { + "/item": { + "get": { + "operationId": "get_item_item_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyModel-Output", + }, + }, + }, + "description": "Successful Response", + }, + }, + "summary": "Get Item", + }, + "post": { + "operationId": "create_item_item_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyModel-Input", + }, + }, + }, + "required": True, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyModel-Output", + }, + }, + }, + "description": "Successful Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError", + }, + }, + }, + "description": "Validation Error", + }, + }, + "summary": "Create Item", + }, + }, + "/list": { + "get": { + "operationId": "get_items_list_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MyModel-Output", + }, + "title": "Response Get Items List Get", + "type": "array", + }, + }, + }, + "description": "Successful Response", + }, + }, + "summary": "Get Items", + }, + }, + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError", + }, + "title": "Detail", + "type": "array", + }, + }, + "title": "HTTPValidationError", + "type": "object", + }, + "MyModel-Input": { + "properties": { + "age": { + "title": "Age", + "type": "integer", + }, + "id": { + "title": "Id", + "type": "integer", + }, + "name": { + "title": "Name", + "type": "string", + }, + }, + "required": [ + "id", + "name", + "age", + ], + "title": "MyModel", + "type": "object", + }, + "MyModel-Output": { + "properties": { + "age": { + "title": "Age", + "type": "integer", + }, + "id": { + "title": "Id", + "type": "integer", + }, + "is_adult": { + "readOnly": True, + "title": "Is Adult", + "type": "boolean", + }, + "name": { + "title": "Name", + "type": "string", + }, + }, + "required": [ + "id", + "name", + "age", + "is_adult", + ], + "title": "MyModel", + "type": "object", + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "integer", + }, + ], + }, + "title": "Location", + "type": "array", + }, + "msg": { + "title": "Message", + "type": "string", + }, + "type": { + "title": "Error Type", + "type": "string", + }, + }, + "required": [ + "loc", + "msg", + "type", + ], + "title": "ValidationError", + "type": "object", + }, + }, + }, + } + + assert openapi_schema == expected_schema From c50589dc59b0c2f9614166b53f9a3fcd18383fb0 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 10 Dec 2025 17:22:24 +0100 Subject: [PATCH 2/4] Fix schema for nested model with computed field in response model --- fastapi/_compat/v2.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index acd23d846..75bc673d2 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -171,11 +171,17 @@ def _get_model_config(model: BaseModel) -> Any: return model.model_config +def _model_has_computed_fields(model_or_enum: Any) -> bool: + if lenient_issubclass(model_or_enum, BaseModel): + model_schema = model_or_enum.__pydantic_core_schema__.get("schema", {}) + computed_fields = model_schema.get("computed_fields", []) + return len(computed_fields) > 0 + return False + + def _has_computed_fields(field: ModelField) -> bool: - computed_fields = field._type_adapter.core_schema.get("schema", {}).get( - "computed_fields", [] - ) - return len(computed_fields) > 0 + models = get_flat_models_from_field(field, known_models=set()) + return any(_model_has_computed_fields(model) for model in models) def get_schema_from_model_field( From a8bbcebcb40c2005037c0ec25d9a400417897b68 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 10 Dec 2025 18:01:09 +0100 Subject: [PATCH 3/4] Fix coverage --- fastapi/_compat/v2.py | 2 +- ..._schema_fals_with_nested_computed_field.py | 46 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 75bc673d2..2b6c0a5f6 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -176,7 +176,7 @@ def _model_has_computed_fields(model_or_enum: Any) -> bool: model_schema = model_or_enum.__pydantic_core_schema__.get("schema", {}) computed_fields = model_schema.get("computed_fields", []) return len(computed_fields) > 0 - return False + return False # pragma: no cover def _has_computed_fields(field: ModelField) -> bool: diff --git a/tests/test_separate_input_output_schema_fals_with_nested_computed_field.py b/tests/test_separate_input_output_schema_fals_with_nested_computed_field.py index 64aaa1471..12014c1d4 100644 --- a/tests/test_separate_input_output_schema_fals_with_nested_computed_field.py +++ b/tests/test_separate_input_output_schema_fals_with_nested_computed_field.py @@ -8,8 +8,8 @@ from pydantic import BaseModel from .utils import needs_pydanticv2 -@pytest.fixture(name="client") -def get_client(): +@pytest.fixture(name="client", params=[True, False]) +def get_client(request: pytest.FixtureRequest): from pydantic import computed_field class MyModel(BaseModel): @@ -22,11 +22,7 @@ def get_client(): def is_adult(self) -> bool: return self.age >= 18 - app = FastAPI(separate_input_output_schemas=False) - - @app.get("/item") - def get_item() -> MyModel: - return MyModel(id=1, name="Alice", age=30) + app = FastAPI(separate_input_output_schemas=request.param) @app.get("/list") def get_items() -> List[MyModel]: @@ -39,6 +35,26 @@ def get_client(): yield TestClient(app) +@needs_pydanticv2 +def test_create_item(client: TestClient): + response = client.post( + "/item", + json={"id": 1, "name": "Alice", "age": 30}, + ) + assert response.status_code == 200, response.text + assert response.json() == {"id": 1, "name": "Alice", "age": 30, "is_adult": True} + + +@needs_pydanticv2 +def test_get_items(client: TestClient): + response = client.get("/list") + assert response.status_code == 200, response.text + assert response.json() == [ + {"id": 1, "name": "Alice", "age": 30, "is_adult": True}, + {"id": 2, "name": "Bob", "age": 17, "is_adult": False}, + ] + + @needs_pydanticv2 def test_openapi(client: TestClient): response = client.get("/openapi.json") @@ -51,22 +67,6 @@ def test_openapi(client: TestClient): "openapi": "3.1.0", "paths": { "/item": { - "get": { - "operationId": "get_item_item_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MyModel-Output", - }, - }, - }, - "description": "Successful Response", - }, - }, - "summary": "Get Item", - }, "post": { "operationId": "create_item_item_post", "requestBody": { From 7a4f8ca675c4ee5bbde2ba8076e4e068a7266517 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 10 Dec 2025 18:15:10 +0100 Subject: [PATCH 4/4] Only check computed fields if separate_input_output_schemas is False --- fastapi/_compat/v2.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index dbdcdd382..b5e6e8983 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -200,11 +200,13 @@ def get_schema_from_model_field( ], separate_input_output_schemas: bool = True, ) -> Dict[str, Any]: - override_mode: Union[Literal["validation"], None] = ( - None - if (separate_input_output_schemas or _has_computed_fields(field)) - else "validation" - ) + override_mode: Union[Literal["validation"], None] = None + if not separate_input_output_schemas: + override_mode = ( + None + if (separate_input_output_schemas or _has_computed_fields(field)) + else "validation" + ) # This expects that GenerateJsonSchema was already used to generate the definitions json_schema = field_mapping[(field, override_mode or field.mode)] if "$ref" not in json_schema: