From 53849b696fbb6ae11c00536ec7698f27fbd458ba Mon Sep 17 00:00:00 2001 From: luolingchun Date: Sat, 4 Jan 2025 09:59:06 +0800 Subject: [PATCH] Support multi content type in request body and responses --- 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 | 65 +++++ flask_openapi3/blueprint.py | 12 +- flask_openapi3/models/path_item.py | 22 +- flask_openapi3/openapi.py | 12 +- flask_openapi3/request.py | 27 +- flask_openapi3/scaffold.py | 26 ++ flask_openapi3/types.py | 6 +- flask_openapi3/utils.py | 246 ++++++++++++------- flask_openapi3/view.py | 12 +- tests/test_api_blueprint.py | 11 +- tests/test_api_view.py | 11 +- tests/test_model_config.py | 3 +- tests/test_multi_content_type.py | 98 ++++++++ tests/test_openapi.py | 17 +- tests/test_restapi.py | 8 +- tests/test_server.py | 34 +-- 23 files changed, 687 insertions(+), 146 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 create mode 100644 examples/multi_content_type.py create mode 100644 tests/test_multi_content_type.py diff --git a/docs/Usage/Request.md b/docs/Usage/Request.md index c5ff9839..c41dee0b 100644 --- a/docs/Usage/Request.md +++ b/docs/Usage/Request.md @@ -167,6 +167,88 @@ def get_book(query: BookQuery, client_id:str = None): ... ``` +## 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: @@ -191,7 +273,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 fae8bf93..b229a385 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 20aadf25..077d4b6c 100644 --- a/docs/Usage/Route_Operation.md +++ b/docs/Usage/Route_Operation.md @@ -289,6 +289,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 new file mode 100644 index 00000000..857528f1 --- /dev/null +++ b/examples/multi_content_type.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# @Author : llc +# @Time : 2024/12/27 15:30 +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 + 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"} + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/flask_openapi3/blueprint.py b/flask_openapi3/blueprint.py index ba7e95c7..361acfb6 100644 --- a/flask_openapi3/blueprint.py +++ b/flask_openapi3/blueprint.py @@ -121,6 +121,8 @@ def _collect_openapi_info( security: list[dict[str, list[Any]]] | None = None, servers: list[Server] | None = None, openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = 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: @@ -191,6 +195,12 @@ 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/models/path_item.py b/flask_openapi3/models/path_item.py index 5622531b..2131b4ae 100644 --- a/flask_openapi3/models/path_item.py +++ b/flask_openapi3/models/path_item.py @@ -1,18 +1,14 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:50 -import typing -from typing import Optional from pydantic import BaseModel, Field +from .operation import Operation from .parameter import Parameter from .reference import Reference from .server import Server -if typing.TYPE_CHECKING: # pragma: no cover - from .operation import Operation - class PathItem(BaseModel): """ @@ -22,14 +18,14 @@ class PathItem(BaseModel): ref: str | None = Field(default=None, alias="$ref") summary: str | None = None description: str | None = None - get: Optional["Operation"] = None - put: Optional["Operation"] = None - post: Optional["Operation"] = None - delete: Optional["Operation"] = None - options: Optional["Operation"] = None - head: Optional["Operation"] = None - patch: Optional["Operation"] = None - trace: Optional["Operation"] = None + get: Operation | None = None + put: Operation | None = None + post: Operation | None = None + delete: Operation | None = None + options: Operation | None = None + head: Operation | None = None + patch: Operation | None = None + trace: Operation | None = None servers: list[Server] | None = None parameters: list[Parameter | Reference] | None = None diff --git a/flask_openapi3/openapi.py b/flask_openapi3/openapi.py index c9c04e2b..dbc1187b 100644 --- a/flask_openapi3/openapi.py +++ b/flask_openapi3/openapi.py @@ -370,6 +370,8 @@ def _collect_openapi_info( security: list[dict[str, list[Any]]] | None = None, servers: list[Server] | None = None, openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, method: str = HTTPMethod.GET, ) -> ParametersTuple: @@ -389,6 +391,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. """ @@ -437,6 +441,12 @@ 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 d5936dbd..5aef305b 100644 --- a/flask_openapi3/request.py +++ b/flask_openapi3/request.py @@ -5,14 +5,15 @@ import json from functools import wraps from json import JSONDecodeError -from typing import Any, Type +from types import UnionType +from typing import Any, Type, Union, get_args, get_origin from flask import abort, current_app, request -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, RootModel, ValidationError from pydantic.fields import FieldInfo from werkzeug.datastructures.structures import MultiDict -from .utils import parse_parameters +from flask_openapi3.utils import is_application_json, parse_parameters def _get_list_value(model: Type[BaseModel], args: MultiDict, model_field_key: str, model_field_value: FieldInfo): @@ -60,7 +61,7 @@ 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": @@ -149,12 +150,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) in (Union, 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 02210445..795b2727 100644 --- a/flask_openapi3/scaffold.py +++ b/flask_openapi3/scaffold.py @@ -29,6 +29,8 @@ def _collect_openapi_info( security: list[dict[str, list[Any]]] | None = None, servers: list[Server] | None = None, openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, method: str = HTTPMethod.GET, ) -> ParametersTuple: @@ -192,6 +194,8 @@ def post( security: list[dict[str, list[Any]]] | None = None, servers: list[Server] | None = None, openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -211,6 +215,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. """ @@ -228,6 +234,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, ) @@ -254,6 +262,8 @@ def put( security: list[dict[str, list[Any]]] | None = None, servers: list[Server] | None = None, openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -273,6 +283,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. """ @@ -290,6 +302,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, ) @@ -316,6 +330,8 @@ def delete( security: list[dict[str, list[Any]]] | None = None, servers: list[Server] | None = None, openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -335,6 +351,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. """ @@ -352,6 +370,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, ) @@ -378,6 +398,8 @@ def patch( security: list[dict[str, list[Any]]] | None = None, servers: list[Server] | None = None, openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -397,6 +419,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. """ @@ -414,6 +438,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 22154a23..0984c6af 100644 --- a/flask_openapi3/types.py +++ b/flask_openapi3/types.py @@ -2,13 +2,15 @@ # @Author : llc # @Time : 2023/7/9 15:25 from http import HTTPStatus -from typing import Any, Type +from typing import Any, Type, TypeVar, Union from pydantic import BaseModel from .models import RawModel, SecurityScheme -_ResponseDictValue = Type[BaseModel] | dict[Any, Any] | None +_MultiBaseModel = TypeVar("_MultiBaseModel", bound=Type[BaseModel]) + +_ResponseDictValue = Union[Type[BaseModel], _MultiBaseModel, dict[Any, Any], None] ResponseDict = dict[str | int | HTTPStatus, _ResponseDictValue] diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index e03f65ac..330f37cb 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -7,7 +7,17 @@ import sys from enum import Enum from http import HTTPStatus -from typing import Any, Callable, DefaultDict, Type, get_type_hints +from types import UnionType +from typing import ( + Any, + Callable, + DefaultDict, + Type, + Union, + get_args, + get_origin, + get_type_hints, +) from flask import current_app, make_response from flask.wrappers import Response as FlaskResponse @@ -265,6 +275,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", {}) @@ -277,14 +292,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(): @@ -297,69 +320,128 @@ 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) in (Union, UnionType): + for model in get_args(body): + _parse_body(model) + else: + _parse_body(body) return content, components_schemas def get_responses(responses: ResponseStrKeyDict, 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) in [UnionType, Union]: + 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 @@ -397,6 +479,8 @@ def parse_parameters( *, components_schemas: dict | None = None, operation: Operation | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, ) -> ParametersTuple: """ @@ -407,6 +491,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: @@ -465,47 +551,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: @@ -595,3 +665,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 c469a161..44cfc0b6 100644 --- a/flask_openapi3/view.py +++ b/flask_openapi3/view.py @@ -110,6 +110,8 @@ def doc( security: list[dict[str, list[Any]]] | None = None, servers: list[Server] | None = None, openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, ) -> Callable: """ @@ -127,6 +129,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. """ @@ -171,7 +175,13 @@ def decorator(func): parse_and_store_tags(tags, self.tags, self.tag_names, operation) # Parse parameters - parse_parameters(func, components_schemas=self.components_schemas, operation=operation) + parse_parameters( + func, + components_schemas=self.components_schemas, + operation=operation, + request_body_description=request_body_description, + request_body_required=request_body_required, + ) # Parse response get_responses(combine_responses, self.components_schemas, operation) diff --git a/tests/test_api_blueprint.py b/tests/test_api_blueprint.py index 6723cb30..00203cb1 100644 --- a/tests/test_api_blueprint.py +++ b/tests/test_api_blueprint.py @@ -6,7 +6,7 @@ import pytest from pydantic import BaseModel, Field -from flask_openapi3 import APIBlueprint, Info, OpenAPI, Tag +from flask_openapi3 import APIBlueprint, ExternalDocumentation, Info, OpenAPI, Server, Tag info = Info(title="book API", version="1.0.0") @@ -83,7 +83,14 @@ 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 f2ef4245..b80d3235 100644 --- a/tests/test_api_view.py +++ b/tests/test_api_view.py @@ -6,7 +6,7 @@ import pytest from pydantic import BaseModel, Field -from flask_openapi3 import APIView, Info, OpenAPI, Tag +from flask_openapi3 import APIView, ExternalDocumentation, Info, OpenAPI, Server, Tag info = Info(title="book API", version="1.0.0") jwt = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} @@ -62,7 +62,14 @@ 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_model_config.py b/tests/test_model_config.py index 8db1a64b..593654b3 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -34,7 +34,6 @@ class BookBody(BaseModel): model_config = dict( openapi_extra={ - "description": "This is post RequestBody", "example": {"age": 12, "author": "author1"}, "examples": { "example1": { @@ -73,7 +72,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_multi_content_type.py b/tests/test_multi_content_type.py new file mode 100644 index 00000000..115f0da0 --- /dev/null +++ b/tests/test_multi_content_type.py @@ -0,0 +1,98 @@ +# -*- 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_openapi.py b/tests/test_openapi.py index 46e01d39..3bf5baab 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -18,16 +18,17 @@ class BaseResponse(BaseModel): test: int - model_config = dict( - openapi_extra={ + @test_app.get( + "/test", + responses={ + "201": { + "model": BaseResponse, "description": "Custom description", "headers": {"location": {"description": "URL of the new resource", "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 @@ -39,9 +40,7 @@ def endpoint_test(): "headers": {"location": {"description": "URL of the new resource", "schema": {"type": "string"}}}, "content": { # This content is coming from responses - "application/json": {"schema": {"$ref": "#/components/schemas/BaseResponse"}}, - # While this one comes from responses - "text/plain": {"schema": {"type": "string"}}, + "application/json": {"schema": {"$ref": "#/components/schemas/BaseResponse"}} }, "links": {"dummy": {"description": "dummy link"}}, } diff --git a/tests/test_restapi.py b/tests/test_restapi.py index 24e02ece..55088a2f 100644 --- a/tests/test_restapi.py +++ b/tests/test_restapi.py @@ -10,7 +10,7 @@ from flask import Response from pydantic import BaseModel, Field, RootModel -from flask_openapi3 import ExternalDocumentation, Info, OpenAPI, Tag +from flask_openapi3 import ExternalDocumentation, Info, OpenAPI, Server, Tag info = Info(title="book API", version="1.0.0") @@ -43,6 +43,8 @@ def get_operation_id_for_path_callback(*, name: str, path: str, method: str) -> class BookQuery(BaseModel): age: int | None = Field(None, description="Age") + author: str + none: None = None class BookBody(BaseModel): @@ -97,8 +99,10 @@ 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, + deprecated=True, ) def get_book(path: BookPath): """Get a book @@ -110,7 +114,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 731de4ce..c1b4dacd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3,29 +3,33 @@ # @Time : 2024/11/10 12:17 from pydantic import ValidationError -from flask_openapi3 import Server, ServerVariable +from flask_openapi3 import ExternalDocumentation, OpenAPI, Server, ServerVariable def test_server_variable(): Server(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