From f4e4b1b0424fd78f63f93dd1451f561784795624 Mon Sep 17 00:00:00 2001 From: luolingchun Date: Thu, 1 Jun 2023 17:44:53 +0800 Subject: [PATCH 1/4] BaseModel Config support openapi_extra --- examples/openapi_extra.py | 81 ++++++++++++++++++++++++++++++++ examples/response_demo.py | 21 ++++++++- examples/rest_demo.py | 29 +----------- examples/upload_file_demo.py | 13 +----- flask_openapi3/scaffold.py | 44 ++++++++++++++++++ flask_openapi3/utils.py | 24 ++++++++++ flask_openapi3/view.py | 12 +++++ tests/test_openapi.py | 89 +++++++++++++----------------------- 8 files changed, 216 insertions(+), 97 deletions(-) create mode 100644 examples/openapi_extra.py diff --git a/examples/openapi_extra.py b/examples/openapi_extra.py new file mode 100644 index 00000000..98992084 --- /dev/null +++ b/examples/openapi_extra.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# @Author : llc +# @Time : 2023/6/1 15:04 +from typing import List + +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI, FileStorage + +app = OpenAPI(__name__) + + +class UploadFilesForm(BaseModel): + file: FileStorage + str_list: List[str] + + class Config: + openapi_extra = { + # "example": {"a": 123}, + "examples": { + "Example 01": { + "summary": "An example", + "value": { + "file": "Example-01.jpg", + "str_list": ["a", "b", "c"] + } + }, + "Example 02": { + "summary": "Another example", + "value": { + "str_list": ["1", "2", "3"] + } + } + } + } + + +class BookBody(BaseModel): + age: int + author: str + + class Config: + openapi_extra = { + "description": "This is post RequestBody", + "example": {"age": 12, "author": "author1"}, + "examples": { + "example1": { + "summary": "example summary1", + "description": "example description1", + "value": { + "age": 24, + "author": "author2" + } + }, + "example2": { + "summary": "example summary2", + "description": "example description2", + "value": { + "age": 48, + "author": "author3" + } + } + + }} + + +@app.post('/upload/files') +def upload_files(form: UploadFilesForm): + print(form.file) + print(form.str_list) + return {"code": 0, "message": "ok"} + + +@app.post('/book', ) +def create_book(body: BookBody): + print(body) + return {"code": 0, "message": "ok"} + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/examples/response_demo.py b/examples/response_demo.py index 04de6f89..2fb771ab 100644 --- a/examples/response_demo.py +++ b/examples/response_demo.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# @Author : [martinatseequent](https://github.com/martinatseequent) +# @Author : llc # @Time : 2021/6/22 9:32 import json @@ -23,6 +23,25 @@ class HelloPath(BaseModel): class Message(BaseModel): message: str = Field(..., description="The message") + class Config: + openapi_extra = { + # "example": {"message": "aaa"}, + "examples": { + "example1": { + "summary": "example1 summary", + "value": { + "message": "bbb" + } + }, + "example2": { + "summary": "example2 summary", + "value": { + "message": "ccc" + } + } + } + } + @bp.get("/hello/", responses={"200": Message}) def hello(path: HelloPath): diff --git a/examples/rest_demo.py b/examples/rest_demo.py index 48673690..86d96ff5 100644 --- a/examples/rest_demo.py +++ b/examples/rest_demo.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field -from flask_openapi3 import ExternalDocumentation, ExtraRequestBody, Example +from flask_openapi3 import ExternalDocumentation from flask_openapi3 import Info, Tag, Server from flask_openapi3 import OpenAPI @@ -128,32 +128,7 @@ def get_books(query: BookQuery): } -extra_body = ExtraRequestBody( - description="This is post RequestBody", - required=True, - example="ttt", - examples={ - "example1": Example( - summary="example summary1", - description="example description1", - value={ - "age": 24, - "author": "author1" - } - ), - "example2": Example( - summary="example summary2", - description="example description2", - value={ - "age": 48, - "author": "author2" - } - ) - } -) - - -@app.post('/book', tags=[book_tag], responses={"200": BookResponse}, extra_body=extra_body) +@app.post('/book', tags=[book_tag], responses={"200": BookResponse}) def create_book(body: BookBody): print(body) return {"code": 0, "message": "ok"}, HTTPStatus.OK diff --git a/examples/upload_file_demo.py b/examples/upload_file_demo.py index 3c04f8b4..c4ab2d63 100644 --- a/examples/upload_file_demo.py +++ b/examples/upload_file_demo.py @@ -6,8 +6,7 @@ from pydantic import BaseModel, Field -from flask_openapi3 import OpenAPI, FileStorage, ExtraRequestBody -from flask_openapi3 import Encoding +from flask_openapi3 import OpenAPI, FileStorage app = OpenAPI(__name__) @@ -23,14 +22,6 @@ class UploadFilesForm(BaseModel): int_list: List[int] -extra_form = ExtraRequestBody( - description="This is form RequestBody", - required=True, - # replace style (default to form) - encoding={"str_list": Encoding(style="simple")} -) - - @app.post('/upload/file') def upload_file(form: UploadFileForm): print(form.file.filename) @@ -39,7 +30,7 @@ def upload_file(form: UploadFileForm): return {"code": 0, "message": "ok"} -@app.post('/upload/files', extra_form=extra_form) +@app.post('/upload/files') def upload_files(form: UploadFilesForm): print(form.files) print(form.str_list) diff --git a/flask_openapi3/scaffold.py b/flask_openapi3/scaffold.py index 1177e06e..526907bd 100644 --- a/flask_openapi3/scaffold.py +++ b/flask_openapi3/scaffold.py @@ -4,6 +4,7 @@ import functools import inspect import sys +import warnings from abc import ABC from functools import wraps from typing import Callable, List, Optional, Dict, Type, Any, Tuple @@ -31,6 +32,8 @@ def iscoroutinefunction(func: Any) -> bool: return inspect.iscoroutinefunction(func) +warnings.simplefilter("once") + class APIScaffold(Scaffold, ABC): def _do_decorator( @@ -174,6 +177,15 @@ def get( doc_ui: Add openapi document UI(swagger, rapidoc and redoc). Defaults to True. """ + if extra_form is not None: + warnings.warn( + """`extra_form` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) + if extra_body is not None: + warnings.warn( + """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) + def decorator(func) -> Callable: header, cookie, path, query, form, body = \ self._do_decorator( @@ -245,6 +257,14 @@ def post( openapi_extensions: Allows extensions to the OpenAPI Schema. doc_ui: Declares this operation to be shown. """ + if extra_form is not None: + warnings.warn( + """`extra_form` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) + if extra_body is not None: + warnings.warn( + """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ @@ -317,6 +337,14 @@ def put( openapi_extensions: Allows extensions to the OpenAPI Schema. doc_ui: Declares this operation to be shown. """ + if extra_form is not None: + warnings.warn( + """`extra_form` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) + if extra_body is not None: + warnings.warn( + """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ @@ -389,6 +417,14 @@ def delete( openapi_extensions: Allows extensions to the OpenAPI Schema. doc_ui: Declares this operation to be shown. """ + if extra_form is not None: + warnings.warn( + """`extra_form` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) + if extra_body is not None: + warnings.warn( + """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ @@ -461,6 +497,14 @@ def patch( openapi_extensions: Allows extensions to the OpenAPI Schema. doc_ui: Declares this operation to be shown. """ + if extra_form is not None: + warnings.warn( + """`extra_form` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) + if extra_body is not None: + warnings.warn( + """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index 9a3faf05..80a593db 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -298,6 +298,17 @@ def get_responses( ) } ) + + model_config = response.Config + if hasattr(model_config, "openapi_extra"): + _responses[key].description = model_config.openapi_extra.get("description") + _responses[key].headers = model_config.openapi_extra.get("headers") + _responses[key].links = model_config.openapi_extra.get("links") + _content = _responses[key].content + _content["application/json"].example = model_config.openapi_extra.get("example") # type: ignore + _content["application/json"].examples = model_config.openapi_extra.get("examples") # type: ignore + _content["application/json"].encoding = model_config.openapi_extra.get("encoding") # type: ignore + _schemas[response.__name__] = Schema(**schema) definitions = schema.get("definitions") if definitions: @@ -407,6 +418,13 @@ def parse_parameters( request_body = RequestBody(**{ "content": _content, }) + model_config = form.Config + if hasattr(model_config, "openapi_extra"): + request_body.description = model_config.openapi_extra.get("description") + request_body.content["multipart/form-data"].example = model_config.openapi_extra.get("example") + request_body.content["multipart/form-data"].examples = model_config.openapi_extra.get("examples") + if model_config.openapi_extra.get("encoding"): + request_body.content["multipart/form-data"].encoding = model_config.openapi_extra.get("encoding") operation.requestBody = request_body if body: _content, _components_schemas = parse_body(body, extra_body) @@ -419,6 +437,12 @@ def parse_parameters( ) else: request_body = RequestBody(content=_content) + model_config = body.Config + if hasattr(model_config, "openapi_extra"): + request_body.description = model_config.openapi_extra.get("description") + request_body.content["application/json"].example = model_config.openapi_extra.get("example") + request_body.content["application/json"].examples = model_config.openapi_extra.get("examples") + request_body.content["application/json"].encoding = model_config.openapi_extra.get("encoding") operation.requestBody = request_body operation.parameters = parameters if parameters else None diff --git a/flask_openapi3/view.py b/flask_openapi3/view.py index 496d828e..3c468351 100644 --- a/flask_openapi3/view.py +++ b/flask_openapi3/view.py @@ -3,6 +3,7 @@ # @Time : 2022/10/14 16:09 import re import typing +import warnings if typing.TYPE_CHECKING: from .openapi import OpenAPI @@ -19,6 +20,8 @@ from .utils import get_operation, parse_and_store_tags, parse_parameters, get_responses, parse_method, \ get_operation_id_for_path +warnings.simplefilter("once") + class APIView: def __init__( @@ -135,6 +138,15 @@ def doc( doc_ui: Add openapi document UI(swagger, rapidoc and redoc). Defaults to True. """ + if extra_form is not None: + warnings.warn( + """`extra_form` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) + if extra_body is not None: + warnings.warn( + """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", + DeprecationWarning) + if responses is None: responses = {} if extra_responses is None: diff --git a/tests/test_openapi.py b/tests/test_openapi.py index c2453537..fa62e113 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -2,8 +2,7 @@ from pydantic import BaseModel -from flask_openapi3 import Example -from flask_openapi3 import OpenAPI, ExtraRequestBody +from flask_openapi3 import OpenAPI def test_responses_and_extra_responses_are_replicated_in_open_api(request): @@ -237,26 +236,28 @@ def test_body_examples_are_replicated_in_open_api(request): test_app = OpenAPI(request.node.name) test_app.config["TESTING"] = True - @test_app.post( - "/test", - extra_body=ExtraRequestBody( - examples={ - "Example 01": Example( - summary="An example", - value={ + class Config: + openapi_extra = { + "examples": { + "Example 01": { + "summary": "An example", + "value": { "test_int": -1, "test_str": "negative", } - ), - "Example 02": Example( - externalValue="https://example.org/examples/second-example.xml" - ), - "Example 03": Example(**{ + }, + "Example 02": { + "externalValue": "https://example.org/examples/second-example.xml" + }, + "Example 03": { "$ref": "#/components/examples/third-example" - }) + } } - ) - ) + } + + BaseRequest.Config = Config + + @test_app.post("/test") def endpoint_test(body: BaseRequest): return body.json(), 200 @@ -278,54 +279,26 @@ def endpoint_test(body: BaseRequest): } -def test_body_examples_are_not_replicated_with_form(request): +def test_form_examples(request): test_app = OpenAPI(request.node.name) test_app.config["TESTING"] = True - @test_app.post( - "/test", - extra_body=ExtraRequestBody(example={ - "Example 01": Example(**{ - "summary": "An example", - "value": { - "test_int": -1, - "test_str": "negative", - } - }), - }) - ) - def endpoint_test(form: BaseRequest): - return form.json(), 200 - - with test_app.test_client() as client: - resp = client.get("/openapi/openapi.json") - assert resp.status_code == 200 - assert resp.json["paths"]["/test"]["post"]["requestBody"] == { - "content": { - "multipart/form-data": { - "schema": {"$ref": "#/components/schemas/BaseRequest"} + class Config: + openapi_extra = { + "examples": { + "Example 01": { + "summary": "An example", + "value": { + "test_int": -1, + "test_str": "negative", + } } - }, - "required": True + } } + BaseRequest.Config = Config -def test_form_examples(request): - test_app = OpenAPI(request.node.name) - test_app.config["TESTING"] = True - - @test_app.post( - "/test", - extra_form=ExtraRequestBody(examples={ - "Example 01": Example(**{ - "summary": "An example", - "value": { - "test_int": -1, - "test_str": "negative", - } - }), - }) - ) + @test_app.post("/test") def endpoint_test(form: BaseRequest): return form.json(), 200 From 7d41436aa16b1e0d183bde0c0b4c6b4a3bb4d3e5 Mon Sep 17 00:00:00 2001 From: luolingchun Date: Fri, 2 Jun 2023 10:21:14 +0800 Subject: [PATCH 2/4] Merge `extra_responses` to `responses` and deprecate `extra_responses` --- examples/api_blueprint_demo.py | 2 +- examples/response_demo.py | 5 +-- examples/rest_demo.py | 2 -- flask_openapi3/blueprint.py | 10 +++--- flask_openapi3/openapi.py | 8 ++--- flask_openapi3/scaffold.py | 44 +++++++++++++++++++-------- flask_openapi3/utils.py | 25 +++++++-------- flask_openapi3/view.py | 14 ++++++--- tests/test_openapi.py | 28 ++++++++--------- tests/test_restapi.py | 1 - tests/test_restapi_with_doc_prefix.py | 8 ++++- 11 files changed, 86 insertions(+), 61 deletions(-) diff --git a/examples/api_blueprint_demo.py b/examples/api_blueprint_demo.py index 378dcfa9..8d837e71 100644 --- a/examples/api_blueprint_demo.py +++ b/examples/api_blueprint_demo.py @@ -55,7 +55,7 @@ def get_book(): return {"code": 0, "message": "ok"} -@api.post('/book', extra_responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}) +@api.post('/book', responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}) def create_book(body: BookBody): assert body.age == 3 return {"code": 0, "message": "ok"} diff --git a/examples/response_demo.py b/examples/response_demo.py index 2fb771ab..4ef40838 100644 --- a/examples/response_demo.py +++ b/examples/response_demo.py @@ -11,7 +11,7 @@ from flask_openapi3 import Info from flask_openapi3 import OpenAPI, APIBlueprint -app = OpenAPI(__name__, info=Info(title="Hello API", version="1.0.0"), ) +app = OpenAPI(__name__, info=Info(title="Hello API", version="1.0.0")) bp = APIBlueprint("Hello BP", __name__) @@ -43,7 +43,8 @@ class Config: } -@bp.get("/hello/", responses={"200": Message}) +@bp.get("/hello/", + responses={"200": Message, "201": {"content": {"text/csv": {"schema": {"type": "string"}}}}}) def hello(path: HelloPath): message = {"message": f"""Hello {path.name}!"""} diff --git a/examples/rest_demo.py b/examples/rest_demo.py index 86d96ff5..905f4eba 100644 --- a/examples/rest_demo.py +++ b/examples/rest_demo.py @@ -95,9 +95,7 @@ class BookResponse(BaseModel): external_docs=ExternalDocumentation( url="https://www.openapis.org/", description="Something great got better, get excited!"), - responses={"200": BookResponse}, - extra_responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}, security=security, servers=[Server(url="https://www.openapis.org/", description="openapi")] ) diff --git a/flask_openapi3/blueprint.py b/flask_openapi3/blueprint.py index 759060d1..9537deb9 100644 --- a/flask_openapi3/blueprint.py +++ b/flask_openapi3/blueprint.py @@ -3,7 +3,7 @@ # @Time : 2022/4/1 16:54 import re from copy import deepcopy -from typing import Optional, List, Dict, Any, Type, Callable, Tuple +from typing import Optional, List, Dict, Any, Type, Callable, Tuple, Union from flask import Blueprint from pydantic import BaseModel @@ -25,7 +25,7 @@ def __init__( *, abp_tags: Optional[List[Tag]] = None, abp_security: Optional[List[Dict[str, List[str]]]] = None, - abp_responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + abp_responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, doc_ui: bool = True, operation_id_callback: Callable = get_operation_id_for_path, **kwargs: Any @@ -39,7 +39,7 @@ def __init__( ``__name__``. This helps locate the ``root_path`` for the blueprint. abp_tags: APIBlueprint tags for every api abp_security: APIBlueprint security for every api - abp_responses: APIBlueprint response model + abp_responses: API responses, should be BaseModel, dict or None. doc_ui: Add openapi document UI(swagger, rapidoc and redoc). Defaults to True. operation_id_callback: Callback function for custom operation_id generation. Receives name (str), path (str) and method (str) parameters. @@ -92,7 +92,7 @@ def _do_decorator( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, Dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -113,7 +113,7 @@ def _do_decorator( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: response's model must be pydantic BaseModel. + responses: API responses, should be BaseModel, dict or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. diff --git a/flask_openapi3/openapi.py b/flask_openapi3/openapi.py index 752d70ec..e0536b6d 100644 --- a/flask_openapi3/openapi.py +++ b/flask_openapi3/openapi.py @@ -32,7 +32,7 @@ def __init__( info: Optional[Info] = None, security_schemes: Optional[Dict[str, Union[SecurityScheme, Dict[str, Any]]]] = None, oauth_config: Optional[OAuthConfig] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, doc_ui: bool = True, doc_expansion: str = "list", doc_prefix: str = "/openapi", @@ -56,7 +56,7 @@ def __init__( security_schemes: See https://spec.openapis.org/oas/v3.0.3#security-scheme-object oauth_config: OAuth 2.0 configuration, see https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/oauth2.md - responses: OpenAPI response model + responses: API responses, should be BaseModel, dict or None. doc_ui: Add openapi document UI(swagger and redoc). Defaults to True. doc_expansion: String=["list"*, "full", "none"]. Controls the default expansion setting for the operations and tags. @@ -220,7 +220,7 @@ def _do_decorator( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, Dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -241,7 +241,7 @@ def _do_decorator( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: response's model must be pydantic BaseModel. + responses: API responses, should be BaseModel, dict or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. diff --git a/flask_openapi3/scaffold.py b/flask_openapi3/scaffold.py index 526907bd..e5e07356 100644 --- a/flask_openapi3/scaffold.py +++ b/flask_openapi3/scaffold.py @@ -7,7 +7,7 @@ import warnings from abc import ABC from functools import wraps -from typing import Callable, List, Optional, Dict, Type, Any, Tuple +from typing import Callable, List, Optional, Dict, Type, Any, Tuple, Union from flask.scaffold import Scaffold from flask.wrappers import Response @@ -48,7 +48,7 @@ def _do_decorator( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -146,7 +146,7 @@ def get( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -168,7 +168,7 @@ def get( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: response's model must be pydantic BaseModel. + responses: API responses, should be BaseModel, dict or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -185,6 +185,10 @@ def get( warnings.warn( """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", DeprecationWarning) + if extra_responses is not None: + warnings.warn( + """`extra_responses` will be deprecated in v3.x, please use `responses` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ @@ -227,7 +231,7 @@ def post( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -249,7 +253,7 @@ def post( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: response's model must be pydantic BaseModel. + responses: API responses, should be BaseModel, dict or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -265,6 +269,10 @@ def post( warnings.warn( """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", DeprecationWarning) + if extra_responses is not None: + warnings.warn( + """`extra_responses` will be deprecated in v3.x, please use `responses` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ @@ -307,7 +315,7 @@ def put( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -329,7 +337,7 @@ def put( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: response's model must be pydantic BaseModel. + responses: API responses, should be BaseModel, dict or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -345,6 +353,10 @@ def put( warnings.warn( """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", DeprecationWarning) + if extra_responses is not None: + warnings.warn( + """`extra_responses` will be deprecated in v3.x, please use `responses` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ @@ -387,7 +399,7 @@ def delete( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -409,7 +421,7 @@ def delete( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: response's model must be pydantic BaseModel. + responses: API responses, should be BaseModel, dict or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -425,6 +437,10 @@ def delete( warnings.warn( """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", DeprecationWarning) + if extra_responses is not None: + warnings.warn( + """`extra_responses` will be deprecated in v3.x, please use `responses` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ @@ -467,7 +483,7 @@ def patch( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -489,7 +505,7 @@ def patch( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: response's model must be pydantic BaseModel. + responses: API responses, should be BaseModel, dict or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -505,6 +521,10 @@ def patch( warnings.warn( """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", DeprecationWarning) + if extra_responses is not None: + warnings.warn( + """`extra_responses` will be deprecated in v3.x, please use `responses` instead.""", + DeprecationWarning) def decorator(func) -> Callable: header, cookie, path, query, form, body = \ diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index 80a593db..929b0e6c 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -4,7 +4,7 @@ import inspect import re -from typing import get_type_hints, Dict, Type, Callable, List, Tuple, Optional, Any +from typing import get_type_hints, Dict, Type, Callable, List, Tuple, Optional, Any, Union from pydantic import BaseModel @@ -248,13 +248,13 @@ def parse_body( def get_responses( - responses: dict, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]], extra_responses: Dict[str, dict], components_schemas: dict, operation: Operation ) -> None: """ - :param responses: Dict[str, BaseModel] + :param responses: API responses, should be BaseModel, dict or None. :param extra_responses: Dict[str, dict] :param components_schemas: `models.component.py` `Components.schemas` :param operation: `models.path.py` Operation @@ -281,8 +281,13 @@ def get_responses( ) _schemas[UnprocessableEntity.__name__] = Schema(**UnprocessableEntity.schema()) for key, response in responses.items(): - # Verify that the response is a class and that class is a subclass of `pydantic.BaseModel` - if inspect.isclass(response) and issubclass(response, BaseModel): + if response is None: + # Verify that if the response is None, because http status code "204" means return "No Content" + _responses[key] = Response(description=HTTP_STATUS.get(key, "")) + continue + if isinstance(response, dict): + _responses[key] = response # type: ignore + else: schema = response.schema(ref_template=OPENAPI3_REF_TEMPLATE) _responses[key] = Response( description=HTTP_STATUS.get(key, ""), @@ -308,19 +313,15 @@ def get_responses( _content["application/json"].example = model_config.openapi_extra.get("example") # type: ignore _content["application/json"].examples = model_config.openapi_extra.get("examples") # type: ignore _content["application/json"].encoding = model_config.openapi_extra.get("encoding") # type: ignore + if model_config.openapi_extra.get("content"): + _responses[key].content.update(model_config.openapi_extra.get("content")) _schemas[response.__name__] = Schema(**schema) definitions = schema.get("definitions") if definitions: for name, value in definitions.items(): _schemas[name] = Schema(**value) - # Verify that if the response is None, because http status code "204" means return "No Content" - elif response is None: - _responses[key] = Response( - description=HTTP_STATUS.get(key, ""), - ) - else: - raise TypeError(f"{response} is invalid `pydantic.BaseModel`.") + # handle extra_responses for key, value in extra_responses.items(): # key "200" value {"content":{"text/csv":{"schema":{"type": "string"}}}} diff --git a/flask_openapi3/view.py b/flask_openapi3/view.py index 3c468351..ed309943 100644 --- a/flask_openapi3/view.py +++ b/flask_openapi3/view.py @@ -9,7 +9,7 @@ from .openapi import OpenAPI from copy import deepcopy -from typing import Optional, List, Dict, Type, Any, Callable +from typing import Optional, List, Dict, Type, Any, Callable, Union from pydantic import BaseModel @@ -29,7 +29,7 @@ def __init__( url_prefix: Optional[str] = None, view_tags: Optional[List[Tag]] = None, view_security: Optional[List[Dict[str, List[str]]]] = None, - view_responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + view_responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, doc_ui: bool = True, operation_id_callback: Callable = get_operation_id_for_path, ): @@ -40,7 +40,7 @@ def __init__( url_prefix: A path to prepend to all the APIView's urls view_tags: APIView tags for every api view_security: APIView security for every api - view_responses: APIView response models + view_responses: API responses, should be BaseModel, dict or None. doc_ui: Add openapi document UI(swagger, rapidoc and redoc). Defaults to True. operation_id_callback: Callback function for custom operation_id generation. Receives name (str), path (str) and method (str) parameters. @@ -109,7 +109,7 @@ def doc( operation_id: Optional[str] = None, extra_form: Optional[ExtraRequestBody] = None, extra_body: Optional[ExtraRequestBody] = None, - responses: Optional[Dict[str, Optional[Type[BaseModel]]]] = None, + responses: Optional[Dict[str, Union[Type[BaseModel], Dict[Any, Any], None]]] = None, extra_responses: Optional[Dict[str, dict]] = None, deprecated: Optional[bool] = None, security: Optional[List[Dict[str, List[Any]]]] = None, @@ -129,7 +129,7 @@ def doc( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: response's model must be pydantic BaseModel. + responses: API responses, should be BaseModel, dict or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -146,6 +146,10 @@ def doc( warnings.warn( """`extra_body` will be deprecated in v3.x, please use `openapi_extra` instead.""", DeprecationWarning) + if extra_responses is not None: + warnings.warn( + """`extra_responses` will be deprecated in v3.x, please use `responses` instead.""", + DeprecationWarning) if responses is None: responses = {} diff --git a/tests/test_openapi.py b/tests/test_openapi.py index fa62e113..1d711455 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -5,7 +5,7 @@ from flask_openapi3 import OpenAPI -def test_responses_and_extra_responses_are_replicated_in_open_api(request): +def test_responses_are_replicated_in_open_api(request): test_app = OpenAPI(request.node.name) test_app.config["TESTING"] = True @@ -13,11 +13,8 @@ class BaseResponse(BaseModel): """Base description""" test: int - @test_app.get( - "/test", - responses={"201": BaseResponse}, - extra_responses={ - "201": { + class Config: + openapi_extra = { "description": "Custom description", "headers": { "location": { @@ -36,8 +33,8 @@ class BaseResponse(BaseModel): } } } - } - ) + + @test_app.get("/test", responses={"201": BaseResponse}) def endpoint_test(): return b'', 201 @@ -57,7 +54,7 @@ def endpoint_test(): "application/json": { "schema": {"$ref": "#/components/schemas/BaseResponse"} }, - # While this one comes from extra_responses + # While this one comes from responses "text/plain": { "schema": {"type": "string"} } @@ -70,14 +67,13 @@ def endpoint_test(): } -def test_none_responses_and_extra_responses_are_replicated_in_open_api(request): +def test_none_responses_are_replicated_in_open_api(request): test_app = OpenAPI(request.node.name) test_app.config["TESTING"] = True @test_app.get( "/test", - responses={"204": None}, - extra_responses={ + responses={ "204": { "description": "Custom description", "headers": { @@ -126,13 +122,13 @@ def endpoint_test(): } -def test_extra_responses_are_replicated_in_open_api(request): +def test_responses_are_replicated_in_open_api2(request): test_app = OpenAPI(request.node.name) test_app.config["TESTING"] = True @test_app.get( "/test", - extra_responses={ + responses={ "201": { "description": "Custom description", "headers": { @@ -181,13 +177,13 @@ def endpoint_test(): } -def test_extra_responses_without_content_are_replicated_in_open_api(request): +def test_responses_without_content_are_replicated_in_open_api(request): test_app = OpenAPI(request.node.name) test_app.config["TESTING"] = True @test_app.get( "/test", - extra_responses={ + responses={ "201": { "description": "Custom description", "headers": { diff --git a/tests/test_restapi.py b/tests/test_restapi.py index 3f4280fb..fa7132b5 100644 --- a/tests/test_restapi.py +++ b/tests/test_restapi.py @@ -106,7 +106,6 @@ def client(): url="https://www.openapis.org/", description="Something great got better, get excited!"), responses={"200": BookResponse}, - extra_responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}, security=security ) def get_book(path: BookPath): diff --git a/tests/test_restapi_with_doc_prefix.py b/tests/test_restapi_with_doc_prefix.py index d356ad51..8806f149 100644 --- a/tests/test_restapi_with_doc_prefix.py +++ b/tests/test_restapi_with_doc_prefix.py @@ -70,6 +70,13 @@ class BookResponse(BaseModel): message: str = Field("ok", description="Exception Information") data: BookBodyWithID + class Config: + openapi_extra = { + "content": { + "text/csv": {"schema": {"type": "string"}} + } + } + @pytest.fixture def client(): @@ -82,7 +89,6 @@ def client(): '/book/', tags=[book_tag], responses={"200": BookResponse}, - extra_responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}, security=security ) def get_book(path: BookPath): From b2910e42ef5855a707b312d57da143956b761f8d Mon Sep 17 00:00:00 2001 From: luolingchun Date: Fri, 2 Jun 2023 11:22:58 +0800 Subject: [PATCH 3/4] Update doc for Openapi Extra --- .github/pull_request_template.md | 2 +- docs/Example.md | 6 +- docs/Tutorial/Openapi_extra.md | 105 +++++++++++++++++++ docs/Tutorial/Operation.md | 12 +++ docs/Tutorial/Response.md | 19 +++- docs/Tutorial/Specification.md | 8 ++ docs/assets/Snipaste_2023-06-02_11-05-11.png | Bin 0 -> 49234 bytes docs/assets/Snipaste_2023-06-02_11-06-59.png | Bin 0 -> 29535 bytes docs/assets/Snipaste_2023-06-02_11-08-40.png | Bin 0 -> 32120 bytes flask_openapi3/models/common.py | 7 ++ flask_openapi3/utils.py | 2 +- mkdocs.yml | 1 + 12 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 docs/Tutorial/Openapi_extra.md create mode 100644 docs/assets/Snipaste_2023-06-02_11-05-11.png create mode 100644 docs/assets/Snipaste_2023-06-02_11-06-59.png create mode 100644 docs/assets/Snipaste_2023-06-02_11-08-40.png diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7e288c54..5599b01e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,5 +2,5 @@ Checklist: - [ ] Run `pytest tests` and no failed. - [ ] Run `flake8 flask_openapi3 tests examples` and no failed. -- [ ] Run `mkdocs serve` and no failed. - [ ] Run `mypy flask_openapi3` and no failed. +- [ ] Run `mkdocs serve` and no failed. diff --git a/docs/Example.md b/docs/Example.md index 23a8d24c..237de032 100644 --- a/docs/Example.md +++ b/docs/Example.md @@ -127,8 +127,7 @@ class BookResponse(BaseModel): tags=[book_tag], summary='new summary', description='new description', - responses={"200": BookResponse}, - extra_responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}, + responses={"200": BookResponse, "201": {"content": {"text/csv": {"schema": {"type": "string"}}}}}, security=security ) def get_book(path: BookPath): @@ -189,7 +188,6 @@ from typing import Optional from pydantic import BaseModel, Field from flask_openapi3 import APIBlueprint, OpenAPI -from flask_openapi3 import HTTPBearer from flask_openapi3 import Tag, Info info = Info(title='book API', version='1.0.0') @@ -238,7 +236,7 @@ def get_book(): return {"code": 0, "message": "ok"} -@api.post('/book', extra_responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}) +@api.post('/book', responses={"201": {"content": {"text/csv": {"schema": {"type": "string"}}}}}) def create_book(body: BookBody): assert body.age == 3 return {"code": 0, "message": "ok"} diff --git a/docs/Tutorial/Openapi_extra.md b/docs/Tutorial/Openapi_extra.md new file mode 100644 index 00000000..b8a98a47 --- /dev/null +++ b/docs/Tutorial/Openapi_extra.md @@ -0,0 +1,105 @@ +*New in v2.4.0* + +The [BaseModel](https://docs.pydantic.dev/latest/usage/models/) in [Pydantic](https://github.com/pydantic/pydantic) +supports some custom configurations([Model Config](https://docs.pydantic.dev/latest/usage/model_config/)), +so we can use the `openapi_extra` to extend OpenAPI Specification. + +The `openapi_extra` will be merged with the automatically generated OpenAPI schema. + +## form + +```python +class UploadFilesForm(BaseModel): + file: FileStorage + str_list: List[str] + + class Config: + openapi_extra = { + # "example": {"a": 123}, + "examples": { + "Example 01": { + "summary": "An example", + "value": { + "file": "Example-01.jpg", + "str_list": ["a", "b", "c"] + } + }, + "Example 02": { + "summary": "Another example", + "value": { + "str_list": ["1", "2", "3"] + } + } + } + } +``` + +Effect in Redoc: + +![](../assets/Snipaste_2023-06-02_11-05-11.png) + +## body + +```python +class BookBody(BaseModel): + age: int + author: str + + class Config: + openapi_extra = { + "description": "This is post RequestBody", + "example": {"age": 12, "author": "author1"}, + "examples": { + "example1": { + "summary": "example summary1", + "description": "example description1", + "value": { + "age": 24, + "author": "author2" + } + }, + "example2": { + "summary": "example summary2", + "description": "example description2", + "value": { + "age": 48, + "author": "author3" + } + } + + }} +``` + +Effect in swagger: + +![](../assets/Snipaste_2023-06-02_11-06-59.png) + +## responses + +```python +class Message(BaseModel): + message: str = Field(..., description="The message") + + class Config: + openapi_extra = { + # "example": {"message": "aaa"}, + "examples": { + "example1": { + "summary": "example1 summary", + "value": { + "message": "bbb" + } + }, + "example2": { + "summary": "example2 summary", + "value": { + "message": "ccc" + } + } + } + } +``` + +Effect in swagger: + +![](../assets/Snipaste_2023-06-02_11-08-40.png) \ No newline at end of file diff --git a/docs/Tutorial/Operation.md b/docs/Tutorial/Operation.md index 421af14d..f8c4b113 100644 --- a/docs/Tutorial/Operation.md +++ b/docs/Tutorial/Operation.md @@ -127,6 +127,12 @@ def create_book(body: BookBody): ## extra_form +*new in v2.4.0* + +!!! Deprecated-Warning warning + + `extra_form` will be deprecated in v3.x, please use `openapi_extra` instead. + *new in v2.1.0* Extra form information can be provided using `extra_form` as in the following sample: @@ -148,6 +154,12 @@ def create_book(body: BookForm): ## extra_body +*new in v2.4.0* + +!!! Deprecated-Warning warning + + `extra_body` will be deprecated in v3.x, please use `openapi_extra` instead. + *new in v2.1.0* Extra body information can be provided using `extra_body` as in the following sample: diff --git a/docs/Tutorial/Response.md b/docs/Tutorial/Response.md index 0849a012..5fc0b9f9 100644 --- a/docs/Tutorial/Response.md +++ b/docs/Tutorial/Response.md @@ -15,7 +15,13 @@ class BookResponse(BaseModel): data: BookBodyWithID -@app.get('/book/', tags=[book_tag], responses={"200": BookResponse}) +@app.get('/book/', + tags=[book_tag], + responses={ + "200": BookResponse, + # Version 2.4.0 starts supporting response for dictionary types + "201": {"content": {"text/csv": {"schema": {"type": "string"}}}} + }) def get_book(path: BookPath, query: BookBody): """get a book get book by id, age or author @@ -26,9 +32,14 @@ def get_book(path: BookPath, query: BookBody): ![image-20210526104627124](../assets/image-20210526104627124.png) - ## extra_responses +*New in v2.4.0* + +!!! Deprecated-Warning warning + + `extra_responses` have been merged into the `responses`, and `extra_responses` will be deprecated in v3.x. + *New in v1.0.0* You can pass to your path operation decorators a parameter `extra_responses`. @@ -43,14 +54,14 @@ Like this: '/book/', tags=[book_tag], responses={"200": BookResponse}, - extra_responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}, + extra_responses={"201": {"content": {"text/csv": {"schema": {"type": "string"}}}}}, security=security ) def get_book(path: BookPath): ... -@api.post('/book', extra_responses={"200": {"content": {"text/csv": {"schema": {"type": "string"}}}}}) +@api.post('/book', extra_responses={"201": {"content": {"text/csv": {"schema": {"type": "string"}}}}}) def create_book(body: BookBody): ... ``` diff --git a/docs/Tutorial/Specification.md b/docs/Tutorial/Specification.md index 65457fdd..80de9525 100644 --- a/docs/Tutorial/Specification.md +++ b/docs/Tutorial/Specification.md @@ -252,6 +252,14 @@ def endpoint(): You can also use [responses ](./Response.md#responses) and [extra_responses](./Response.md#extra_responses) in your api. +*New in v2.4.0* + +!!! Deprecated-Warning warning + + `extra_responses` have been merged into the `responses`, and `extra_responses` will be deprecated in v3.x. + + + ## doc_ui You can pass `doc_ui=False` to disable the `OpenAPI spec` when init `OpenAPI `. diff --git a/docs/assets/Snipaste_2023-06-02_11-05-11.png b/docs/assets/Snipaste_2023-06-02_11-05-11.png new file mode 100644 index 0000000000000000000000000000000000000000..a171bcc176cbcfb286eb9d517ef9e01c7b3171f5 GIT binary patch literal 49234 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 J|LXPoe*$qu&FlaG literal 0 HcmV?d00001 diff --git a/docs/assets/Snipaste_2023-06-02_11-08-40.png b/docs/assets/Snipaste_2023-06-02_11-08-40.png new file mode 100644 index 0000000000000000000000000000000000000000..a6aa0aa233f496c261c3bff38ce74c3185206111 GIT binary patch literal 32120 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 D0&} Date: Fri, 2 Jun 2023 17:04:56 +0800 Subject: [PATCH 4/4] Improving Code Comments --- flask_openapi3/blueprint.py | 65 ++++++++----- flask_openapi3/commands.py | 24 +++-- flask_openapi3/openapi.py | 179 +++++++++++++++++++++++++----------- flask_openapi3/request.py | 104 +++++++++++---------- flask_openapi3/scaffold.py | 52 +++++++---- flask_openapi3/utils.py | 161 +++++++++++++++++++++++--------- flask_openapi3/view.py | 52 ++++++----- 7 files changed, 419 insertions(+), 218 deletions(-) diff --git a/flask_openapi3/blueprint.py b/flask_openapi3/blueprint.py index 9537deb9..42dfcccf 100644 --- a/flask_openapi3/blueprint.py +++ b/flask_openapi3/blueprint.py @@ -35,49 +35,63 @@ def __init__( Arguments: name: The name of the blueprint. It Will be prepared to each endpoint name. - import_name: The name of the blueprint package, usually - ``__name__``. This helps locate the ``root_path`` for the blueprint. - abp_tags: APIBlueprint tags for every api - abp_security: APIBlueprint security for every api - abp_responses: API responses, should be BaseModel, dict or None. - doc_ui: Add openapi document UI(swagger, rapidoc and redoc). Defaults to True. + import_name: The name of the blueprint package, usually ``__name__``. + This helps locate the ``root_path`` for the blueprint. + abp_tags: APIBlueprint tags for every API. + abp_security: APIBlueprint security for every API. + abp_responses: API responses should be either a subclass of BaseModel, a dictionary, or None. + doc_ui: Enable OpenAPI document UI (Swagger UI, Redoc and Rapidoc). Defaults to True. operation_id_callback: Callback function for custom operation_id generation. - Receives name (str), path (str) and method (str) parameters. - Defaults to `get_operation_id_for_path` from utils + Receives name (str), path (str) and method (str) parameters. + Defaults to `get_operation_id_for_path` from utils **kwargs: Flask Blueprint kwargs """ super(APIBlueprint, self).__init__(name, import_name, **kwargs) + + # Initialize instance variables self.paths: Dict = dict() self.components_schemas: Dict = dict() self.tags: List[Tag] = [] self.tag_names: List[str] = [] + # Set values from arguments or default values self.abp_tags = abp_tags or [] self.abp_security = abp_security or [] self.abp_responses = abp_responses or {} self.doc_ui = doc_ui + + # Set the operation ID callback function self.operation_id_callback: Callable = operation_id_callback def register_api(self, api: "APIBlueprint") -> None: """Register a nested APIBlueprint""" + + # Check if the APIBlueprint is being registered on itself if api is self: raise ValueError("Cannot register a api blueprint on itself") + # Merge tags from the nested APIBlueprint for tag in api.tags: if tag.name not in self.tag_names: self.tags.append(tag) + # Merge paths from the nested APIBlueprint for path_url, path_item in api.paths.items(): trail_slash = path_url.endswith("/") - # merge url_prefix and new api blueprint path url + + # Merge url_prefix and new API blueprint path url uri = self.url_prefix.rstrip("/") + "/" + path_url.lstrip("/") if self.url_prefix else path_url - # strip the right slash + + # Strip the right slash if not trail_slash: uri = uri.rstrip("/") + self.paths[uri] = path_item + # Merge component schemas from the nested APIBlueprint self.components_schemas.update(**api.components_schemas) + # Register the nested APIBlueprint as a blueprint self.register_blueprint(api) def _do_decorator( @@ -102,7 +116,8 @@ def _do_decorator( method: str = HTTPMethod.GET ) -> Tuple[Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel]]: """ - Collect openapi specification information + Collects OpenAPI specification information for Flask routes and view functions. + Arguments: rule: Flask route func: Flask view_func @@ -113,7 +128,7 @@ def _do_decorator( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: API responses, should be BaseModel, dict or None. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -126,34 +141,34 @@ def _do_decorator( responses = {} if extra_responses is None: extra_responses = {} - # global response combine api responses + # Global response: combine API responses combine_responses = deepcopy(self.abp_responses) combine_responses.update(**responses) - # create operation + # Create operation operation = get_operation( func, summary=summary, description=description, openapi_extensions=openapi_extensions ) - # set external docs + # Set external docs operation.externalDocs = external_docs # Unique string used to identify the operation. operation.operationId = operation_id or self.operation_id_callback( name=func.__name__, path=rule, method=method ) - # only set `deprecated` if True otherwise leave it as None + # Only set `deprecated` if True, otherwise leave it as None operation.deprecated = deprecated - # add security + # Add security if security is None: security = [] operation.security = security + self.abp_security or None - # add servers + # Add servers operation.servers = servers - # store tags + # Store tags tags = tags + self.abp_tags if tags else self.abp_tags parse_and_store_tags(tags, self.tags, self.tag_names, operation) - # parse parameters + # Parse parameters header, cookie, path, query, form, body = parse_parameters( func, extra_form=extra_form, @@ -161,19 +176,19 @@ def _do_decorator( components_schemas=self.components_schemas, operation=operation ) - # parse response + # Parse response get_responses(combine_responses, extra_responses, self.components_schemas, operation) - # /pet/ --> /pet/{petId} + # Convert route parameter format from /pet/ to /pet/{petId} uri = re.sub(r"<([^<:]+:)?", "{", rule).replace(">", "}") trail_slash = uri.endswith("/") - # merge url_prefix and uri + # Merge url_prefix and uri uri = self.url_prefix.rstrip("/") + "/" + uri.lstrip("/") if self.url_prefix else uri if not trail_slash: uri = uri.rstrip("/") - # parse method + # Parse method parse_method(uri, method, self.paths, operation) return header, cookie, path, query, form, body else: - # parse parameters + # Parse parameters header, cookie, path, query, form, body = parse_parameters(func, doc_ui=False) return header, cookie, path, query, form, body diff --git a/flask_openapi3/commands.py b/flask_openapi3/commands.py index d6eb6247..ad4b7dd8 100644 --- a/flask_openapi3/commands.py +++ b/flask_openapi3/commands.py @@ -10,28 +10,34 @@ from .markdown import openapi_to_markdown -@click.command(name='openapi') -@click.option('--output', '-o', type=click.Path(), help='The output file path.') -@click.option('--format', '-f', type=click.Choice(['json', 'yaml', 'markdown']), help='The output file format.') -@click.option('--indent', '-i', type=int, help='The indentation for JSON dumps.') -@click.option('--ensure_ascii', '-a', is_flag=True, help='Ensure ASCII characters or not. Defaults to False.') +@click.command(name="openapi") +@click.option("--output", "-o", type=click.Path(), help="The output file path.") +@click.option("--format", "-f", type=click.Choice(["json", "yaml", "markdown"]), help="The output file format.") +@click.option("--indent", "-i", type=int, help="The indentation for JSON dumps.") +@click.option("--ensure_ascii", "-a", is_flag=True, help="Ensure ASCII characters or not. Defaults to False.") @with_appcontext def openapi_command(output, format, indent, ensure_ascii): """Export the OpenAPI Specification to console or a file""" - if hasattr(current_app, 'api_doc'): + + # Check if the current app has an api_doc attribute + if hasattr(current_app, "api_doc"): obj = current_app.api_doc - if format == 'yaml': + + # Generate the OpenAPI Specification based on the specified format + if format == "yaml": try: import yaml # type: ignore except ImportError: raise ImportError("pyyaml must be installed.") openapi = yaml.safe_dump(obj, allow_unicode=True) - elif format == 'markdown': + elif format == "markdown": openapi = openapi_to_markdown(obj) else: openapi = json.dumps(obj, indent=indent, ensure_ascii=ensure_ascii) + + # Save the OpenAPI Specification to a file if the output path is provided if output: - with open(output, 'w', encoding='utf8') as f: + with open(output, "w", encoding="utf8") as f: f.write(openapi) click.echo(f"Saved to {output}.") else: diff --git a/flask_openapi3/openapi.py b/flask_openapi3/openapi.py index e0536b6d..25279ef2 100644 --- a/flask_openapi3/openapi.py +++ b/flask_openapi3/openapi.py @@ -48,62 +48,83 @@ def __init__( **kwargs: Any ) -> None: """ - Based on Flask. Provide REST api, swagger-ui and redoc. + OpenAPI class that provides REST API functionality along with Swagger UI and Redoc. Arguments: - import_name: Just flask import_name - info: See https://spec.openapis.org/oas/v3.0.3#info-object - security_schemes: See https://spec.openapis.org/oas/v3.0.3#security-scheme-object - oauth_config: OAuth 2.0 configuration, - see https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/oauth2.md - responses: API responses, should be BaseModel, dict or None. - doc_ui: Add openapi document UI(swagger and redoc). Defaults to True. - doc_expansion: String=["list"*, "full", "none"]. - Controls the default expansion setting for the operations and tags. - It can be "list" (expands only the tags), - "full" (expands the tags and operations) or "none" (expands nothing). - see https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md + import_name: The import name for the Flask application. + info: Information about the API (title, version, etc.). + See https://spec.openapis.org/oas/v3.0.3#info-object. + security_schemes: Security schemes for the API. + See https://spec.openapis.org/oas/v3.0.3#security-scheme-object. + oauth_config: OAuth 2.0 configuration for authentication. + See https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/oauth2.md. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. + doc_ui: Enable OpenAPI document UI (Swagger UI and Redoc). Defaults to True. + doc_expansion: Default expansion setting for operations and tags ("list", "full", or "none"). + See https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md. doc_prefix: URL prefix used for OpenAPI document and UI. Defaults to "/openapi". - api_doc_url: The OpenAPI Spec documentation. Defaults to "/openapi.json". - swagger_url: The Swagger UI documentation. Defaults to `/swagger`. - redoc_url: The Redoc UI documentation. Defaults to `/redoc`. - rapidoc_url: The RapiDoc UI documentation. Defaults to `/rapidoc`. - ui_templates: Custom UI templates, which are used to overwrite or add UI documents. - servers: An array of Server Objects, which provide connectivity information to a target server. - external_docs: Allows referencing an external resource for extended documentation. - See: https://spec.openapis.org/oas/v3.0.3#external-documentation-object - operation_id_callback: Callback function for custom operation_id generation. - Receives name (str), path (str) and method (str) parameters. - Default to `get_operation_id_for_path` from utils - openapi_extensions: Allows extensions to the OpenAPI Schema. - See https://spec.openapis.org/oas/v3.0.3#specification-extensions - **kwargs: Flask kwargs + api_doc_url: URL for accessing the OpenAPI specification document in JSON format. + Defaults to "/openapi.json". + swagger_url: URL for accessing the Swagger UI documentation. Defaults to "/swagger". + redoc_url: URL for accessing the Redoc UI documentation. Defaults to "/redoc". + rapidoc_url: URL for accessing the RapiDoc UI documentation. Defaults to "/rapidoc". + ui_templates: Custom UI templates to override or add UI documents. + servers: An array of Server objects providing connectivity information to a target server. + external_docs: External documentation for the API. + See: https://spec.openapis.org/oas/v3.0.3#external-documentation-object. + operation_id_callback: Callback function for custom operation ID generation. + Receives name (str), path (str), and method (str) parameters. + Defaults to `get_operation_id_for_path` from utils. + openapi_extensions: Extensions to the OpenAPI Schema. + See https://spec.openapis.org/oas/v3.0.3#specification-extensions. + **kwargs: Additional kwargs to be passed to Flask. """ super(OpenAPI, self).__init__(import_name, **kwargs) + + # Set OpenAPI version and API information self.openapi_version = "3.0.3" self.info = info or Info(title="OpenAPI", version="1.0.0") + + # Set security schemes, responses, paths and components self.security_schemes = security_schemes self.responses = responses or {} self.paths: Dict = dict() self.components_schemas: Dict = dict() self.components = Components() + + # Initialize lists for tags and tag names self.tags: List[Tag] = [] self.tag_names: List[str] = [] + + # Set URL prefixes and endpoints self.doc_prefix = doc_prefix self.api_doc_url = api_doc_url self.swagger_url = swagger_url self.redoc_url = redoc_url self.rapidoc_url = rapidoc_url + + # Set OAuth configuration and documentation expansion self.oauth_config = oauth_config self.doc_expansion = doc_expansion + + # Set UI templates for customization self.ui_templates = ui_templates or dict() + + # Set servers and external documentation self.severs = servers self.external_docs = external_docs + + # Set the operation ID callback function self.operation_id_callback: Callable = operation_id_callback + + # Set OpenAPI extensions self.openapi_extensions = openapi_extensions or dict() + + # Initialize the OpenAPI documentation UI if doc_ui: self._init_doc() - # add openapi command + + # Add the OpenAPI command self.cli.add_command(openapi_command) def _init_doc(self) -> None: @@ -114,6 +135,7 @@ def _init_doc(self) -> None: template_folder = os.path.join(_here, "templates") static_folder = os.path.join(template_folder, "static") + # Create the blueprint for OpenAPI documentation blueprint = Blueprint( "openapi", __name__, @@ -121,24 +143,28 @@ def _init_doc(self) -> None: template_folder=template_folder, static_folder=static_folder ) + + # Add the API documentation URL rule blueprint.add_url_rule( rule=self.api_doc_url, endpoint="api_doc", view_func=lambda: self.api_doc ) + + # Combine built-in templates and user-defined templates builtins_templates = { self.swagger_url.strip("/"): swagger_html_string, self.redoc_url.strip("/"): redoc_html_string, - self.rapidoc_url.strip("/"): rapidoc_html_string + self.rapidoc_url.strip("/"): rapidoc_html_string, + **self.ui_templates } - # update builtins_templates - builtins_templates.update(**self.ui_templates) - # iter builtins_templates + + # Add URL rules for the templates for key, value in builtins_templates.items(): blueprint.add_url_rule( rule=f"/{key}", endpoint=key, - # pass default value to source + # Pass default value to source view_func=lambda source=value: render_template_string( source, api_doc_url=self.api_doc_url.lstrip("/"), @@ -147,7 +173,8 @@ def _init_doc(self) -> None: oauth_config=self.oauth_config.dict() if self.oauth_config else None ) ) - # home page + + # Add URL rule for the home page blueprint.add_url_rule( rule="/", endpoint="openapi", @@ -158,36 +185,66 @@ def _init_doc(self) -> None: rapidoc_url=self.rapidoc_url.lstrip("/") ) ) + + # Register the blueprint with the Flask application self.register_blueprint(blueprint) @property def api_doc(self) -> Dict: - """Generate Specification json""" + """ + Generate the OpenAPI specification JSON. + + Returns: + The OpenAPI specification JSON as a dictionary. + + """ spec = APISpec( openapi=self.openapi_version, info=self.info, servers=self.severs, externalDocs=self.external_docs ) + # Set tags spec.tags = self.tags or None + + # Set paths spec.paths = self.paths + + # Set components self.components.schemas = self.components_schemas self.components.securitySchemes = self.security_schemes spec.components = self.components + # Convert spec to JSON spec_json = json.loads(spec.json(by_alias=True, exclude_none=True)) + + # Update with OpenAPI extensions spec_json.update(**self.openapi_extensions) return spec_json def register_api(self, api: APIBlueprint) -> None: - """Register APIBlueprint""" + """ + Register an APIBlueprint. + + Arguments: + api: The APIBlueprint instance to register. + + """ for tag in api.tags: if tag.name not in self.tag_names: + # Append tag to the list of tags self.tags.append(tag) + # Append tag name to the list of tag names self.tag_names.append(tag.name) + + # Update paths with the APIBlueprint's paths self.paths.update(**api.paths) + + # Update components schemas with the APIBlueprint's components schemas self.components_schemas.update(**api.components_schemas) + + # Register the APIBlueprint with the current instance self.register_blueprint(api) def register_api_view(self, api_view: APIView, view_kwargs: Optional[Dict[Any, Any]] = None) -> None: @@ -195,17 +252,27 @@ def register_api_view(self, api_view: APIView, view_kwargs: Optional[Dict[Any, A Register APIView Arguments: - api_view: APIView - view_kwargs: extra view kwargs + api_view: The APIView instance to register. + view_kwargs: Additional keyword arguments to pass to the API views. """ if view_kwargs is None: view_kwargs = {} + + # Iterate through tags of the APIView for tag in api_view.tags: if tag.name not in self.tag_names: + # Append tag to the list of tags self.tags.append(tag) + # Append tag name to the list of tag names self.tag_names.append(tag.name) + + # Update paths with the APIView's paths self.paths.update(**api_view.paths) + + # Update components schemas with the APIView's components schemas self.components_schemas.update(**api_view.components_schemas) + + # Register the APIView with the current instance api_view.register(self, view_kwargs=view_kwargs) def _do_decorator( @@ -230,10 +297,11 @@ def _do_decorator( method: str = HTTPMethod.GET ) -> Tuple[Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel]]: """ - Collect openapi specification information + Collects OpenAPI specification information for Flask routes and view functions. + Arguments: - rule: Flask route - func: Flask view_func + rule: Flask route. + func: Flask view_func. tags: Adds metadata to a single tag. summary: A short summary of what the operation does. description: A verbose explanation of the operation behavior. @@ -241,46 +309,47 @@ def _do_decorator( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: API responses, should be BaseModel, dict or None. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. 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. - doc_ui: Add openapi document UI(swagger, rapidoc and redoc). Defaults to True. + doc_ui: Add OpenAPI document UI (swagger, rapidoc, and redoc). Defaults to True. + method: HTTP method for the operation. Defaults to GET. """ if doc_ui is True: if responses is None: responses = {} if extra_responses is None: extra_responses = {} - # global response combine api responses + # Global response: combine API responses combine_responses = deepcopy(self.responses) combine_responses.update(**responses) - # create operation + # Create operation operation = get_operation( func, summary=summary, description=description, openapi_extensions=openapi_extensions ) - # set external docs + # Set external docs operation.externalDocs = external_docs # Unique string used to identify the operation. operation.operationId = operation_id or self.operation_id_callback( name=func.__name__, path=rule, method=method ) - # only set `deprecated` if True otherwise leave it as None + # Only set `deprecated` if True, otherwise leave it as None operation.deprecated = deprecated - # add security + # Add security operation.security = security - # add servers + # Add servers operation.servers = servers - # store tags + # Store tags if tags is None: tags = [] parse_and_store_tags(tags, self.tags, self.tag_names, operation) - # parse parameters + # Parse parameters header, cookie, path, query, form, body = parse_parameters( func, extra_form=extra_form, @@ -288,14 +357,14 @@ def _do_decorator( components_schemas=self.components_schemas, operation=operation ) - # parse response + # Parse response get_responses(combine_responses, extra_responses, self.components_schemas, operation) - # /pet/ --> /pet/{petId} + # Convert route parameter format from /pet/ to /pet/{petId} uri = re.sub(r"<([^<:]+:)?", "{", rule).replace(">", "}") - # parse method + # Parse method parse_method(uri, method, self.paths, operation) return header, cookie, path, query, form, body else: - # parse parameters + # Parse parameters header, cookie, path, query, form, body = parse_parameters(func, doc_ui=False) return header, cookie, path, query, form, body diff --git a/flask_openapi3/request.py b/flask_openapi3/request.py index ba4023fd..006578d4 100644 --- a/flask_openapi3/request.py +++ b/flask_openapi3/request.py @@ -11,56 +11,56 @@ from pydantic.error_wrappers import ErrorWrapper -def _do_header(header, request_kwargs): +def _do_header(header, func_kwargs): request_headers = dict(request.headers) or {} for key, value in header.schema().get("properties", {}).items(): key_title = key.replace("_", "-").title() - # add original key + # Add original key if key_title in request_headers.keys(): request_headers[key] = request_headers[key_title] - request_kwargs.update({"header": header(**request_headers)}) + func_kwargs.update({"header": header(**request_headers)}) -def _do_cookie(cookie, request_kwargs): +def _do_cookie(cookie, func_kwargs): request_cookies = cookie(**request.cookies or {}) - request_kwargs.update({"cookie": request_cookies}) + func_kwargs.update({"cookie": request_cookies}) -def _do_path(path, kwargs, request_kwargs): - request_path = path(**kwargs) - request_kwargs.update({"path": request_path}) +def _do_path(path, path_kwargs, func_kwargs): + request_path = path(**path_kwargs) + func_kwargs.update({"path": request_path}) -def _do_query(query, request_kwargs): +def _do_query(query, func_kwargs): request_args = request.args query_dict = {} - for k, v in query.schema().get('properties', {}).items(): - if v.get('type') == 'array': + for k, v in query.schema().get("properties", {}).items(): + if v.get("type") == "array": value = request_args.getlist(k) else: value = request_args.get(k) if value is not None: query_dict[k] = value - request_kwargs.update({"query": query(**query_dict)}) + func_kwargs.update({"query": query(**query_dict)}) -def _do_form(form, request_kwargs): +def _do_form(form, func_kwargs): request_form = request.form request_files = request.files form_dict = {} - for k, v in form.schema().get('properties', {}).items(): - if v.get('type') == 'array': - items = v.get('items', {}) - if items.get('type') == 'string' and items.get('format') == 'binary': + for k, v in form.schema().get("properties", {}).items(): + if v.get("type") == "array": + items = v.get("items", {}) + if items.get("type") == "string" and items.get("format") == "binary": # List[FileStorage] - # {'title': 'Files', 'type': 'array', 'items': {'format': 'binary', 'type': 'string'} + # eg: {"title": "Files", "type": "array", "items": {"format": "binary", "type": "string"} value = request_files.getlist(k) else: # List[str], List[int] ... - # {'title': 'Files', 'type': 'array', 'items': {'type': 'string'} + # eg: {"title": "Files", "type": "array", "items": {"type": "string"} value = request_form.getlist(k) else: - if v.get('format') == 'binary': + if v.get("format") == "binary": # FileStorage value = request_files.get(k) else: @@ -68,22 +68,22 @@ def _do_form(form, request_kwargs): value = request_form.get(k) if value is not None: form_dict[k] = value - request_kwargs.update({"form": form(**form_dict)}) + func_kwargs.update({"form": form(**form_dict)}) -def _do_body(body, request_kwargs): +def _do_body(body, func_kwargs): obj = request.get_json(silent=True) or {} if isinstance(obj, str): try: obj = json.loads(obj) except JSONDecodeError as e: - raise ValidationError([ErrorWrapper(e, loc='__root__')], body) + raise ValidationError([ErrorWrapper(e, loc="__root__")], body) if body.__custom_root_type__: - # https://pydantic-docs.helpmanual.io/usage/models/#custom-root-types + # https://docs.pydantic.dev/latest/usage/models/#custom-root-types body_ = body(__root__=obj) else: body_ = body(**obj) - request_kwargs.update({"body": body_}) + func_kwargs.update({"body": body_}) def _do_request( @@ -93,39 +93,49 @@ def _do_request( query: Optional[Type[BaseModel]] = None, form: Optional[Type[BaseModel]] = None, body: Optional[Type[BaseModel]] = None, - **kwargs: Any + path_kwargs: Optional[Dict[Any, Any]] = None ) -> Union[Response, Dict]: """ - Validate requests and responses - :param func: view func - :param responses: response model - :param header: header model - :param cookie: cookie model - :param path: path model - :param query: query model - :param form: form model - :param body: body model - :param kwargs: path parameters - :return: + Validate requests and responses. + + Args: + header: Header model. + cookie: Cookie model. + path: Path model. + query: Query model. + form: Form model. + body: Body model. + path_kwargs: Path parameters. + + Returns: + Union[Response, Dict]: Request kwargs. + + Raises: + ValidationError: If validation fails. """ - # validate header, cookie, path and query - request_kwargs: Dict = dict() + + # Dictionary to store func kwargs + func_kwargs: Dict = dict() + try: + # Validate header, cookie, path, and query parameters if header: - _do_header(header, request_kwargs) + _do_header(header, func_kwargs) if cookie: - _do_cookie(cookie, request_kwargs) + _do_cookie(cookie, func_kwargs) if path: - _do_path(path, kwargs, request_kwargs) + _do_path(path, path_kwargs, func_kwargs) if query: - _do_query(query, request_kwargs) + _do_query(query, func_kwargs) if form: - _do_form(form, request_kwargs) + _do_form(form, func_kwargs) if body: - _do_body(body, request_kwargs) + _do_body(body, func_kwargs) except ValidationError as e: + # Create a JSON response with validation error details response = make_response(e.json()) - response.headers['Content-Type'] = 'application/json' + response.headers["Content-Type"] = "application/json" response.status_code = 422 return response - return request_kwargs + + return func_kwargs diff --git a/flask_openapi3/scaffold.py b/flask_openapi3/scaffold.py index e5e07356..628047eb 100644 --- a/flask_openapi3/scaffold.py +++ b/flask_openapi3/scaffold.py @@ -74,22 +74,40 @@ def create_view_func( view_class=None, view_kwargs=None ): + """ + Create a view function that can be used with Flask to handle API requests. + + Arguments: + func: The original function to be called when handling the API request. + header: The header parameter for the API request. + cookie: The cookie parameter for the API request. + path: The path parameter for the API request. + query: The query parameter for the API request. + form: The form parameter for the API request. + body: The body parameter for the API request. + view_class: The class of the API view (if applicable). + view_kwargs: Additional keyword arguments to pass to the API view. + + Returns: + The view function that can be registered with Flask. + + """ is_coroutine_function = iscoroutinefunction(func) if is_coroutine_function: @wraps(func) async def view_func(**kwargs) -> Response: - result = _do_request( + func_kwargs = _do_request( header=header, cookie=cookie, path=path, query=query, form=form, body=body, - **kwargs + path_kwargs=kwargs ) - if isinstance(result, Response): + if isinstance(func_kwargs, Response): # 422 - return result + return func_kwargs # handle async request if view_class: signature = inspect.signature(view_class.__init__) @@ -98,9 +116,9 @@ async def view_func(**kwargs) -> Response: view_object = view_class(view_kwargs=view_kwargs) else: view_object = view_class() - response = await func(view_object, **result) + response = await func(view_object, **func_kwargs) else: - response = await func(**result) + response = await func(**func_kwargs) return response else: @wraps(func) @@ -112,7 +130,7 @@ def view_func(**kwargs) -> Response: query=query, form=form, body=body, - **kwargs + path_kwargs=kwargs ) if isinstance(result, Response): # 422 @@ -156,7 +174,7 @@ def get( **options: Any ) -> Callable: """ - Decorator for rest api, like: app.route(methods=["GET"]) + Decorator for defining a REST API endpoint with the HTTP GET method. More information goto https://spec.openapis.org/oas/v3.0.3#operation-object Arguments: @@ -168,7 +186,7 @@ def get( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: API responses, should be BaseModel, dict or None. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -241,7 +259,7 @@ def post( **options: Any ) -> Callable: """ - Decorator for rest api, like: app.route(methods=["POST"]) + Decorator for defining a REST API endpoint with the HTTP POST method. More information goto https://spec.openapis.org/oas/v3.0.3#operation-object Arguments: @@ -253,7 +271,7 @@ def post( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: API responses, should be BaseModel, dict or None. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -325,7 +343,7 @@ def put( **options: Any ) -> Callable: """ - Decorator for rest api, like: app.route(methods=["PUT"]) + Decorator for defining a REST API endpoint with the HTTP PUT method. More information goto https://spec.openapis.org/oas/v3.0.3#operation-object Arguments: @@ -337,7 +355,7 @@ def put( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: API responses, should be BaseModel, dict or None. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -409,7 +427,7 @@ def delete( **options: Any ) -> Callable: """ - Decorator for rest api, like: app.route(methods=["DELETE"]) + Decorator for defining a REST API endpoint with the HTTP DELETE method. More information goto https://spec.openapis.org/oas/v3.0.3#operation-object Arguments: @@ -421,7 +439,7 @@ def delete( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: API responses, should be BaseModel, dict or None. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -493,7 +511,7 @@ def patch( **options: Any ) -> Callable: """ - Decorator for rest api, like: app.route(methods=["PATCH"]) + Decorator for defining a REST API endpoint with the HTTP PATCH method. More information goto https://spec.openapis.org/oas/v3.0.3#operation-object Arguments: @@ -505,7 +523,7 @@ def patch( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: API responses, should be BaseModel, dict or None. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index a5a25eb0..beb94e4f 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -22,38 +22,70 @@ def get_operation( description: Optional[str] = None, openapi_extensions: Optional[Dict[str, Any]] = None, ) -> Operation: - """Return an Operation object with summary and description.""" + """ + Return an Operation object with the specified summary and description. + + Arguments: + func: The function or method for which the operation is being defined. + summary: A short summary of what the operation does. + description: A verbose explanation of the operation behavior. + openapi_extensions: Additional extensions to the OpenAPI Schema. + + Returns: + An Operation object representing the operation. + + """ + # Get the docstring of the function doc = inspect.getdoc(func) or "" doc = doc.strip() lines = doc.split("\n") doc_summary = lines[0] or None + + # Determine the summary and description based on provided arguments or docstring if summary is None: doc_description = lines[0] if len(lines) == 0 else "
".join(lines[1:]) or None else: doc_description = "
".join(lines) or None + # Create the operation dictionary with summary and description operation_dict = dict( summary=summary or doc_summary, description=description or doc_description ) - # update openapi_extensions + + # Add any additional openapi_extensions to the operation dictionary openapi_extensions = openapi_extensions or {} operation_dict.update(**openapi_extensions) + # Create and return the Operation object operation = Operation(**operation_dict) return operation def get_operation_id_for_path(*, name: str, path: str, method: str) -> str: + """ + Generate a unique operation ID based on the name, path, and method. + + Arguments: + name: The name or identifier for the operation. + path: The URL path for the operation. + method: The HTTP method for the operation. + + Returns: + A unique operation ID generated based on the provided name, path, and method. + + """ operation_id = name + path + # Replace non-word characters with underscores operation_id = re.sub(r"\W", "_", operation_id) operation_id = operation_id + "_" + method.lower() return operation_id def get_schema(obj: Type[BaseModel]) -> dict: - """Pydantic model conversion to openapi schema""" + """Converts a Pydantic model to an OpenAPI schema.""" + assert inspect.isclass(obj) and issubclass(obj, BaseModel), \ f"{obj} is invalid `pydantic.BaseModel`" @@ -61,7 +93,7 @@ def get_schema(obj: Type[BaseModel]) -> dict: def parse_header(header: Type[BaseModel]) -> Tuple[List[Parameter], dict]: - """Parse header model""" + """Parses a header model and returns a list of parameters and component schemas.""" schema = get_schema(header) parameters = [] components_schemas: Dict = dict() @@ -75,11 +107,11 @@ def parse_header(header: Type[BaseModel]) -> Tuple[List[Parameter], dict]: "required": name in schema.get("required", []), "schema": Schema(**value) } - # parse extra values + # Parse extra values data.update(**value) parameters.append(Parameter(**data)) - # parse definitions + # Parse definitions definitions = schema.get("definitions", {}) for name, value in definitions.items(): components_schemas[name] = Schema(**value) @@ -88,7 +120,7 @@ def parse_header(header: Type[BaseModel]) -> Tuple[List[Parameter], dict]: def parse_cookie(cookie: Type[BaseModel]) -> Tuple[List[Parameter], dict]: - """Parse cookie model""" + """Parses a cookie model and returns a list of parameters and component schemas.""" schema = get_schema(cookie) parameters = [] components_schemas: Dict = dict() @@ -102,11 +134,11 @@ def parse_cookie(cookie: Type[BaseModel]) -> Tuple[List[Parameter], dict]: "required": name in schema.get("required", []), "schema": Schema(**value) } - # parse extra values + # Parse extra values data.update(**value) parameters.append(Parameter(**data)) - # parse definitions + # Parse definitions definitions = schema.get("definitions", {}) for name, value in definitions.items(): components_schemas[name] = Schema(**value) @@ -115,7 +147,7 @@ def parse_cookie(cookie: Type[BaseModel]) -> Tuple[List[Parameter], dict]: def parse_path(path: Type[BaseModel]) -> Tuple[List[Parameter], dict]: - """Parse path model""" + """Parses a path model and returns a list of parameters and component schemas.""" schema = get_schema(path) parameters = [] components_schemas: Dict = dict() @@ -129,11 +161,11 @@ def parse_path(path: Type[BaseModel]) -> Tuple[List[Parameter], dict]: "required": True, "schema": Schema(**value) } - # parse extra values + # Parse extra values data.update(**value) parameters.append(Parameter(**data)) - # parse definitions + # Parse definitions definitions = schema.get("definitions", {}) for name, value in definitions.items(): components_schemas[name] = Schema(**value) @@ -142,7 +174,7 @@ def parse_path(path: Type[BaseModel]) -> Tuple[List[Parameter], dict]: def parse_query(query: Type[BaseModel]) -> Tuple[List[Parameter], dict]: - """Parse query model""" + """Parses a query model and returns a list of parameters and component schemas.""" schema = get_schema(query) parameters = [] components_schemas: Dict = dict() @@ -156,11 +188,11 @@ def parse_query(query: Type[BaseModel]) -> Tuple[List[Parameter], dict]: "required": name in schema.get("required", []), "schema": Schema(**value) } - # parse extra values + # Parse extra values data.update(**value) parameters.append(Parameter(**data)) - # parse definitions + # Parse definitions definitions = schema.get("definitions", {}) for name, value in definitions.items(): components_schemas[name] = Schema(**value) @@ -172,7 +204,7 @@ def parse_form( form: Type[BaseModel], extra_form: Optional[ExtraRequestBody] = None, ) -> Tuple[Dict[str, MediaType], dict]: - """Parse form model""" + """Parses a form model and returns a list of parameters and component schemas.""" schema = get_schema(form) components_schemas = dict() properties = schema.get("properties", {}) @@ -186,7 +218,7 @@ def parse_form( if v.get("type") == "array": encoding[k] = Encoding(style="form") if extra_form: - # update encoding + # Update encoding if extra_form.encoding: encoding.update(**extra_form.encoding) content = { @@ -205,7 +237,7 @@ def parse_form( ) } - # parse definitions + # Parse definitions definitions = schema.get("definitions", {}) for name, value in definitions.items(): components_schemas[name] = Schema(**value) @@ -217,7 +249,7 @@ def parse_body( body: Type[BaseModel], extra_body: Optional[ExtraRequestBody] = None, ) -> Tuple[Dict[str, MediaType], dict]: - """Parse body model""" + """Parses a body model and returns a list of parameters and component schemas.""" schema = get_schema(body) components_schemas = dict() @@ -239,7 +271,7 @@ def parse_body( ) } - # parse definitions + # Parse definitions definitions = schema.get("definitions", {}) for name, value in definitions.items(): components_schemas[name] = Schema(**value) @@ -253,17 +285,12 @@ def get_responses( components_schemas: dict, operation: Operation ) -> None: - """ - :param responses: API responses, should be BaseModel, dict or None. - :param extra_responses: Dict[str, dict] - :param components_schemas: `models.component.py` `Components.schemas` - :param operation: `models.path.py` Operation - """ if responses is None: responses = {} _responses = {} _schemas = {} if not responses.get("422"): + # Handle 422 response for Unprocessable Entity _responses["422"] = Response( description=HTTP_STATUS["422"], content={ @@ -282,7 +309,7 @@ def get_responses( _schemas[UnprocessableEntity.__name__] = Schema(**UnprocessableEntity.schema()) for key, response in responses.items(): if response is None: - # Verify that if the response is None, because http status code "204" means return "No Content" + # If the response is None, it means HTTP status code "204" (No Content) _responses[key] = Response(description=HTTP_STATUS.get(key, "")) continue if isinstance(response, dict): @@ -306,6 +333,7 @@ def get_responses( model_config = response.Config if hasattr(model_config, "openapi_extra"): + # Add additional information from model_config to the response _responses[key].description = model_config.openapi_extra.get("description") _responses[key].headers = model_config.openapi_extra.get("headers") _responses[key].links = model_config.openapi_extra.get("links") @@ -319,6 +347,7 @@ def get_responses( _schemas[response.__name__] = Schema(**schema) definitions = schema.get("definitions") if definitions: + # Add schema definitions to _schemas for name, value in definitions.items(): _schemas[name] = Schema(**value) @@ -342,18 +371,30 @@ def parse_and_store_tags( old_tag_names: List[str], operation: Operation ) -> None: - """Store tags - :param new_tags: api tag - :param old_tags: openapi doc tags - :param old_tag_names: openapi doc tag names - :param operation: `models.path.py` Operation + """ + Parses new tags, stores them in old_tags list if they are not already present, + and updates the tags attribute of the operation object. + + Arguments: + new_tags: A list of new Tag objects to be parsed and stored. + old_tags: The list of existing Tag objects. + old_tag_names: The list of names of existing tags. + operation: The operation object whose tags attribute needs to be updated. + + Returns: + None """ if new_tags is None: new_tags = [] + + # Iterate over each tag in new_tags for tag in new_tags: if tag.name not in old_tag_names: old_tag_names.append(tag.name) old_tags.append(tag) + + # Set the tags attribute of the operation object to a list of unique tag names from new_tags + # If the resulting list is empty, set it to None operation.tags = list(set([tag.name for tag in new_tags])) or None @@ -367,45 +408,67 @@ def parse_parameters( doc_ui: bool = True, ) -> Tuple[Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel]]: """ - :param func: Flask view func - :param extra_form: Extra information describing the request body(application/form). - :param extra_body: Extra information describing the request body(application/json). - :param components_schemas: `models.component.py` Components.schemas - :param operation: `models.path.py` Operation - :param doc_ui: add openapi document UI(swagger and redoc). Defaults to True. + Parses the parameters of a given function and returns the types for header, cookie, path, + query, form, and body parameters. Also populates the Operation object with the parsed parameters. + + Arguments: + func: The function to parse the parameters from. + extra_form: Additional form data for the request body (default: None). + extra_body: Additional body data for the request body (default: None). + components_schemas: Dictionary to store the parsed components schemas (default: None). + operation: Operation object to populate with parsed parameters (default: None). + doc_ui: Flag indicating whether to return types for documentation UI (default: True). + + Returns: + Tuple[Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel], Type[BaseModel]]: + The types for header, cookie, path, query, form, and body parameters respectively. + """ + # Get the type hints from the function annotations = get_type_hints(func) + + # Get the types for header, cookie, path, query, form, and body parameters header = annotations.get("header") cookie = annotations.get("cookie") path = annotations.get("path") query = annotations.get("query") form = annotations.get("form") body = annotations.get("body") + + # If doc_ui is False, return the types without further processing if doc_ui is False: return header, cookie, path, query, form, body # type: ignore + parameters = [] + + # If components_schemas is None, initialize it as an empty dictionary if components_schemas is None: components_schemas = dict() + + # If operation is None, initialize it as an Operation object if operation is None: operation = Operation() + if header: _parameters, _components_schemas = parse_header(header) parameters.extend(_parameters) components_schemas.update(**_components_schemas) + if cookie: _parameters, _components_schemas = parse_cookie(cookie) parameters.extend(_parameters) components_schemas.update(**_components_schemas) + if path: - # get args from a route path _parameters, _components_schemas = parse_path(path) parameters.extend(_parameters) components_schemas.update(**_components_schemas) + if query: - # get args from route query _parameters, _components_schemas = parse_query(query) parameters.extend(_parameters) components_schemas.update(**_components_schemas) + if form: _content, _components_schemas = parse_form(form, extra_form) components_schemas.update(**_components_schemas) @@ -427,6 +490,7 @@ def parse_parameters( if model_config.openapi_extra.get("encoding"): request_body.content["multipart/form-data"].encoding = model_config.openapi_extra.get("encoding") operation.requestBody = request_body + if body: _content, _components_schemas = parse_body(body, extra_body) components_schemas.update(**_components_schemas) @@ -445,6 +509,8 @@ def parse_parameters( request_body.content["application/json"].examples = model_config.openapi_extra.get("examples") request_body.content["application/json"].encoding = model_config.openapi_extra.get("encoding") operation.requestBody = request_body + + # Set the parsed parameters in the operation object operation.parameters = parameters if parameters else None return header, cookie, path, query, form, body # type: ignore @@ -452,11 +518,18 @@ def parse_parameters( def parse_method(uri: str, method: str, paths: dict, operation: Operation) -> None: """ - :param uri: api route path - :param method: get post put delete patch - :param paths: openapi doc paths - :param operation: `models.path.py` Operation + Parses the HTTP method and updates the corresponding PathItem object in the paths' dictionary. + + Arguments: + uri: The URI of the API endpoint. + method: The HTTP method for the API endpoint. + paths: A dictionary containing the API paths and their corresponding PathItem objects. + operation: The Operation object to assign to the PathItem. + + Returns: + None """ + # Check the HTTP method and update the PathItem object in the paths dictionary if method == HTTPMethod.GET: if not paths.get(uri): paths[uri] = PathItem(get=operation) diff --git a/flask_openapi3/view.py b/flask_openapi3/view.py index ed309943..4807867b 100644 --- a/flask_openapi3/view.py +++ b/flask_openapi3/view.py @@ -38,13 +38,13 @@ def __init__( Arguments: url_prefix: A path to prepend to all the APIView's urls - view_tags: APIView tags for every api - view_security: APIView security for every api - view_responses: API responses, should be BaseModel, dict or None. - doc_ui: Add openapi document UI(swagger, rapidoc and redoc). Defaults to True. + view_tags: APIView tags for every API. + view_security: APIView security for every API. + view_responses: API responses should be either a subclass of BaseModel, a dictionary, or None. + doc_ui: Enable OpenAPI document UI (Swagger UI and Redoc). Defaults to True. operation_id_callback: Callback function for custom operation_id generation. - Receives name (str), path (str) and method (str) parameters. - Defaults to `get_operation_id_for_path` from utils + Receives name (str), path (str) and method (str) parameters. + Defaults to `get_operation_id_for_path` from utils """ self.url_prefix = url_prefix self.view_tags = view_tags or [] @@ -66,10 +66,10 @@ def wrapper(cls): if self.views.get(rule): raise ValueError(f"malformed url rule: {rule!r}") methods = [] - # /pet/ --> /pet/{petId} + # Convert route parameter format from /pet/ to /pet/{petId} uri = re.sub(r"<([^<:]+:)?", "{", rule).replace(">", "}") trail_slash = uri.endswith("/") - # merge url_prefix and uri + # Merge url_prefix and uri uri = self.url_prefix.rstrip("/") + "/" + uri.lstrip("/") if self.url_prefix else uri if not trail_slash: uri = uri.rstrip("/") @@ -82,16 +82,16 @@ def wrapper(cls): continue if not getattr(cls_method, "operation", None): continue - # parse method + # Parse method parse_method(uri, method, self.paths, cls_method.operation) - # update operation_id + # Update operation_id if not cls_method.operation.operationId: cls_method.operation.operationId = self.operation_id_callback( name=cls_method.__qualname__, path=rule, method=method ) - # /pet/{petId} --> /pet/ + # Convert route parameters from to {param} _rule = uri.replace("{", "<").replace("}", ">") self.views[_rule] = (cls, methods) @@ -129,7 +129,7 @@ def doc( operation_id: Unique string used to identify the operation. extra_form: Extra information describing the request body(application/form). extra_body: Extra information describing the request body(application/json). - responses: API responses, should be BaseModel, dict or None. + responses: API responses should be either a subclass of BaseModel, a dictionary, or None. extra_responses: Extra information for responses. deprecated: Declares this operation to be deprecated. security: A declaration of which security mechanisms can be used for this operation. @@ -162,29 +162,29 @@ def doc( def decorator(func): if self.doc_ui is False or doc_ui is False: return - # global response combine api responses + # Global response combines API responses combine_responses = deepcopy(self.view_responses) combine_responses.update(**responses) - # create operation + # Create operation operation = get_operation( func, summary=summary, description=description, openapi_extensions=openapi_extensions ) - # set external docs + # Set external docs operation.externalDocs = external_docs # Unique string used to identify the operation. operation.operationId = operation_id - # only set `deprecated` if True otherwise leave it as None + # Only set `deprecated` if True, otherwise leave it as None operation.deprecated = deprecated - # add security + # Add security operation.security = security + self.view_security or None - # add servers + # Add servers operation.servers = servers - # store tags + # Store tags parse_and_store_tags(tags, self.tags, self.tag_names, operation) - # parse parameters + # Parse parameters parse_parameters( func, extra_form=extra_form, @@ -192,7 +192,7 @@ def decorator(func): components_schemas=self.components_schemas, operation=operation ) - # parse response + # Parse response get_responses(combine_responses, extra_responses, self.components_schemas, operation) func.operation = operation @@ -201,6 +201,16 @@ def decorator(func): return decorator def register(self, app: "OpenAPI", view_kwargs: Optional[Dict[Any, Any]] = None): + """ + Register the API views with the given OpenAPI app. + + Args: + app: An instance of the OpenAPI app. + view_kwargs: Additional keyword arguments to pass to the API views. + + Returns: + None + """ if view_kwargs is None: view_kwargs = {} for rule, (cls, methods) in self.views.items():