From dbf411b942b538764dbb69ae06703dd6b3c26126 Mon Sep 17 00:00:00 2001 From: luolingchun Date: Sat, 4 Jan 2025 09:59:06 +0800 Subject: [PATCH 1/4] Support multi content type in request body and responses --- examples/multi_content_type.py | 48 ++++++ flask_openapi3/blueprint.py | 11 +- flask_openapi3/openapi.py | 11 +- flask_openapi3/request.py | 31 +++- flask_openapi3/scaffold.py | 32 +++- flask_openapi3/types.py | 6 +- flask_openapi3/utils.py | 260 +++++++++++++++++++++------------ flask_openapi3/view.py | 11 +- tests/test_model_config.py | 3 +- tests/test_openapi.py | 41 +++--- 10 files changed, 315 insertions(+), 139 deletions(-) create mode 100644 examples/multi_content_type.py diff --git a/examples/multi_content_type.py b/examples/multi_content_type.py new file mode 100644 index 00000000..64e794f2 --- /dev/null +++ b/examples/multi_content_type.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# @Author : llc +# @Time : 2024/12/27 15:30 +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) + + +class DogBody(BaseModel): + a: int = None + b: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.dog+json" + } + } + + +class CatBody(BaseModel): + c: int = None + d: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.cat+json" + } + } + + +class ContentTypeModel(BaseModel): + model_config = { + "openapi_extra": { + "content_type": "text/csv" + } + } + + +@app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel}) +def index_a(body: DogBody | CatBody | ContentTypeModel): + print(body) + return {"hello": "world"} + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/flask_openapi3/blueprint.py b/flask_openapi3/blueprint.py index b0b85c39..4c95efdf 100644 --- a/flask_openapi3/blueprint.py +++ b/flask_openapi3/blueprint.py @@ -121,6 +121,8 @@ def _collect_openapi_info( security: Optional[list[dict[str, list[Any]]]] = None, servers: Optional[list[Server]] = None, openapi_extensions: Optional[dict[str, Any]] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True, method: str = HTTPMethod.GET ) -> ParametersTuple: @@ -140,6 +142,8 @@ def _collect_openapi_info( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ if self.doc_ui is True and doc_ui is True: @@ -193,6 +197,11 @@ def _collect_openapi_info( parse_method(uri, method, self.paths, operation) # Parse parameters - return parse_parameters(func, components_schemas=self.components_schemas, operation=operation) + return parse_parameters( + func, components_schemas=self.components_schemas, + operation=operation, + request_body_description=request_body_description, + request_body_required=request_body_required + ) else: return parse_parameters(func, doc_ui=False) diff --git a/flask_openapi3/openapi.py b/flask_openapi3/openapi.py index 5c1c91e8..97c995ae 100644 --- a/flask_openapi3/openapi.py +++ b/flask_openapi3/openapi.py @@ -380,6 +380,8 @@ def _collect_openapi_info( security: Optional[list[dict[str, list[Any]]]] = None, servers: Optional[list[Server]] = None, openapi_extensions: Optional[dict[str, Any]] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True, method: str = HTTPMethod.GET ) -> ParametersTuple: @@ -399,6 +401,8 @@ def _collect_openapi_info( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. method: HTTP method for the operation. Defaults to GET. """ @@ -450,6 +454,11 @@ def _collect_openapi_info( parse_method(uri, method, self.paths, operation) # Parse parameters - return parse_parameters(func, components_schemas=self.components_schemas, operation=operation) + return parse_parameters( + func, components_schemas=self.components_schemas, + operation=operation, + request_body_description=request_body_description, + request_body_required=request_body_required + ) else: return parse_parameters(func, doc_ui=False) diff --git a/flask_openapi3/request.py b/flask_openapi3/request.py index f6c1d39e..e4f8ec70 100644 --- a/flask_openapi3/request.py +++ b/flask_openapi3/request.py @@ -3,13 +3,22 @@ # @Time : 2022/4/1 16:54 import json from json import JSONDecodeError -from typing import Any, Type, Optional + +from typing import Any, Type, Optional, get_origin, get_args, Union + +try: + from types import UnionType # type: ignore +except ImportError: + # python < 3.9 + UnionType = type(Union) # type: ignore from flask import request, current_app, abort -from pydantic import ValidationError, BaseModel +from pydantic import ValidationError, BaseModel, RootModel from pydantic.fields import FieldInfo from werkzeug.datastructures.structures import MultiDict +from flask_openapi3.utils import is_application_json + def _get_list_value(model: Type[BaseModel], args: MultiDict, model_field_key: str, model_field_value: FieldInfo): if model_field_value.alias and model.model_config.get("populate_by_name"): @@ -138,12 +147,20 @@ def _validate_form(form: Type[BaseModel], func_kwargs: dict): def _validate_body(body: Type[BaseModel], func_kwargs: dict): - obj = request.get_json(silent=True) - if isinstance(obj, str): - body_model = body.model_validate_json(json_data=obj) + if is_application_json(request.mimetype): + if get_origin(body) == UnionType: + root_model_list = [model for model in get_args(body)] + Body = RootModel[Union[tuple(root_model_list)]] # type: ignore + else: + Body = body # type: ignore + obj = request.get_json(silent=True) + if isinstance(obj, str): + body_model = Body.model_validate_json(json_data=obj) + else: + body_model = Body.model_validate(obj=obj) + func_kwargs["body"] = body_model else: - body_model = body.model_validate(obj=obj) - func_kwargs["body"] = body_model + func_kwargs["body"] = request def _validate_request( diff --git a/flask_openapi3/scaffold.py b/flask_openapi3/scaffold.py index 0e38e5ac..d7e3669a 100644 --- a/flask_openapi3/scaffold.py +++ b/flask_openapi3/scaffold.py @@ -32,13 +32,15 @@ def _collect_openapi_info( security: Optional[list[dict[str, list[Any]]]] = None, servers: Optional[list[Server]] = None, openapi_extensions: Optional[dict[str, Any]] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True, method: str = HTTPMethod.GET ) -> ParametersTuple: - raise NotImplementedError # pragma: no cover + raise NotImplementedError # pragma: no cover def register_api(self, api) -> None: - raise NotImplementedError # pragma: no cover + raise NotImplementedError # pragma: no cover def _add_url_rule( self, @@ -48,7 +50,7 @@ def _add_url_rule( provide_automatic_options=None, **options, ) -> None: - raise NotImplementedError # pragma: no cover + raise NotImplementedError # pragma: no cover @staticmethod def create_view_func( @@ -199,6 +201,8 @@ def post( security: Optional[list[dict[str, list[Any]]]] = None, servers: Optional[list[Server]] = None, openapi_extensions: Optional[dict[str, Any]] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True, **options: Any ) -> Callable: @@ -218,6 +222,8 @@ def post( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -236,6 +242,8 @@ def decorator(func) -> Callable: security=security, servers=servers, openapi_extensions=openapi_extensions, + request_body_description=request_body_description, + request_body_required=request_body_required, doc_ui=doc_ui, method=HTTPMethod.POST ) @@ -262,6 +270,8 @@ def put( security: Optional[list[dict[str, list[Any]]]] = None, servers: Optional[list[Server]] = None, openapi_extensions: Optional[dict[str, Any]] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True, **options: Any ) -> Callable: @@ -281,6 +291,8 @@ def put( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -299,6 +311,8 @@ def decorator(func) -> Callable: security=security, servers=servers, openapi_extensions=openapi_extensions, + request_body_description=request_body_description, + request_body_required=request_body_required, doc_ui=doc_ui, method=HTTPMethod.PUT ) @@ -325,6 +339,8 @@ def delete( security: Optional[list[dict[str, list[Any]]]] = None, servers: Optional[list[Server]] = None, openapi_extensions: Optional[dict[str, Any]] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True, **options: Any ) -> Callable: @@ -344,6 +360,8 @@ def delete( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -362,6 +380,8 @@ def decorator(func) -> Callable: security=security, servers=servers, openapi_extensions=openapi_extensions, + request_body_description=request_body_description, + request_body_required=request_body_required, doc_ui=doc_ui, method=HTTPMethod.DELETE ) @@ -388,6 +408,8 @@ def patch( security: Optional[list[dict[str, list[Any]]]] = None, servers: Optional[list[Server]] = None, openapi_extensions: Optional[dict[str, Any]] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True, **options: Any ) -> Callable: @@ -407,6 +429,8 @@ def patch( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -425,6 +449,8 @@ def decorator(func) -> Callable: security=security, servers=servers, openapi_extensions=openapi_extensions, + request_body_description=request_body_description, + request_body_required=request_body_required, doc_ui=doc_ui, method=HTTPMethod.PATCH ) diff --git a/flask_openapi3/types.py b/flask_openapi3/types.py index d7e2b437..90de0d49 100644 --- a/flask_openapi3/types.py +++ b/flask_openapi3/types.py @@ -2,14 +2,16 @@ # @Author : llc # @Time : 2023/7/9 15:25 from http import HTTPStatus -from typing import Union, Type, Any, Optional +from typing import Union, Type, Any, Optional, TypeVar from pydantic import BaseModel from .models import RawModel from .models import SecurityScheme -_ResponseDictValue = Union[Type[BaseModel], dict[Any, Any], None] +_MultiBaseModel = TypeVar("_MultiBaseModel", bound=Type[BaseModel]) + +_ResponseDictValue = Union[Type[BaseModel], _MultiBaseModel, dict[Any, Any], None] ResponseDict = dict[Union[str, int, HTTPStatus], _ResponseDictValue] diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index 205a8cfc..1494105c 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -7,12 +7,20 @@ import sys from enum import Enum from http import HTTPStatus -from typing import get_type_hints, Type, Callable, Optional, Any, DefaultDict + +from typing import get_type_hints, Type, Callable, Optional, Any, DefaultDict, Union + +try: + from types import UnionType # type: ignore +except ImportError: + # python < 3.9 + UnionType = type(Union) # type: ignore from flask import make_response, current_app from flask.wrappers import Response as FlaskResponse from pydantic import BaseModel, ValidationError from pydantic.json_schema import JsonSchemaMode +from typing_extensions import get_args, get_origin from .models import Encoding from .models import MediaType @@ -268,6 +276,11 @@ def parse_form( ) -> tuple[dict[str, MediaType], dict]: """Parses a form model and returns a list of parameters and component schemas.""" schema = get_model_schema(form) + + model_config: DefaultDict[str, Any] = form.model_config # type: ignore + openapi_extra = model_config.get("openapi_extra", {}) + content_type = openapi_extra.get("content_type", "multipart/form-data") + components_schemas = dict() properties = schema.get("properties", {}) @@ -280,14 +293,22 @@ def parse_form( for k, v in properties.items(): if v.get("type") == "array": encoding[k] = Encoding(style="form", explode=True) - content = { - "multipart/form-data": MediaType( - schema=Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{title}"}), - ) - } + + media_type = MediaType(**{"schema": Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{title}"})}) + + if openapi_extra: + openapi_extra_keys = openapi_extra.keys() + if "example" in openapi_extra_keys: + media_type.example = openapi_extra.get("example") + if "examples" in openapi_extra_keys: + media_type.examples = openapi_extra.get("examples") + if "encoding" in openapi_extra_keys: + media_type.encoding = openapi_extra.get("encoding") + if encoding: - content["multipart/form-data"].encoding = encoding + media_type.encoding = encoding + content = {content_type: media_type} # Parse definitions definitions = schema.get("$defs", {}) for name, value in definitions.items(): @@ -300,22 +321,49 @@ def parse_body( body: Type[BaseModel], ) -> tuple[dict[str, MediaType], dict]: """Parses a body model and returns a list of parameters and component schemas.""" - schema = get_model_schema(body) - components_schemas = dict() - original_title = schema.get("title") or body.__name__ - title = normalize_name(original_title) - components_schemas[title] = Schema(**schema) - content = { - "application/json": MediaType( - schema=Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{title}"}) - ) - } + content = {} + components_schemas = {} - # Parse definitions - definitions = schema.get("$defs", {}) - for name, value in definitions.items(): - components_schemas[name] = Schema(**value) + def _parse_body(_model): + model_config: DefaultDict[str, Any] = _model.model_config # type: ignore + openapi_extra = model_config.get("openapi_extra", {}) + content_type = openapi_extra.get("content_type", "application/json") + + if not is_application_json(content_type): + content_schema = openapi_extra.get("content_schema", {"type": DataType.STRING}) + content[content_type] = MediaType(**{"schema": content_schema}) + return + + schema = get_model_schema(_model) + + original_title = schema.get("title") or _model.__name__ + title = normalize_name(original_title) + components_schemas[title] = Schema(**schema) + + media_type = MediaType(**{"schema": Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{title}"})}) + + if openapi_extra: + openapi_extra_keys = openapi_extra.keys() + if "example" in openapi_extra_keys: + media_type.example = openapi_extra.get("example") + if "examples" in openapi_extra_keys: + media_type.examples = openapi_extra.get("examples") + if "encoding" in openapi_extra_keys: + media_type.encoding = openapi_extra.get("encoding") + + content[content_type] = media_type + + # Parse definitions + definitions = schema.get("$defs", {}) + for name, value in definitions.items(): + components_schemas[name] = Schema(**value) + + if get_origin(body) == UnionType: + for model in get_args(body): + _parse_body(model) + else: + _parse_body(body) return content, components_schemas @@ -325,54 +373,86 @@ def get_responses( components_schemas: dict, operation: Operation ) -> None: - _responses = {} - _schemas = {} + _responses: dict = {} + _schemas: dict = {} + + def _parse_response(_key, _model): + model_config: DefaultDict[str, Any] = _model.model_config # type: ignore + openapi_extra = model_config.get("openapi_extra", {}) + content_type = openapi_extra.get("content_type", "application/json") + + if not is_application_json(content_type): + content_schema = openapi_extra.get("content_schema", {"type": DataType.STRING}) + media_type = MediaType(**{"schema": content_schema}) + if _responses.get(_key): + _responses[_key].content[content_type] = media_type + else: + _responses[_key] = Response( + description=HTTP_STATUS.get(_key, ""), + content={content_type: media_type} + ) + return + + schema = get_model_schema(_model, mode="serialization") + # OpenAPI 3 support ^[a-zA-Z0-9\.\-_]+$ so we should normalize __name__ + original_title = schema.get("title") or _model.__name__ + name = normalize_name(original_title) + + media_type = MediaType(**{"schema": Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{name}"})}) + + if openapi_extra: + openapi_extra_keys = openapi_extra.keys() + if "example" in openapi_extra_keys: + media_type.example = openapi_extra.get("example") + if "examples" in openapi_extra_keys: + media_type.examples = openapi_extra.get("examples") + if "encoding" in openapi_extra_keys: + media_type.encoding = openapi_extra.get("encoding") + if _responses.get(_key): + _responses[_key].content[content_type] = media_type + else: + _responses[_key] = Response( + description=HTTP_STATUS.get(_key, ""), + content={content_type: media_type} + ) + + _schemas[name] = Schema(**schema) + definitions = schema.get("$defs") + if definitions: + # Add schema definitions to _schemas + for name, value in definitions.items(): + _schemas[normalize_name(name)] = Schema(**value) for key, response in responses.items(): - if response is None: + if isinstance(response, dict) and "model" in response: + response_model = response.get("model") + response_description = response.get("description") + response_headers = response.get("headers") + response_links = response.get("links") + else: + response_model = response + response_description = None + response_headers = None + response_links = None + + if response_model is None: # If the response is None, it means HTTP status code "204" (No Content) _responses[key] = Response(description=HTTP_STATUS.get(key, "")) - elif isinstance(response, dict): - response["description"] = response.get("description", HTTP_STATUS.get(key, "")) - _responses[key] = Response(**response) + elif isinstance(response_model, dict): + response_model["description"] = response_model.get("description", HTTP_STATUS.get(key, "")) + _responses[key] = Response(**response_model) + elif get_origin(response_model) == UnionType: + for model in get_args(response_model): + _parse_response(key, model) else: - # OpenAPI 3 support ^[a-zA-Z0-9\.\-_]+$ so we should normalize __name__ - schema = get_model_schema(response, mode="serialization") - original_title = schema.get("title") or response.__name__ - name = normalize_name(original_title) - _responses[key] = Response( - description=HTTP_STATUS.get(key, ""), - content={ - "application/json": MediaType( - schema=Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{name}"}) - )}) - - model_config: DefaultDict[str, Any] = response.model_config # type: ignore - openapi_extra = model_config.get("openapi_extra", {}) - if openapi_extra: - openapi_extra_keys = openapi_extra.keys() - # Add additional information from model_config to the response - if "description" in openapi_extra_keys: - _responses[key].description = openapi_extra.get("description") - if "headers" in openapi_extra_keys: - _responses[key].headers = openapi_extra.get("headers") - if "links" in openapi_extra_keys: - _responses[key].links = openapi_extra.get("links") - _content = _responses[key].content - if "example" in openapi_extra_keys: - _content["application/json"].example = openapi_extra.get("example") # type: ignore - if "examples" in openapi_extra_keys: - _content["application/json"].examples = openapi_extra.get("examples") # type: ignore - if "encoding" in openapi_extra_keys: - _content["application/json"].encoding = openapi_extra.get("encoding") # type: ignore - _content.update(openapi_extra.get("content", {})) # type: ignore - - _schemas[name] = Schema(**schema) - definitions = schema.get("$defs") - if definitions: - # Add schema definitions to _schemas - for name, value in definitions.items(): - _schemas[normalize_name(name)] = Schema(**value) + _parse_response(key, response_model) + + if response_description is not None: + _responses[key].description = response_description + if response_headers is not None: + _responses[key].headers = response_headers + if response_links is not None: + _responses[key].links = response_links components_schemas.update(**_schemas) operation.responses = _responses @@ -413,6 +493,8 @@ def parse_parameters( *, components_schemas: Optional[dict] = None, operation: Optional[Operation] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True, ) -> ParametersTuple: """ @@ -423,6 +505,8 @@ def parse_parameters( func: The function to parse the parameters from. components_schemas: Dictionary to store the parsed components schemas (default: None). operation: Operation object to populate with parsed parameters (default: None). + request_body_description: A brief description of the request body (default: None). + request_body_required: Determines if the request body is required in the request (default: True). doc_ui: Flag indicating whether to return types for documentation UI (default: True). Returns: @@ -481,51 +565,31 @@ def parse_parameters( _content, _components_schemas = parse_form(form) components_schemas.update(**_components_schemas) request_body = RequestBody(content=_content, required=True) - model_config: DefaultDict[str, Any] = form.model_config # type: ignore - openapi_extra = model_config.get("openapi_extra", {}) - if openapi_extra: - openapi_extra_keys = openapi_extra.keys() - if "description" in openapi_extra_keys: - request_body.description = openapi_extra.get("description") - if "example" in openapi_extra_keys: - request_body.content["multipart/form-data"].example = openapi_extra.get("example") - if "examples" in openapi_extra_keys: - request_body.content["multipart/form-data"].examples = openapi_extra.get("examples") - if "encoding" in openapi_extra_keys: - request_body.content["multipart/form-data"].encoding = openapi_extra.get("encoding") + if request_body_description: + request_body.description = request_body_description + request_body.required = request_body_required operation.requestBody = request_body if body: _content, _components_schemas = parse_body(body) components_schemas.update(**_components_schemas) request_body = RequestBody(content=_content, required=True) - model_config: DefaultDict[str, Any] = body.model_config # type: ignore - openapi_extra = model_config.get("openapi_extra", {}) - if openapi_extra: - openapi_extra_keys = openapi_extra.keys() - if "description" in openapi_extra_keys: - request_body.description = openapi_extra.get("description") - request_body.required = openapi_extra.get("required", True) - if "example" in openapi_extra_keys: - request_body.content["application/json"].example = openapi_extra.get("example") - if "examples" in openapi_extra_keys: - request_body.content["application/json"].examples = openapi_extra.get("examples") - if "encoding" in openapi_extra_keys: - request_body.content["application/json"].encoding = openapi_extra.get("encoding") + if request_body_description: + request_body.description = request_body_description + request_body.required = request_body_required operation.requestBody = request_body if raw: _content = {} for mimetype in raw.mimetypes: - if mimetype.startswith("application/json"): - _content[mimetype] = MediaType( - schema=Schema(type=DataType.OBJECT) - ) + if is_application_json(mimetype): + _content[mimetype] = MediaType(**{"schema": Schema(type=DataType.OBJECT)}) else: - _content[mimetype] = MediaType( - schema=Schema(type=DataType.STRING) - ) + _content[mimetype] = MediaType(**{"schema": Schema(type=DataType.STRING)}) request_body = RequestBody(content=_content) + if request_body_description: + request_body.description = request_body_description + request_body.required = request_body_required operation.requestBody = request_body if parameters: @@ -615,3 +679,7 @@ def convert_responses_key_to_string(responses: ResponseDict) -> ResponseStrKeyDi def normalize_name(name: str) -> str: return re.sub(r"[^\w.\-]", "_", name) + + +def is_application_json(content_type: str) -> bool: + return "application" in content_type and "json" in content_type diff --git a/flask_openapi3/view.py b/flask_openapi3/view.py index 23711bf9..b34cd9de 100644 --- a/flask_openapi3/view.py +++ b/flask_openapi3/view.py @@ -112,6 +112,8 @@ def doc( security: Optional[list[dict[str, list[Any]]]] = None, servers: Optional[list[Server]] = None, openapi_extensions: Optional[dict[str, Any]] = None, + request_body_description: Optional[str] = None, + request_body_required: Optional[bool] = True, doc_ui: bool = True ) -> Callable: """ @@ -129,6 +131,8 @@ def doc( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -177,9 +181,10 @@ def decorator(func): # Parse parameters parse_parameters( - func, - components_schemas=self.components_schemas, - operation=operation + func, components_schemas=self.components_schemas, + operation=operation, + request_body_description=request_body_description, + request_body_required=request_body_required ) # Parse response diff --git a/tests/test_model_config.py b/tests/test_model_config.py index 37fd8c0c..e2273901 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -42,7 +42,6 @@ class BookBody(BaseModel): model_config = dict( openapi_extra={ - "description": "This is post RequestBody", "example": {"age": 12, "author": "author1"}, "examples": { "example1": { @@ -97,7 +96,7 @@ def api_form(form: UploadFilesForm): print(form) # pragma: no cover -@app.post("/body", responses={"200": MessageResponse}) +@app.post("/body", request_body_description="This is post RequestBody", responses={"200": MessageResponse}) def api_error_json(body: BookBody): print(body) # pragma: no cover diff --git a/tests/test_openapi.py b/tests/test_openapi.py index bcdeaf72..b30be10e 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -17,8 +17,11 @@ class BaseResponse(BaseModel): """Base description""" test: int - model_config = dict( - openapi_extra={ + @test_app.get( + "/test", + responses={ + "201": { + "model": BaseResponse, "description": "Custom description", "headers": { "location": { @@ -26,20 +29,14 @@ class BaseResponse(BaseModel): "schema": {"type": "string"} } }, - "content": { - "text/plain": { - "schema": {"type": "string"} - } - }, "links": { "dummy": { "description": "dummy link" } } } - ) - - @test_app.get("/test", responses={"201": BaseResponse}) + } + ) def endpoint_test(): return b"", 201 # pragma: no cover @@ -58,10 +55,6 @@ def endpoint_test(): # This content is coming from responses "application/json": { "schema": {"$ref": "#/components/schemas/BaseResponse"} - }, - # While this one comes from responses - "text/plain": { - "schema": {"type": "string"} } }, "links": { @@ -561,16 +554,16 @@ def endpoint_test(body: TupleModel): assert schema == {'$ref': '#/components/schemas/TupleModel'} components = test_app.api_doc["components"]["schemas"] assert components["TupleModel"] == {'properties': {'my_tuple': {'maxItems': 2, - 'minItems': 2, - 'prefixItems': [{'enum': ['a', 'b'], - 'type': 'string'}, - {'enum': ['c', 'd'], - 'type': 'string'}], - 'title': 'My Tuple', - 'type': 'array'}}, - 'required': ['my_tuple'], - 'title': 'TupleModel', - 'type': 'object'} + 'minItems': 2, + 'prefixItems': [{'enum': ['a', 'b'], + 'type': 'string'}, + {'enum': ['c', 'd'], + 'type': 'string'}], + 'title': 'My Tuple', + 'type': 'array'}}, + 'required': ['my_tuple'], + 'title': 'TupleModel', + 'type': 'object'} def test_schema_bigint(request): From 880b20584bbcfed1f79dab02135639ce7ec855c1 Mon Sep 17 00:00:00 2001 From: luolingchun Date: Mon, 6 Jan 2025 17:10:22 +0800 Subject: [PATCH 2/4] Add tests --- examples/multi_content_type.py | 35 +++++++++- flask_openapi3/request.py | 12 ++-- flask_openapi3/utils.py | 10 +-- tests/test_api_blueprint.py | 14 +++- tests/test_api_view.py | 14 +++- tests/test_multi_content_type.py | 114 +++++++++++++++++++++++++++++++ tests/test_restapi.py | 13 +++- tests/test_server.py | 52 +++++++------- 8 files changed, 218 insertions(+), 46 deletions(-) create mode 100644 tests/test_multi_content_type.py diff --git a/examples/multi_content_type.py b/examples/multi_content_type.py index 64e794f2..7c706ab3 100644 --- a/examples/multi_content_type.py +++ b/examples/multi_content_type.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2024/12/27 15:30 +from flask import Request from pydantic import BaseModel from flask_openapi3 import OpenAPI @@ -30,6 +31,17 @@ class CatBody(BaseModel): } +class BsonModel(BaseModel): + e: int = None + f: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/bson" + } + } + + class ContentTypeModel(BaseModel): model_config = { "openapi_extra": { @@ -38,9 +50,28 @@ class ContentTypeModel(BaseModel): } -@app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel}) -def index_a(body: DogBody | CatBody | ContentTypeModel): +@app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel | BsonModel}) +def index_a(body: DogBody | CatBody | ContentTypeModel | BsonModel): + """ + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + from bson import BSON + + obj = BSON(body.data).decode() + new_body = body.model_validate(obj=obj) + print(new_body) + else: + # DogBody or CatBody + ... return {"hello": "world"} diff --git a/flask_openapi3/request.py b/flask_openapi3/request.py index e4f8ec70..11aab8bb 100644 --- a/flask_openapi3/request.py +++ b/flask_openapi3/request.py @@ -8,8 +8,8 @@ try: from types import UnionType # type: ignore -except ImportError: - # python < 3.9 +except ImportError: # pragma: no cover + # python < 3.10 UnionType = type(Union) # type: ignore from flask import request, current_app, abort @@ -51,10 +51,8 @@ def _get_value(model: Type[BaseModel], args: MultiDict, model_field_key: str, mo def _validate_header(header: Type[BaseModel], func_kwargs: dict): request_headers = dict(request.headers) header_dict = {} - model_properties = header.model_json_schema().get("properties", {}) for model_field_key, model_field_value in header.model_fields.items(): key_title = model_field_key.replace("_", "-").title() - model_field_schema = model_properties.get(model_field_value.alias or model_field_key) if model_field_value.alias and header.model_config.get("populate_by_name"): key = model_field_value.alias key_alias_title = model_field_value.alias.replace("_", "-").title() @@ -65,11 +63,9 @@ def _validate_header(header: Type[BaseModel], func_kwargs: dict): value = request_headers.get(key_alias_title) else: key = model_field_key - value = request_headers[key_title] + value = request_headers.get(key_title) if value is not None: header_dict[key] = value - if model_field_schema.get("type") == "null": - header_dict[key] = value # type:ignore # extra keys for key, value in request_headers.items(): if key not in header_dict.keys(): @@ -148,7 +144,7 @@ def _validate_form(form: Type[BaseModel], func_kwargs: dict): def _validate_body(body: Type[BaseModel], func_kwargs: dict): if is_application_json(request.mimetype): - if get_origin(body) == UnionType: + if get_origin(body) in (Union, UnionType): root_model_list = [model for model in get_args(body)] Body = RootModel[Union[tuple(root_model_list)]] # type: ignore else: diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index 1494105c..4f3d996d 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -12,9 +12,9 @@ try: from types import UnionType # type: ignore -except ImportError: - # python < 3.9 - UnionType = type(Union) # type: ignore +except ImportError: # pragma: no cover + # python < 3.10 + UnionType = Union # type: ignore from flask import make_response, current_app from flask.wrappers import Response as FlaskResponse @@ -359,7 +359,7 @@ def _parse_body(_model): for name, value in definitions.items(): components_schemas[name] = Schema(**value) - if get_origin(body) == UnionType: + if get_origin(body) in (Union, UnionType): for model in get_args(body): _parse_body(model) else: @@ -441,7 +441,7 @@ def _parse_response(_key, _model): elif isinstance(response_model, dict): response_model["description"] = response_model.get("description", HTTP_STATUS.get(key, "")) _responses[key] = Response(**response_model) - elif get_origin(response_model) == UnionType: + elif get_origin(response_model) in [UnionType, Union]: for model in get_args(response_model): _parse_response(key, model) else: diff --git a/tests/test_api_blueprint.py b/tests/test_api_blueprint.py index 4ca6bfcc..c5db8aee 100644 --- a/tests/test_api_blueprint.py +++ b/tests/test_api_blueprint.py @@ -7,7 +7,7 @@ import pytest from pydantic import BaseModel, Field -from flask_openapi3 import APIBlueprint, OpenAPI +from flask_openapi3 import APIBlueprint, OpenAPI, Server, ExternalDocumentation from flask_openapi3 import Tag, Info info = Info(title='book API', version='1.0.0') @@ -82,7 +82,17 @@ def update_book1(path: BookPath, body: BookBody): return {"code": 0, "message": "ok"} -@api.patch('/v2/book/') +@api.patch( + '/v2/book/', + servers=[Server( + url="http://127.0.0.1:5000", + variables=None + )], + external_docs=ExternalDocumentation( + url="https://www.openapis.org/", + description="Something great got better, get excited!"), + deprecated=True +) def update_book1_v2(path: BookPath, body: BookBody): assert path.bid == 1 assert body.age == 3 diff --git a/tests/test_api_view.py b/tests/test_api_view.py index 00b8a638..2f7e3d63 100644 --- a/tests/test_api_view.py +++ b/tests/test_api_view.py @@ -7,7 +7,7 @@ import pytest from pydantic import BaseModel, Field -from flask_openapi3 import APIView +from flask_openapi3 import APIView, Server, ExternalDocumentation from flask_openapi3 import OpenAPI, Tag, Info info = Info(title='book API', version='1.0.0') @@ -73,7 +73,17 @@ def put(self, path: BookPath): print(path) return "put" - @api_view.doc(summary="delete book", deprecated=True) + @api_view.doc( + summary="delete book", + servers=[Server( + url="http://127.0.0.1:5000", + variables=None + )], + external_docs=ExternalDocumentation( + url="https://www.openapis.org/", + description="Something great got better, get excited!"), + deprecated=True + ) def delete(self, path: BookPath): print(path) return "delete" diff --git a/tests/test_multi_content_type.py b/tests/test_multi_content_type.py new file mode 100644 index 00000000..cc9be776 --- /dev/null +++ b/tests/test_multi_content_type.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# @Author : llc +# @Time : 2025/1/6 16:37 +from typing import Union + +import pytest +from flask import Request +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) +app.config["TESTING"] = True + + +class DogBody(BaseModel): + a: int = None + b: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.dog+json" + } + } + + +class CatBody(BaseModel): + c: int = None + d: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.cat+json" + } + } + + +class BsonModel(BaseModel): + e: int = None + f: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/bson" + } + } + + +class ContentTypeModel(BaseModel): + model_config = { + "openapi_extra": { + "content_type": "text/csv" + } + } + + +@app.post("/a", responses={200: Union[DogBody, CatBody, ContentTypeModel, BsonModel]}) +def index_a(body: Union[DogBody, CatBody, ContentTypeModel, BsonModel]): + """ + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + ... + else: + # DogBody or CatBody + ... + return {"hello": "world"} + + +@app.post("/b", responses={200: Union[ContentTypeModel, BsonModel]}) +def index_b(body: Union[ContentTypeModel, BsonModel]): + """ + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + ... + else: + # DogBody or CatBody + ... + return {"hello": "world"} + + +@pytest.fixture +def client(): + client = app.test_client() + + return client + + +def test_openapi(client): + resp = client.get("/openapi/openapi.json") + assert resp.status_code == 200 + + resp = client.post("/a", json={"a": 1, "b": "2"}) + assert resp.status_code == 200 + + resp = client.post("/a", data="a,b,c\n1,2,3", headers={"Content-Type": "text/csv"}) + assert resp.status_code == 200 diff --git a/tests/test_restapi.py b/tests/test_restapi.py index 4a04aafc..21140e92 100644 --- a/tests/test_restapi.py +++ b/tests/test_restapi.py @@ -11,7 +11,7 @@ from flask import Response from pydantic import BaseModel, RootModel, Field -from flask_openapi3 import ExternalDocumentation +from flask_openapi3 import ExternalDocumentation, Server from flask_openapi3 import Info, Tag from flask_openapi3 import OpenAPI @@ -50,6 +50,8 @@ def get_operation_id_for_path_callback(*, name: str, path: str, method: str) -> class BookQuery(BaseModel): age: Optional[int] = Field(None, description='Age') + author: str + none: Optional[None] = None class BookBody(BaseModel): @@ -104,8 +106,13 @@ def client(): external_docs=ExternalDocumentation( url="https://www.openapis.org/", description="Something great got better, get excited!"), + servers=[Server( + url="http://127.0.0.1:5000", + variables=None + )], responses={"200": BookResponse}, - security=security + security=security, + deprecated=True, ) def get_book(path: BookPath): """Get a book @@ -117,7 +124,7 @@ def get_book(path: BookPath): @app.get('/book', tags=[book_tag], responses={"200": BookListResponseV1}) -def get_books(query: BookBody): +def get_books(query: BookQuery): """get books to get all books """ diff --git a/tests/test_server.py b/tests/test_server.py index 3e9b1830..6c8ac800 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3,7 +3,7 @@ # @Time : 2024/11/10 12:17 from pydantic import ValidationError -from flask_openapi3 import Server, ServerVariable +from flask_openapi3 import OpenAPI, Server, ServerVariable, ExternalDocumentation def test_server_variable(): @@ -11,33 +11,37 @@ def test_server_variable(): url="http://127.0.0.1:5000", variables=None ) + error = 0 try: variables = {"one": ServerVariable(default="one", enum=[])} - Server( - url="http://127.0.0.1:5000", - variables=variables - ) - error = 0 except ValidationError: error = 1 assert error == 1 - try: - variables = {"one": ServerVariable(default="one")} - Server( - url="http://127.0.0.1:5000", - variables=variables - ) - error = 0 - except ValidationError: - error = 1 + variables = {"one": ServerVariable(default="one")} + Server( + url="http://127.0.0.1:5000", + variables=variables + ) + error = 0 assert error == 0 - try: - variables = {"one": ServerVariable(default="one", enum=["one", "two"])} - Server( - url="http://127.0.0.1:5000", - variables=variables - ) - error = 0 - except ValidationError: - error = 1 + variables = {"one": ServerVariable(default="one", enum=["one", "two"])} + Server( + url="http://127.0.0.1:5000", + variables=variables + ) + error = 0 assert error == 0 + + app = OpenAPI( + __name__, + servers=[Server( + url="http://127.0.0.1:5000", + variables=None + )], + external_docs=ExternalDocumentation( + url="https://www.openapis.org/", + description="Something great got better, get excited!") + ) + + assert "servers" in app.api_doc + assert "externalDocs" in app.api_doc From 20111c97da798ced000b5c4b045ea8ff4cb9ec3c Mon Sep 17 00:00:00 2001 From: luolingchun Date: Tue, 14 Jan 2025 11:14:14 +0800 Subject: [PATCH 3/4] Add docs --- docs/Usage/Request.md | 84 +++++++++++++- docs/Usage/Response.md | 116 +++++++++++++++++++ docs/Usage/Route_Operation.md | 23 ++++ docs/assets/Snipaste_2025-01-14_10-44-00.png | Bin 0 -> 18537 bytes docs/assets/Snipaste_2025-01-14_10-49-19.png | Bin 0 -> 19859 bytes docs/assets/Snipaste_2025-01-14_10-56-40.png | Bin 0 -> 7711 bytes docs/assets/Snipaste_2025-01-14_11-08-40.png | Bin 0 -> 18444 bytes examples/multi_content_type.py | 2 + flask_openapi3/utils.py | 5 +- 9 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 docs/assets/Snipaste_2025-01-14_10-44-00.png create mode 100644 docs/assets/Snipaste_2025-01-14_10-49-19.png create mode 100644 docs/assets/Snipaste_2025-01-14_10-56-40.png create mode 100644 docs/assets/Snipaste_2025-01-14_11-08-40.png diff --git a/docs/Usage/Request.md b/docs/Usage/Request.md index 9042f6e3..e7765322 100644 --- a/docs/Usage/Request.md +++ b/docs/Usage/Request.md @@ -103,6 +103,88 @@ def get_book(raw: BookRaw): return "ok" ``` +## Multiple content types in the request body + +```python +from typing import Union + +from flask import Request +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) + + +class DogBody(BaseModel): + a: int = None + b: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.dog+json" + } + } + + +class CatBody(BaseModel): + c: int = None + d: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.cat+json" + } + } + + +class BsonModel(BaseModel): + e: int = None + f: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/bson" + } + } + + +class ContentTypeModel(BaseModel): + model_config = { + "openapi_extra": { + "content_type": "text/csv" + } + } + + +@app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel | BsonModel}) +def index_a(body: DogBody | CatBody | ContentTypeModel | BsonModel): + """ + multiple content types examples. + + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + ... + else: + # DogBody or CatBody + ... + return {"hello": "world"} +``` + +The effect in swagger: + +![](../assets/Snipaste_2025-01-14_10-44-00.png) + + ## Request model First, you need to define a [pydantic](https://github.com/pydantic/pydantic) model: @@ -125,7 +207,7 @@ class BookQuery(BaseModel): author: str = Field(None, description='Author', json_schema_extra={"deprecated": True}) ``` -Magic: +The effect in swagger: ![](../assets/Snipaste_2022-09-04_10-10-03.png) diff --git a/docs/Usage/Response.md b/docs/Usage/Response.md index 8d8300b9..aad1c938 100644 --- a/docs/Usage/Response.md +++ b/docs/Usage/Response.md @@ -56,6 +56,122 @@ def hello(path: HelloPath): ![image-20210526104627124](../assets/image-20210526104627124.png) +*Sometimes you may need more description fields about the response, such as description, headers and links. + +You can use the following form: + +```python +@app.get( + "/test", + responses={ + "201": { + "model": BaseResponse, + "description": "Custom description", + "headers": { + "location": { + "description": "URL of the new resource", + "schema": {"type": "string"} + } + }, + "links": { + "dummy": { + "description": "dummy link" + } + } + } + } + ) + def endpoint_test(): + ... +``` + +The effect in swagger: + +![](../assets/Snipaste_2025-01-14_11-08-40.png) + + +## Multiple content types in the responses + +```python +from typing import Union + +from flask import Request +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) + + +class DogBody(BaseModel): + a: int = None + b: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.dog+json" + } + } + + +class CatBody(BaseModel): + c: int = None + d: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.cat+json" + } + } + + +class BsonModel(BaseModel): + e: int = None + f: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/bson" + } + } + + +class ContentTypeModel(BaseModel): + model_config = { + "openapi_extra": { + "content_type": "text/csv" + } + } + + +@app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel | BsonModel}) +def index_a(body: DogBody | CatBody | ContentTypeModel | BsonModel): + """ + multiple content types examples. + + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + ... + else: + # DogBody or CatBody + ... + return {"hello": "world"} +``` + +The effect in swagger: + +![](../assets/Snipaste_2025-01-14_10-49-19.png) + + ## More information about OpenAPI responses - [OpenAPI Responses Object](https://spec.openapis.org/oas/v3.1.0#responses-object), it includes the Response Object. diff --git a/docs/Usage/Route_Operation.md b/docs/Usage/Route_Operation.md index 2db8e3c0..30ed972e 100644 --- a/docs/Usage/Route_Operation.md +++ b/docs/Usage/Route_Operation.md @@ -287,6 +287,29 @@ class BookListAPIView: app.register_api_view(api_view) ``` +## request_body_description + +A brief description of the request body. + +```python +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) + +@app.post( + "/", + request_body_description="A brief description of the request body." +) +def create_book(body: Bookbody): + ... +``` + +![](../assets/Snipaste_2025-01-14_10-56-40.png) + +## request_body_required + +Determines if the request body is required in the request. + ## doc_ui You can pass `doc_ui=False` to disable the `OpenAPI spec` when init `OpenAPI `. diff --git a/docs/assets/Snipaste_2025-01-14_10-44-00.png b/docs/assets/Snipaste_2025-01-14_10-44-00.png new file mode 100644 index 0000000000000000000000000000000000000000..d716d979521ae36a543573008972597568971ca9 GIT binary patch literal 18537 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 literal 0 HcmV?d00001 diff --git a/docs/assets/Snipaste_2025-01-14_10-56-40.png b/docs/assets/Snipaste_2025-01-14_10-56-40.png new file mode 100644 index 0000000000000000000000000000000000000000..e66488dced0a5c5331904f35f578c99bb157feca GIT binary patch literal 7711 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 literal 0 HcmV?d00001 diff --git a/docs/assets/Snipaste_2025-01-14_11-08-40.png b/docs/assets/Snipaste_2025-01-14_11-08-40.png new file mode 100644 index 0000000000000000000000000000000000000000..dd537d87aae74b2975a00c05fb8d6b88bfff1bc2 GIT binary patch literal 18444 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 literal 0 HcmV?d00001 diff --git a/examples/multi_content_type.py b/examples/multi_content_type.py index 7c706ab3..28a2f1fa 100644 --- a/examples/multi_content_type.py +++ b/examples/multi_content_type.py @@ -53,6 +53,8 @@ class ContentTypeModel(BaseModel): @app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel | BsonModel}) def index_a(body: DogBody | CatBody | ContentTypeModel | BsonModel): """ + multiple content types examples. + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index 4f3d996d..5fa1182a 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -7,8 +7,8 @@ import sys from enum import Enum from http import HTTPStatus - -from typing import get_type_hints, Type, Callable, Optional, Any, DefaultDict, Union +from typing import Dict, Type, Callable, List, Tuple, Optional, Any, DefaultDict, Union +from typing import get_args, get_origin, get_type_hints try: from types import UnionType # type: ignore @@ -20,7 +20,6 @@ from flask.wrappers import Response as FlaskResponse from pydantic import BaseModel, ValidationError from pydantic.json_schema import JsonSchemaMode -from typing_extensions import get_args, get_origin from .models import Encoding from .models import MediaType From 2064eea3153ee689f19d5a55b4e241de77ee35ae Mon Sep 17 00:00:00 2001 From: luolingchun Date: Sat, 8 Feb 2025 10:16:43 +0800 Subject: [PATCH 4/4] Fix ruff --- flask_openapi3/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index 5fa1182a..817a0398 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -7,7 +7,7 @@ import sys from enum import Enum from http import HTTPStatus -from typing import Dict, Type, Callable, List, Tuple, Optional, Any, DefaultDict, Union +from typing import Type, Callable, Optional, Any, DefaultDict, Union from typing import get_args, get_origin, get_type_hints try: