diff --git a/fastapi/utils.py b/fastapi/utils.py index 391c47d81..7f29ee314 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -47,9 +47,10 @@ def get_model_definitions( ) definitions.update(m_definitions) model_name = model_name_map[model] + definitions[model_name] = m_schema + for m_schema in definitions.values(): if "description" in m_schema: m_schema["description"] = m_schema["description"].split("\f")[0] - definitions[model_name] = m_schema return definitions diff --git a/tests/test_get_model_definitions_formfeed_escape.py b/tests/test_get_model_definitions_formfeed_escape.py new file mode 100644 index 000000000..a90c3e916 --- /dev/null +++ b/tests/test_get_model_definitions_formfeed_escape.py @@ -0,0 +1,192 @@ +from typing import Any, Iterator, Set, Type + +import fastapi.openapi.utils +import fastapi.utils +import pydantic.schema +from fastapi import FastAPI +from pydantic import BaseModel +from starlette.testclient import TestClient + + +class Address(BaseModel): + """ + This is a public description of an Address + \f + You can't see this part of the docstring, it's private! + """ + + line_1: str + city: str + state_province: str + + +class Facility(BaseModel): + id: str + address: Address + + +app = FastAPI() + +client = TestClient(app) + + +@app.get("/facilities/{facility_id}") +def get_facility(facility_id: str) -> Facility: + ... + + +openapi_schema = { + "components": { + "schemas": { + "Address": { + # NOTE: the description of this model shows only the public-facing text, before the `\f` in docstring + "description": "This is a public " "description of an Address\n", + "properties": { + "city": {"title": "City", "type": "string"}, + "line_1": {"title": "Line " "1", "type": "string"}, + "state_province": {"title": "State " "Province", "type": "string"}, + }, + "required": ["line_1", "city", "state_province"], + "title": "Address", + "type": "object", + }, + "Facility": { + "properties": { + "address": {"$ref": "#/components/schemas/Address"}, + "id": {"title": "Id", "type": "string"}, + }, + "required": ["id", "address"], + "title": "Facility", + "type": "object", + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "title": "Detail", + "type": "array", + } + }, + "title": "HTTPValidationError", + "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", + }, + } + }, + "info": {"title": "FastAPI", "version": "0.1.0"}, + "openapi": "3.0.2", + "paths": { + "/facilities/{facility_id}": { + "get": { + "operationId": "get_facility_facilities__facility_id__get", + "parameters": [ + { + "in": "path", + "name": "facility_id", + "required": True, + "schema": {"title": "Facility " "Id", "type": "string"}, + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Facility"} + } + }, + "description": "Successful " "Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation " "Error", + }, + }, + "summary": "Get Facility", + } + } + }, +} + + +def test_openapi_schema(): + """ + Sanity check to ensure our app's openapi schema renders as we expect + """ + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +class SortedTypeSet(Set[Type[Any]]): + """ + Set of Types whose `__iter__()` method yields results sorted by the type names + """ + + def __init__(self, seq: Set[Type[Any]], *, sort_reversed: bool): + """ + :param seq: Initial members of this set + :param sort_reversed: If true, reverse-order the sorting by type name during iteration + """ + super().__init__(seq) + self.sort_reversed = sort_reversed + + def __iter__(self) -> Iterator[Type[Any]]: + members_sorted = sorted( + super().__iter__(), + key=lambda type_: type_.__name__, + reverse=self.sort_reversed, + ) + for member in members_sorted: + yield member + + +def test_model_description_escaped_with_formfeed(): + """ + Ensure that openapi model descriptions that originate from Pydantic docstrings always truncate the docstring to text + that falls before the formfeed (\f) character. This feature was introduced in (https://github.com/tiangolo/fastapi/pull/3032). + When originally introduced, there was a possibility that the truncation may be ignored depending on the order in which + the models got processed. This created non-deterministic errors, since Pydantic model processing uses unordered sets + and model ordering may differ from one invocation to the next. + + This test verifies that (\f) escape of docstrings works in all possible orderings of our two Pydantic model classes. + """ + flat_models = fastapi.openapi.utils.get_flat_models_from_routes(app.routes) + model_name_map = pydantic.schema.get_model_name_map(flat_models) + + expected_address_description = "This is a public description of an Address\n" + + models_when_sorted_asc = fastapi.utils.get_model_definitions( + flat_models=SortedTypeSet(flat_models, sort_reversed=False), + model_name_map=model_name_map, + ) + assert ( + models_when_sorted_asc["Address"]["description"] == expected_address_description + ) + + models_when_sorted_desc = fastapi.utils.get_model_definitions( + flat_models=SortedTypeSet(flat_models, sort_reversed=True), + model_name_map=model_name_map, + ) + assert ( + models_when_sorted_desc["Address"]["description"] + == expected_address_description + )