diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 808646cc2..c2cb8b5df 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -100,6 +100,7 @@ def _get_openapi_operation_parameters( field_mapping: Dict[ Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue ], + field_docstring: Dict[str, str] | None = None, separate_input_output_schemas: bool = True, ) -> List[Dict[str, Any]]: parameters = [] @@ -115,6 +116,7 @@ def _get_openapi_operation_parameters( (ParamTypes.cookie, cookie_params), ] default_convert_underscores = True + field_docstring = field_docstring or {} if len(flat_dependant.header_params) == 1: first_field = flat_dependant.header_params[0] if lenient_issubclass(first_field.type_, BaseModel): @@ -155,6 +157,8 @@ def _get_openapi_operation_parameters( } if field_info.description: parameter["description"] = field_info.description + elif param.name in field_docstring: + parameter["description"] = field_docstring[param.name] openapi_examples = getattr(field_info, "openapi_examples", None) example = getattr(field_info, "example", None) if openapi_examples: @@ -232,8 +236,14 @@ def get_openapi_operation_metadata( if route.tags: operation["tags"] = route.tags operation["summary"] = generate_operation_summary(route=route, method=method) + if route.parsed_docstring: + operation["description"] = "\n\n".join( + [i.value for i in route.parsed_docstring if i.kind == "text"] + ) if route.description: operation["description"] = route.description + operation["description"] = operation["description"].split("\f")[0].strip() + operation_id = route.operation_id or route.unique_id if operation_id in operation_ids: message = ( @@ -273,6 +283,10 @@ def get_openapi_path( assert current_response_class, "A response class is needed to generate OpenAPI" route_response_media_type: Optional[str] = current_response_class.media_type if route.include_in_schema: + args = [i.value for i in route.parsed_docstring if i.kind == "parameters"] + args_docstring_mapping = ( + {a.name: a.description for a in args[0]} if len(args) == 1 else {} + ) for method in route.methods: operation = get_openapi_operation_metadata( route=route, method=method, operation_ids=operation_ids @@ -292,6 +306,7 @@ def get_openapi_path( model_name_map=model_name_map, field_mapping=field_mapping, separate_input_output_schemas=separate_input_output_schemas, + field_docstring=args_docstring_mapping, ) parameters.extend(operation_parameters) if parameters: @@ -348,9 +363,19 @@ def get_openapi_path( if status_code_param is not None: if isinstance(status_code_param.default, int): status_code = str(status_code_param.default) + response_description = "" + if route.parsed_docstring: + ret = [i.value for i in route.parsed_docstring if i.kind == "returns"] + response_description = ( + ",".join(x.description for x in ret[0]) + if len(ret) == 1 + else "Successful Response" + ) + if route.response_description: + response_description = route.response_description operation.setdefault("responses", {}).setdefault(status_code, {})[ "description" - ] = route.response_description + ] = response_description if route_response_media_type and is_body_allowed_for_status_code( route.status_code ): diff --git a/fastapi/routing.py b/fastapi/routing.py index 457481e32..f14e81e01 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1,7 +1,6 @@ import asyncio import dataclasses import email.message -import inspect import json from contextlib import AsyncExitStack, asynccontextmanager from enum import Enum, IntEnum @@ -56,6 +55,7 @@ from fastapi.utils import ( get_value_or_default, is_body_allowed_for_status_code, ) +from griffe import Docstring from pydantic import BaseModel from starlette import routing from starlette.concurrency import run_in_threadpool @@ -437,7 +437,7 @@ class APIRoute(routing.Route): dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, - response_description: str = "Successful Response", + response_description: Optional[str] = None, responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, name: Optional[str] = None, @@ -528,10 +528,13 @@ class APIRoute(routing.Route): self.response_field = None # type: ignore self.secure_cloned_response_field = None self.dependencies = list(dependencies or []) - self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") + self.description = description + self.docstring = self.endpoint.__doc__ or "" + self.parsed_docstring = Docstring( + self.docstring, parser="google", parser_options={"warnings": False} + ).parsed # if a "form feed" character (page break) is found in the description text, # truncate description text to the content preceding the first "form feed" - self.description = self.description.split("\f")[0].strip() response_fields = {} for additional_status_code, response in self.responses.items(): assert isinstance(response, dict), "An additional response must be a dict" @@ -889,7 +892,7 @@ class APIRouter(routing.Router): dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, - response_description: str = "Successful Response", + response_description: Optional[str] = None, responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, methods: Optional[Union[Set[str], List[str]]] = None, @@ -970,7 +973,7 @@ class APIRouter(routing.Router): dependencies: Optional[Sequence[params.Depends]] = None, summary: Optional[str] = None, description: Optional[str] = None, - response_description: str = "Successful Response", + response_description: Optional[str] = None, responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, methods: Optional[List[str]] = None, @@ -1480,7 +1483,7 @@ class APIRouter(routing.Router): ), ] = None, response_description: Annotated[ - str, + Optional[str], Doc( """ The description for the default response. @@ -1488,7 +1491,7 @@ class APIRouter(routing.Router): It will be added to the generated OpenAPI (e.g. visible at `/docs`). """ ), - ] = "Successful Response", + ] = None, responses: Annotated[ Optional[Dict[Union[int, str], Dict[str, Any]]], Doc( @@ -1857,7 +1860,7 @@ class APIRouter(routing.Router): ), ] = None, response_description: Annotated[ - str, + Optional[str], Doc( """ The description for the default response. @@ -1865,7 +1868,7 @@ class APIRouter(routing.Router): It will be added to the generated OpenAPI (e.g. visible at `/docs`). """ ), - ] = "Successful Response", + ] = None, responses: Annotated[ Optional[Dict[Union[int, str], Dict[str, Any]]], Doc( @@ -2239,7 +2242,7 @@ class APIRouter(routing.Router): ), ] = None, response_description: Annotated[ - str, + Optional[str], Doc( """ The description for the default response. @@ -2247,7 +2250,7 @@ class APIRouter(routing.Router): It will be added to the generated OpenAPI (e.g. visible at `/docs`). """ ), - ] = "Successful Response", + ] = None, responses: Annotated[ Optional[Dict[Union[int, str], Dict[str, Any]]], Doc( @@ -2621,7 +2624,7 @@ class APIRouter(routing.Router): ), ] = None, response_description: Annotated[ - str, + Optional[str], Doc( """ The description for the default response. @@ -2629,7 +2632,7 @@ class APIRouter(routing.Router): It will be added to the generated OpenAPI (e.g. visible at `/docs`). """ ), - ] = "Successful Response", + ] = None, responses: Annotated[ Optional[Dict[Union[int, str], Dict[str, Any]]], Doc( @@ -2998,7 +3001,7 @@ class APIRouter(routing.Router): ), ] = None, response_description: Annotated[ - str, + Optional[str], Doc( """ The description for the default response. @@ -3006,7 +3009,7 @@ class APIRouter(routing.Router): It will be added to the generated OpenAPI (e.g. visible at `/docs`). """ ), - ] = "Successful Response", + ] = None, responses: Annotated[ Optional[Dict[Union[int, str], Dict[str, Any]]], Doc( @@ -3375,7 +3378,7 @@ class APIRouter(routing.Router): ), ] = None, response_description: Annotated[ - str, + Optional[str], Doc( """ The description for the default response. @@ -3383,7 +3386,7 @@ class APIRouter(routing.Router): It will be added to the generated OpenAPI (e.g. visible at `/docs`). """ ), - ] = "Successful Response", + ] = None, responses: Annotated[ Optional[Dict[Union[int, str], Dict[str, Any]]], Doc( @@ -3757,7 +3760,7 @@ class APIRouter(routing.Router): ), ] = None, response_description: Annotated[ - str, + Optional[str], Doc( """ The description for the default response. @@ -3765,7 +3768,7 @@ class APIRouter(routing.Router): It will be added to the generated OpenAPI (e.g. visible at `/docs`). """ ), - ] = "Successful Response", + ] = None, responses: Annotated[ Optional[Dict[Union[int, str], Dict[str, Any]]], Doc( @@ -4139,7 +4142,7 @@ class APIRouter(routing.Router): ), ] = None, response_description: Annotated[ - str, + Optional[str], Doc( """ The description for the default response. @@ -4147,7 +4150,7 @@ class APIRouter(routing.Router): It will be added to the generated OpenAPI (e.g. visible at `/docs`). """ ), - ] = "Successful Response", + ] = None, responses: Annotated[ Optional[Dict[Union[int, str], Dict[str, Any]]], Doc( diff --git a/pyproject.toml b/pyproject.toml index 1c540e2f6..973313b15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "starlette>=0.40.0,<0.47.0", "pydantic>=1.7.4,!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0", "typing-extensions>=4.8.0", + "griffe>=1.7.0", ] [project.urls]