diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index a17d62556..97a48bf86 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -234,11 +234,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 # pragma: no cover + + 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( @@ -250,17 +256,18 @@ 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" + ) field_alias = ( (field.validation_alias or field.alias) if field.mode == "validation" else (field.serialization_alias or field.alias) ) - # 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: 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..12014c1d4 --- /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", params=[True, False]) +def get_client(request: pytest.FixtureRequest): + 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=request.param) + + @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_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") + openapi_schema = response.json() + expected_schema = { + "info": { + "title": "FastAPI", + "version": "0.1.0", + }, + "openapi": "3.1.0", + "paths": { + "/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