diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b2b099ab..3c2dd571 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] + python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14" ] flask-version: [ "Flask>=2.0,<3.0", "Flask>=3.0" ] env: PYTHONPATH: . diff --git a/README.md b/README.md index f58d9f60..8be8d1af 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The key features are: ## Requirements -Python 3.9+ +Python 3.10+ flask-openapi3 is dependent on the following libraries: diff --git a/docs/Usage/Request.md b/docs/Usage/Request.md index 3f7b1856..cbd3d7dd 100644 --- a/docs/Usage/Request.md +++ b/docs/Usage/Request.md @@ -167,6 +167,88 @@ def get_book(query: BookQuery, client_id:str = None): ... ``` +## Multiple content types in the request body + +```python +from typing import Union + +from flask import Request +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) + + +class DogBody(BaseModel): + a: int = None + b: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.dog+json" + } + } + + +class CatBody(BaseModel): + c: int = None + d: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.cat+json" + } + } + + +class BsonModel(BaseModel): + e: int = None + f: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/bson" + } + } + + +class ContentTypeModel(BaseModel): + model_config = { + "openapi_extra": { + "content_type": "text/csv" + } + } + + +@app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel | BsonModel}) +def index_a(body: DogBody | CatBody | ContentTypeModel | BsonModel): + """ + multiple content types examples. + + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + ... + else: + # DogBody or CatBody + ... + return {"hello": "world"} +``` + +The effect in swagger: + +![](../assets/Snipaste_2025-01-14_10-44-00.png) + + ## Request model First, you need to define a [pydantic](https://github.com/pydantic/pydantic) model: @@ -191,7 +273,7 @@ class BookQuery(BaseModel): author: str = Field(None, description='Author', json_schema_extra={"deprecated": True}) ``` -Magic: +The effect in swagger: ![](../assets/Snipaste_2022-09-04_10-10-03.png) diff --git a/docs/Usage/Response.md b/docs/Usage/Response.md index 8d8300b9..aad1c938 100644 --- a/docs/Usage/Response.md +++ b/docs/Usage/Response.md @@ -56,6 +56,122 @@ def hello(path: HelloPath): ![image-20210526104627124](../assets/image-20210526104627124.png) +*Sometimes you may need more description fields about the response, such as description, headers and links. + +You can use the following form: + +```python +@app.get( + "/test", + responses={ + "201": { + "model": BaseResponse, + "description": "Custom description", + "headers": { + "location": { + "description": "URL of the new resource", + "schema": {"type": "string"} + } + }, + "links": { + "dummy": { + "description": "dummy link" + } + } + } + } + ) + def endpoint_test(): + ... +``` + +The effect in swagger: + +![](../assets/Snipaste_2025-01-14_11-08-40.png) + + +## Multiple content types in the responses + +```python +from typing import Union + +from flask import Request +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) + + +class DogBody(BaseModel): + a: int = None + b: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.dog+json" + } + } + + +class CatBody(BaseModel): + c: int = None + d: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/vnd.cat+json" + } + } + + +class BsonModel(BaseModel): + e: int = None + f: str = None + + model_config = { + "openapi_extra": { + "content_type": "application/bson" + } + } + + +class ContentTypeModel(BaseModel): + model_config = { + "openapi_extra": { + "content_type": "text/csv" + } + } + + +@app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel | BsonModel}) +def index_a(body: DogBody | CatBody | ContentTypeModel | BsonModel): + """ + multiple content types examples. + + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + ... + else: + # DogBody or CatBody + ... + return {"hello": "world"} +``` + +The effect in swagger: + +![](../assets/Snipaste_2025-01-14_10-49-19.png) + + ## More information about OpenAPI responses - [OpenAPI Responses Object](https://spec.openapis.org/oas/v3.1.0#responses-object), it includes the Response Object. diff --git a/docs/Usage/Route_Operation.md b/docs/Usage/Route_Operation.md index 20aadf25..077d4b6c 100644 --- a/docs/Usage/Route_Operation.md +++ b/docs/Usage/Route_Operation.md @@ -289,6 +289,29 @@ class BookListAPIView: app.register_api_view(api_view) ``` +## request_body_description + +A brief description of the request body. + +```python +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) + +@app.post( + "/", + request_body_description="A brief description of the request body." +) +def create_book(body: Bookbody): + ... +``` + +![](../assets/Snipaste_2025-01-14_10-56-40.png) + +## request_body_required + +Determines if the request body is required in the request. + ## doc_ui You can pass `doc_ui=False` to disable the `OpenAPI spec` when init `OpenAPI `. diff --git a/docs/assets/Snipaste_2025-01-14_10-44-00.png b/docs/assets/Snipaste_2025-01-14_10-44-00.png new file mode 100644 index 00000000..d716d979 Binary files /dev/null and b/docs/assets/Snipaste_2025-01-14_10-44-00.png differ diff --git a/docs/assets/Snipaste_2025-01-14_10-49-19.png b/docs/assets/Snipaste_2025-01-14_10-49-19.png new file mode 100644 index 00000000..f1bb52e7 Binary files /dev/null and b/docs/assets/Snipaste_2025-01-14_10-49-19.png differ diff --git a/docs/assets/Snipaste_2025-01-14_10-56-40.png b/docs/assets/Snipaste_2025-01-14_10-56-40.png new file mode 100644 index 00000000..e66488dc Binary files /dev/null and b/docs/assets/Snipaste_2025-01-14_10-56-40.png differ diff --git a/docs/assets/Snipaste_2025-01-14_11-08-40.png b/docs/assets/Snipaste_2025-01-14_11-08-40.png new file mode 100644 index 00000000..dd537d87 Binary files /dev/null and b/docs/assets/Snipaste_2025-01-14_11-08-40.png differ diff --git a/docs/index.zh.md b/docs/index.zh.md index 84e3907f..60232846 100644 --- a/docs/index.zh.md +++ b/docs/index.zh.md @@ -32,7 +32,7 @@ ## 依赖 -Python 3.9+ +Python 3.10+ flask-openapi3 依赖以下库: diff --git a/examples/api_blueprint_demo.py b/examples/api_blueprint_demo.py index 11c8dda8..a9a9242d 100644 --- a/examples/api_blueprint_demo.py +++ b/examples/api_blueprint_demo.py @@ -2,7 +2,6 @@ # @Author : llc # @Time : 2021/6/6 14:05 -from typing import Optional from pydantic import BaseModel, Field @@ -37,7 +36,7 @@ class Unauthorized(BaseModel): class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") diff --git a/examples/api_view_demo.py b/examples/api_view_demo.py index c35d5846..8b33f185 100644 --- a/examples/api_view_demo.py +++ b/examples/api_view_demo.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2022/10/18 9:00 -from typing import Optional from pydantic import BaseModel, Field @@ -22,11 +21,11 @@ class BookPath(BaseModel): class BookQuery(BaseModel): - age: Optional[int] = Field(None, description="Age") + age: int | None = Field(None, description="Age") class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") diff --git a/examples/async_demo.py b/examples/async_demo.py index 897395e3..c792569b 100644 --- a/examples/async_demo.py +++ b/examples/async_demo.py @@ -2,7 +2,6 @@ # @Author : llc # @Time : 2022/11/30 14:55 -from typing import Optional from pydantic import BaseModel, Field @@ -17,11 +16,11 @@ class Query(BaseModel): class BookQuery(BaseModel): - age: Optional[int] = Field(None, description="Age") + age: int | None = Field(None, description="Age") class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") diff --git a/examples/multi_content_type.py b/examples/multi_content_type.py new file mode 100644 index 00000000..857528f1 --- /dev/null +++ b/examples/multi_content_type.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# @Author : llc +# @Time : 2024/12/27 15:30 +from flask import Request +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) + + +class DogBody(BaseModel): + a: int = None + b: str = None + + model_config = {"openapi_extra": {"content_type": "application/vnd.dog+json"}} + + +class CatBody(BaseModel): + c: int = None + d: str = None + + model_config = {"openapi_extra": {"content_type": "application/vnd.cat+json"}} + + +class BsonModel(BaseModel): + e: int = None + f: str = None + + model_config = {"openapi_extra": {"content_type": "application/bson"}} + + +class ContentTypeModel(BaseModel): + model_config = {"openapi_extra": {"content_type": "text/csv"}} + + +@app.post("/a", responses={200: DogBody | CatBody | ContentTypeModel | BsonModel}) +def index_a(body: DogBody | CatBody | ContentTypeModel | BsonModel): + """ + multiple content types examples. + + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + from bson import BSON + + obj = BSON(body.data).decode() + new_body = body.model_validate(obj=obj) + print(new_body) + else: + # DogBody or CatBody + ... + return {"hello": "world"} + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/examples/rest_demo.py b/examples/rest_demo.py index 90faa073..0765a59d 100644 --- a/examples/rest_demo.py +++ b/examples/rest_demo.py @@ -2,7 +2,6 @@ # @Author : llc # @Time : 2021/4/28 11:24 from http import HTTPStatus -from typing import Optional from pydantic import BaseModel, Field @@ -45,25 +44,25 @@ class BookPath(BaseModel): class BookQuery(BaseModel): - age: Optional[int] = Field(None, description="Age") + age: int | None = Field(None, description="Age") s_list: list[str] = Field(None, alias="s_list[]", description="some array") class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") class BookBodyWithID(BaseModel): bid: int = Field(..., description="book id") - age: Optional[int] = Field(None, ge=2, le=4, description="Age") + age: int | None = Field(None, ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") class BookResponse(BaseModel): code: int = Field(0, description="Status Code") message: str = Field("ok", description="Exception Information") - data: Optional[BookBodyWithID] + data: BookBodyWithID | None @app.get( diff --git a/flask_openapi3/blueprint.py b/flask_openapi3/blueprint.py index 6d5e44b8..361acfb6 100644 --- a/flask_openapi3/blueprint.py +++ b/flask_openapi3/blueprint.py @@ -2,7 +2,7 @@ # @Author : llc # @Time : 2022/4/1 16:54 import inspect -from typing import Any, Callable, Optional +from typing import Any, Callable from flask import Blueprint @@ -28,9 +28,9 @@ def __init__( name: str, import_name: str, *, - abp_tags: Optional[list[Tag]] = None, - abp_security: Optional[list[dict[str, list[str]]]] = None, - abp_responses: Optional[ResponseDict] = None, + abp_tags: list[Tag] | None = None, + abp_security: list[dict[str, list[str]]] | None = None, + abp_responses: ResponseDict | None = None, doc_ui: bool = True, operation_id_callback: Callable = get_operation_id_for_path, **kwargs: Any, @@ -111,16 +111,18 @@ def _collect_openapi_info( rule: str, func: Callable, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, method: str = HTTPMethod.GET, ) -> ParametersTuple: @@ -140,6 +142,8 @@ def _collect_openapi_info( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ if self.doc_ui is True and doc_ui is True: @@ -191,6 +195,12 @@ def _collect_openapi_info( parse_method(uri, method, self.paths, operation) # Parse parameters - return parse_parameters(func, components_schemas=self.components_schemas, operation=operation) + return parse_parameters( + func, + components_schemas=self.components_schemas, + operation=operation, + request_body_description=request_body_description, + request_body_required=request_body_required, + ) else: return parse_parameters(func, doc_ui=False) diff --git a/flask_openapi3/models/__init__.py b/flask_openapi3/models/__init__.py index d3e82250..cceefc73 100644 --- a/flask_openapi3/models/__init__.py +++ b/flask_openapi3/models/__init__.py @@ -9,8 +9,6 @@ https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#table-of-contents """ -from typing import Optional, Union - from flask import Request from pydantic import BaseModel @@ -57,13 +55,13 @@ class APISpec(BaseModel): openapi: str info: Info - servers: Optional[list[Server]] = None + servers: list[Server] | None = None paths: Paths - components: Optional[Components] = None - security: Optional[list[SecurityRequirement]] = None - tags: Optional[list[Tag]] = None - externalDocs: Optional[ExternalDocumentation] = None - webhooks: Optional[dict[str, Union[PathItem, Reference]]] = None + components: Components | None = None + security: list[SecurityRequirement] | None = None + tags: list[Tag] | None = None + externalDocs: ExternalDocumentation | None = None + webhooks: dict[str, PathItem | Reference] | None = None model_config = {"extra": "allow"} @@ -73,15 +71,15 @@ class OAuthConfig(BaseModel): https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/oauth2.md#oauth-20-configuration """ - clientId: Optional[str] = None - clientSecret: Optional[str] = None - realm: Optional[str] = None - appName: Optional[str] = None - scopeSeparator: Optional[str] = None - scopes: Optional[str] = None - additionalQueryStringParams: Optional[dict[str, str]] = None - useBasicAuthenticationWithAccessCodeGrant: Optional[bool] = False - usePkceWithAuthorizationCodeGrant: Optional[bool] = False + clientId: str | None = None + clientSecret: str | None = None + realm: str | None = None + appName: str | None = None + scopeSeparator: str | None = None + scopes: str | None = None + additionalQueryStringParams: dict[str, str] | None = None + useBasicAuthenticationWithAccessCodeGrant: bool | None = False + usePkceWithAuthorizationCodeGrant: bool | None = False class RawModel(Request): diff --git a/flask_openapi3/models/components.py b/flask_openapi3/models/components.py index d2ebe1fa..efc142b7 100644 --- a/flask_openapi3/models/components.py +++ b/flask_openapi3/models/components.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:36 -from typing import Any, Optional, Union +from typing import Any from pydantic import BaseModel, Field @@ -23,15 +23,15 @@ class Components(BaseModel): https://spec.openapis.org/oas/v3.1.0#components-object """ - schemas: Optional[dict[str, Union[Reference, Schema]]] = Field(None) - responses: Optional[dict[str, Union[Response, Reference]]] = None - parameters: Optional[dict[str, Union[Parameter, Reference]]] = None - examples: Optional[dict[str, Union[Example, Reference]]] = None - requestBodies: Optional[dict[str, Union[RequestBody, Reference]]] = None - headers: Optional[dict[str, Union[Header, Reference]]] = None - securitySchemes: Optional[dict[str, Union[SecurityScheme, dict[str, Any]]]] = None - links: Optional[dict[str, Union[Link, Reference]]] = None - callbacks: Optional[dict[str, Union[Callback, Reference]]] = None - pathItems: Optional[dict[str, Union[PathItem, Reference]]] = None + schemas: dict[str, Reference | Schema] | None = Field(None) + responses: dict[str, Response | Reference] | None = None + parameters: dict[str, Parameter | Reference] | None = None + examples: dict[str, Example | Reference] | None = None + requestBodies: dict[str, RequestBody | Reference] | None = None + headers: dict[str, Header | Reference] | None = None + securitySchemes: dict[str, SecurityScheme | dict[str, Any]] | None = None + links: dict[str, Link | Reference] | None = None + callbacks: dict[str, Callback | Reference] | None = None + pathItems: dict[str, PathItem | Reference] | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/contact.py b/flask_openapi3/models/contact.py index 6ad53b59..c8e5ea84 100644 --- a/flask_openapi3/models/contact.py +++ b/flask_openapi3/models/contact.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:37 -from typing import Optional from pydantic import BaseModel @@ -11,8 +10,8 @@ class Contact(BaseModel): https://spec.openapis.org/oas/v3.1.0#contact-object """ - name: Optional[str] = None - url: Optional[str] = None - email: Optional[str] = None + name: str | None = None + url: str | None = None + email: str | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/discriminator.py b/flask_openapi3/models/discriminator.py index 141b5176..a9901314 100644 --- a/flask_openapi3/models/discriminator.py +++ b/flask_openapi3/models/discriminator.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:41 -from typing import Optional from pydantic import BaseModel @@ -12,6 +11,6 @@ class Discriminator(BaseModel): """ propertyName: str - mapping: Optional[dict[str, str]] = None + mapping: dict[str, str] | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/encoding.py b/flask_openapi3/models/encoding.py index 8e8598d2..d1455a97 100644 --- a/flask_openapi3/models/encoding.py +++ b/flask_openapi3/models/encoding.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:41 -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Union from pydantic import BaseModel @@ -18,10 +18,10 @@ class Encoding(BaseModel): https://spec.openapis.org/oas/v3.1.0#encoding-object """ - contentType: Optional[str] = None - headers: Optional[dict[str, Union[Header, Reference]]] = None - style: Optional[str] = None - explode: Optional[bool] = None + contentType: str | None = None + headers: dict[str, Union[Header, Reference]] | None = None + style: str | None = None + explode: bool | None = None allowReserved: bool = False model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/example.py b/flask_openapi3/models/example.py index 833f2b42..a15ff481 100644 --- a/flask_openapi3/models/example.py +++ b/flask_openapi3/models/example.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:42 -from typing import Any, Optional +from typing import Any from pydantic import BaseModel @@ -11,9 +11,9 @@ class Example(BaseModel): https://spec.openapis.org/oas/v3.1.0#example-object """ - summary: Optional[str] = None - description: Optional[str] = None - value: Optional[Any] = None - externalValue: Optional[str] = None + summary: str | None = None + description: str | None = None + value: Any | None = None + externalValue: str | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/external_documentation.py b/flask_openapi3/models/external_documentation.py index 1ebb1c7f..14c1d53a 100644 --- a/flask_openapi3/models/external_documentation.py +++ b/flask_openapi3/models/external_documentation.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:43 -from typing import Optional from pydantic import BaseModel @@ -11,7 +10,7 @@ class ExternalDocumentation(BaseModel): https://spec.openapis.org/oas/v3.1.0#external-documentation-object """ - description: Optional[str] = None + description: str | None = None url: str model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/header.py b/flask_openapi3/models/header.py index de8316f5..418cb8de 100644 --- a/flask_openapi3/models/header.py +++ b/flask_openapi3/models/header.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:44 -from typing import Optional from .parameter import Parameter from .parameter_in_type import ParameterInType @@ -12,7 +11,7 @@ class Header(Parameter): https://spec.openapis.org/oas/v3.1.0#header-object """ - name: Optional[str] = None # type:ignore - param_in: Optional[ParameterInType] = None # type:ignore + name: str | None = None # type:ignore + param_in: ParameterInType | None = None # type:ignore model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/info.py b/flask_openapi3/models/info.py index 3f7a3dd7..ee45ff3e 100644 --- a/flask_openapi3/models/info.py +++ b/flask_openapi3/models/info.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2021/4/28 10:58 -from typing import Optional from pydantic import BaseModel @@ -15,11 +14,11 @@ class Info(BaseModel): """ title: str - summary: Optional[str] = None - description: Optional[str] = None - termsOfService: Optional[str] = None - contact: Optional[Contact] = None - license: Optional[License] = None + summary: str | None = None + description: str | None = None + termsOfService: str | None = None + contact: Contact | None = None + license: License | None = None version: str model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/license.py b/flask_openapi3/models/license.py index 58bb4390..652a8377 100644 --- a/flask_openapi3/models/license.py +++ b/flask_openapi3/models/license.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:45 -from typing import Optional from pydantic import BaseModel @@ -12,7 +11,7 @@ class License(BaseModel): """ name: str - identifier: Optional[str] = None - url: Optional[str] = None + identifier: str | None = None + url: str | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/link.py b/flask_openapi3/models/link.py index 69f8d2d2..c805bcb5 100644 --- a/flask_openapi3/models/link.py +++ b/flask_openapi3/models/link.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:45 -from typing import Any, Optional +from typing import Any from pydantic import BaseModel @@ -13,11 +13,11 @@ class Link(BaseModel): https://spec.openapis.org/oas/v3.1.0#link-object """ - operationRef: Optional[str] = None - operationId: Optional[str] = None - parameters: Optional[dict[str, Any]] = None - requestBody: Optional[Any] = None - description: Optional[str] = None - server: Optional[Server] = None + operationRef: str | None = None + operationId: str | None = None + parameters: dict[str, Any] | None = None + requestBody: Any | None = None + description: str | None = None + server: Server | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/media_type.py b/flask_openapi3/models/media_type.py index 5596b3f0..d356b88f 100644 --- a/flask_openapi3/models/media_type.py +++ b/flask_openapi3/models/media_type.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:46 -from typing import Any, Optional, Union +from typing import Any from pydantic import BaseModel, Field @@ -16,9 +16,9 @@ class MediaType(BaseModel): https://spec.openapis.org/oas/v3.1.0#media-type-object """ - media_type_schema: Optional[Union[Reference, Schema]] = Field(default=None, alias="schema") - example: Optional[Any] = None - examples: Optional[dict[str, Union[Example, Reference]]] = None - encoding: Optional[dict[str, Encoding]] = None + media_type_schema: Reference | Schema | None = Field(default=None, alias="schema") + example: Any | None = None + examples: dict[str, Example | Reference] | None = None + encoding: dict[str, Encoding] | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/flask_openapi3/models/oauth_flow.py b/flask_openapi3/models/oauth_flow.py index 10946fad..3dd4cff1 100644 --- a/flask_openapi3/models/oauth_flow.py +++ b/flask_openapi3/models/oauth_flow.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:47 -from typing import Optional from pydantic import BaseModel @@ -11,9 +10,9 @@ class OAuthFlow(BaseModel): https://spec.openapis.org/oas/v3.1.0#oauth-flow-object """ - authorizationUrl: Optional[str] = None - tokenUrl: Optional[str] = None - refreshUrl: Optional[str] = None + authorizationUrl: str | None = None + tokenUrl: str | None = None + refreshUrl: str | None = None scopes: dict[str, str] model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/oauth_flows.py b/flask_openapi3/models/oauth_flows.py index f12d47f8..bce11825 100644 --- a/flask_openapi3/models/oauth_flows.py +++ b/flask_openapi3/models/oauth_flows.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:47 -from typing import Optional from pydantic import BaseModel @@ -13,9 +12,9 @@ class OAuthFlows(BaseModel): https://spec.openapis.org/oas/v3.1.0#oauth-flows-object """ - implicit: Optional[OAuthFlow] = None - password: Optional[OAuthFlow] = None - clientCredentials: Optional[OAuthFlow] = None - authorizationCode: Optional[OAuthFlow] = None + implicit: OAuthFlow | None = None + password: OAuthFlow | None = None + clientCredentials: OAuthFlow | None = None + authorizationCode: OAuthFlow | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/operation.py b/flask_openapi3/models/operation.py index 06a3be37..904be27e 100644 --- a/flask_openapi3/models/operation.py +++ b/flask_openapi3/models/operation.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:48 -from typing import Optional, Union from pydantic import BaseModel @@ -20,18 +19,18 @@ class Operation(BaseModel): https://spec.openapis.org/oas/v3.1.0#operation-object """ - tags: Optional[list[str]] = None - summary: Optional[str] = None - description: Optional[str] = None - externalDocs: Optional[ExternalDocumentation] = None - operationId: Optional[str] = None - parameters: Optional[list[Parameter]] = None - requestBody: Optional[Union[RequestBody, Reference]] = None - responses: Optional[dict[str, Response]] = None - callbacks: Optional[dict[str, Callback]] = None + tags: list[str] | None = None + summary: str | None = None + description: str | None = None + externalDocs: ExternalDocumentation | None = None + operationId: str | None = None + parameters: list[Parameter] | None = None + requestBody: RequestBody | Reference | None = None + responses: dict[str, Response] | None = None + callbacks: dict[str, Callback] | None = None - deprecated: Optional[bool] = False - security: Optional[list[SecurityRequirement]] = None - servers: Optional[list[Server]] = None + deprecated: bool | None = False + security: list[SecurityRequirement] | None = None + servers: list[Server] | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/parameter.py b/flask_openapi3/models/parameter.py index 9e3147d8..0c5a5463 100644 --- a/flask_openapi3/models/parameter.py +++ b/flask_openapi3/models/parameter.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:49 -from typing import Any, Optional, Union +from typing import Any from pydantic import BaseModel, Field @@ -19,16 +19,16 @@ class Parameter(BaseModel): name: str param_in: ParameterInType = Field(alias="in") - description: Optional[str] = None - required: Optional[bool] = None - deprecated: Optional[bool] = None - allowEmptyValue: Optional[bool] = None - style: Optional[str] = None - explode: Optional[bool] = None - allowReserved: Optional[bool] = None - param_schema: Optional[Union[Reference, Schema]] = Field(default=None, alias="schema") - example: Optional[Any] = None - examples: Optional[dict[str, Union[Example, Reference]]] = None - content: Optional[dict[str, MediaType]] = None + description: str | None = None + required: bool | None = None + deprecated: bool | None = None + allowEmptyValue: bool | None = None + style: str | None = None + explode: bool | None = None + allowReserved: bool | None = None + param_schema: Reference | Schema | None = Field(default=None, alias="schema") + example: Any | None = None + examples: dict[str, Example | Reference] | None = None + content: dict[str, MediaType] | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/flask_openapi3/models/path_item.py b/flask_openapi3/models/path_item.py index f4d5ec7d..2131b4ae 100644 --- a/flask_openapi3/models/path_item.py +++ b/flask_openapi3/models/path_item.py @@ -1,36 +1,32 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:50 -import typing -from typing import Optional, Union from pydantic import BaseModel, Field +from .operation import Operation from .parameter import Parameter from .reference import Reference from .server import Server -if typing.TYPE_CHECKING: # pragma: no cover - from .operation import Operation - class PathItem(BaseModel): """ https://spec.openapis.org/oas/v3.1.0#path-item-object """ - ref: Optional[str] = Field(default=None, alias="$ref") - summary: Optional[str] = None - description: Optional[str] = None - get: Optional["Operation"] = None - put: Optional["Operation"] = None - post: Optional["Operation"] = None - delete: Optional["Operation"] = None - options: Optional["Operation"] = None - head: Optional["Operation"] = None - patch: Optional["Operation"] = None - trace: Optional["Operation"] = None - servers: Optional[list[Server]] = None - parameters: Optional[list[Union[Parameter, Reference]]] = None + ref: str | None = Field(default=None, alias="$ref") + summary: str | None = None + description: str | None = None + get: Operation | None = None + put: Operation | None = None + post: Operation | None = None + delete: Operation | None = None + options: Operation | None = None + head: Operation | None = None + patch: Operation | None = None + trace: Operation | None = None + servers: list[Server] | None = None + parameters: list[Parameter | Reference] | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/flask_openapi3/models/request_body.py b/flask_openapi3/models/request_body.py index 054ade76..669b7948 100644 --- a/flask_openapi3/models/request_body.py +++ b/flask_openapi3/models/request_body.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:53 -from typing import Optional from pydantic import BaseModel @@ -13,8 +12,8 @@ class RequestBody(BaseModel): https://spec.openapis.org/oas/v3.1.0#request-body-object """ - description: Optional[str] = None + description: str | None = None content: dict[str, MediaType] - required: Optional[bool] = True + required: bool | None = True model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/response.py b/flask_openapi3/models/response.py index 7e2b8c0c..9ed93c20 100644 --- a/flask_openapi3/models/response.py +++ b/flask_openapi3/models/response.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:54 -from typing import Optional, Union from pydantic import BaseModel @@ -17,8 +16,8 @@ class Response(BaseModel): """ description: str - headers: Optional[dict[str, Union[Header, Reference]]] = None - content: Optional[dict[str, MediaType]] = None - links: Optional[dict[str, Union[Link, Reference]]] = None + headers: dict[str, Header | Reference] | None = None + content: dict[str, MediaType] | None = None + links: dict[str, Link | Reference] | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/responses.py b/flask_openapi3/models/responses.py index 753a6f8e..ffb6b314 100644 --- a/flask_openapi3/models/responses.py +++ b/flask_openapi3/models/responses.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:55 -from typing import Union from .reference import Reference from .response import Response @@ -9,4 +8,4 @@ """ https://spec.openapis.org/oas/v3.1.0#responses-object """ -Responses = dict[str, Union[Response, Reference]] +Responses = dict[str, Response | Reference] diff --git a/flask_openapi3/models/schema.py b/flask_openapi3/models/schema.py index 068700f8..c02761aa 100644 --- a/flask_openapi3/models/schema.py +++ b/flask_openapi3/models/schema.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:55 -from typing import Any, Optional, Union +from typing import Any, Union from pydantic import BaseModel, Field @@ -17,42 +17,42 @@ class Schema(BaseModel): https://spec.openapis.org/oas/v3.1.0#schema-object """ - ref: Optional[str] = Field(alias="$ref", default=None) - title: Optional[str] = None - multipleOf: Optional[float] = Field(default=None, gt=0.0) - maximum: Optional[Union[int, float]] = None - exclusiveMaximum: Optional[float] = None - minimum: Optional[float] = None - exclusiveMinimum: Optional[float] = None - maxLength: Optional[int] = Field(default=None, ge=0) - minLength: Optional[int] = Field(default=None, ge=0) - pattern: Optional[str] = None - maxItems: Optional[int] = Field(default=None, ge=0) - minItems: Optional[int] = Field(default=None, ge=0) - uniqueItems: Optional[bool] = None - maxProperties: Optional[int] = Field(default=None, ge=0) - minProperties: Optional[int] = Field(default=None, ge=0) - required: Optional[list[str]] = Field(default=None) - enum: Union[None, list[Any]] = Field(default=None) - type: Optional[DataType] = Field(default=None) - allOf: Optional[list[Union[Reference, "Schema"]]] = None - oneOf: Optional[list[Union[Reference, "Schema"]]] = None - anyOf: Optional[list[Union[Reference, "Schema"]]] = None - schema_not: Optional[Union[Reference, "Schema"]] = Field(default=None, alias="not") - items: Optional[Union[Reference, "Schema"]] = None - properties: Optional[dict[str, Union[Reference, "Schema"]]] = None - prefixItems: Optional[list[Union[Reference, "Schema"]]] = None - additionalProperties: Optional[Union[bool, Reference, "Schema"]] = None - description: Optional[str] = None - schema_format: Optional[str] = Field(default=None, alias="format") - default: Optional[Any] = None - nullable: Optional[bool] = None - discriminator: Optional[Discriminator] = None - readOnly: Optional[bool] = None - writeOnly: Optional[bool] = None - xml: Optional[XML] = None - externalDocs: Optional[ExternalDocumentation] = None - example: Optional[Any] = None - deprecated: Optional[bool] = None + ref: str | None = Field(alias="$ref", default=None) + title: str | None = None + multipleOf: float | None = Field(default=None, gt=0.0) + maximum: int | float | None = None + exclusiveMaximum: float | None = None + minimum: float | None = None + exclusiveMinimum: float | None = None + maxLength: int | None = Field(default=None, ge=0) + minLength: int | None = Field(default=None, ge=0) + pattern: str | None = None + maxItems: int | None = Field(default=None, ge=0) + minItems: int | None = Field(default=None, ge=0) + uniqueItems: bool | None = None + maxProperties: int | None = Field(default=None, ge=0) + minProperties: int | None = Field(default=None, ge=0) + required: list[str] | None = Field(default=None) + enum: None | list[Any] = Field(default=None) + type: DataType | None = Field(default=None) + allOf: list[Union[Reference, "Schema"]] | None = None + oneOf: list[Union[Reference, "Schema"]] | None = None + anyOf: list[Union[Reference, "Schema"]] | None = None + schema_not: Union[Reference, "Schema"] | None = Field(default=None, alias="not") + items: Union[Reference, "Schema"] | None = None + properties: dict[str, Union[Reference, "Schema"]] | None = None + prefixItems: list[Union[Reference, "Schema"]] | None = None + additionalProperties: Union[bool, Reference, "Schema"] | None = None + description: str | None = None + schema_format: str | None = Field(default=None, alias="format") + default: Any | None = None + nullable: bool | None = None + discriminator: Discriminator | None = None + readOnly: bool | None = None + writeOnly: bool | None = None + xml: XML | None = None + externalDocs: ExternalDocumentation | None = None + example: Any | None = None + deprecated: bool | None = None model_config = {"populate_by_name": True} diff --git a/flask_openapi3/models/security_scheme.py b/flask_openapi3/models/security_scheme.py index 747f3f44..db02f372 100644 --- a/flask_openapi3/models/security_scheme.py +++ b/flask_openapi3/models/security_scheme.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:56 -from typing import Optional from pydantic import BaseModel, Field @@ -15,12 +14,12 @@ class SecurityScheme(BaseModel): """ type: str - description: Optional[str] = None - name: Optional[str] = None - security_scheme_in: Optional[SecuritySchemeInType] = Field(default=None, alias="in") - scheme: Optional[str] = None - bearerFormat: Optional[str] = None - flows: Optional[OAuthFlows] = None - openIdConnectUrl: Optional[str] = None + description: str | None = None + name: str | None = None + security_scheme_in: SecuritySchemeInType | None = Field(default=None, alias="in") + scheme: str | None = None + bearerFormat: str | None = None + flows: OAuthFlows | None = None + openIdConnectUrl: str | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/flask_openapi3/models/server.py b/flask_openapi3/models/server.py index 1996b7cc..d5d335ea 100644 --- a/flask_openapi3/models/server.py +++ b/flask_openapi3/models/server.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2021/4/28 11:26 -from typing import Optional from pydantic import BaseModel @@ -14,7 +13,7 @@ class Server(BaseModel): """ url: str - description: Optional[str] = None - variables: Optional[dict[str, ServerVariable]] = None + description: str | None = None + variables: dict[str, ServerVariable] | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/server_variable.py b/flask_openapi3/models/server_variable.py index 812e32a9..7c28c158 100644 --- a/flask_openapi3/models/server_variable.py +++ b/flask_openapi3/models/server_variable.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:57 -from typing import Optional from pydantic import BaseModel, Field @@ -11,8 +10,8 @@ class ServerVariable(BaseModel): https://spec.openapis.org/oas/v3.1.0#server-variable-object """ - enum: Optional[list[str]] = Field(None, min_length=1) + enum: list[str] | None = Field(None, min_length=1) default: str - description: Optional[str] = None + description: str | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/tag.py b/flask_openapi3/models/tag.py index b02588e5..9282b082 100644 --- a/flask_openapi3/models/tag.py +++ b/flask_openapi3/models/tag.py @@ -1,5 +1,3 @@ -from typing import Optional - from pydantic import BaseModel from .external_documentation import ExternalDocumentation @@ -11,7 +9,7 @@ class Tag(BaseModel): """ name: str - description: Optional[str] = None - externalDocs: Optional[ExternalDocumentation] = None + description: str | None = None + externalDocs: ExternalDocumentation | None = None model_config = {"extra": "allow"} diff --git a/flask_openapi3/models/validation_error.py b/flask_openapi3/models/validation_error.py index 961788ff..0589ef6d 100644 --- a/flask_openapi3/models/validation_error.py +++ b/flask_openapi3/models/validation_error.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2021/5/10 14:51 -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, Field @@ -12,8 +12,8 @@ class ValidationErrorModel(BaseModel): loc: list[Any] = Field(..., title="Location", description="The error's location as a list.") msg: str = Field(..., title="Message", description="A human readable explanation of the error.") input: Any = Field(..., title="Input", description="The input provided for validation.") - url: Optional[str] = Field(None, title="URL", description="The URL to further information about the error.") - ctx: Optional[dict[str, Any]] = Field( + url: str | None = Field(None, title="URL", description="The URL to further information about the error.") + ctx: dict[str, Any] | None = Field( None, title="Error context", description="An optional object which contains values required to render the error message.", diff --git a/flask_openapi3/models/xml.py b/flask_openapi3/models/xml.py index 0af900e1..0bda6e7b 100644 --- a/flask_openapi3/models/xml.py +++ b/flask_openapi3/models/xml.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2023/7/4 9:58 -from typing import Optional from pydantic import BaseModel @@ -11,9 +10,9 @@ class XML(BaseModel): https://spec.openapis.org/oas/v3.1.0#xml-object """ - name: Optional[str] = None - namespace: Optional[str] = None - prefix: Optional[str] = None + name: str | None = None + namespace: str | None = None + prefix: str | None = None attribute: bool = False wrapped: bool = False diff --git a/flask_openapi3/openapi.py b/flask_openapi3/openapi.py index b8dd711b..dbc1187b 100644 --- a/flask_openapi3/openapi.py +++ b/flask_openapi3/openapi.py @@ -3,18 +3,13 @@ # @Time : 2021/4/30 14:25 import os import re -import sys from importlib import import_module -from typing import Any, Callable, Optional, Type, Union +from importlib.metadata import entry_points +from typing import Any, Callable, Type from flask import Blueprint, Flask, render_template_string from pydantic import BaseModel -if sys.version_info >= (3, 10): - from importlib.metadata import entry_points -else: # pragma: no cover - from importlib_metadata import entry_points # type: ignore - from .blueprint import APIBlueprint from .commands import openapi_command from .models import ( @@ -52,14 +47,14 @@ def __init__( self, import_name: str, *, - info: Optional[Info] = None, - security_schemes: Optional[SecuritySchemesDict] = None, - responses: Optional[ResponseDict] = None, - servers: Optional[list[Server]] = None, - external_docs: Optional[ExternalDocumentation] = None, + info: Info | None = None, + security_schemes: SecuritySchemesDict | None = None, + responses: ResponseDict | None = None, + servers: list[Server] | None = None, + external_docs: ExternalDocumentation | None = None, operation_id_callback: Callable = get_operation_id_for_path, - openapi_extensions: Optional[dict[str, Any]] = None, - validation_error_status: Union[str, int] = 422, + openapi_extensions: dict[str, Any] | None = None, + validation_error_status: str | int = 422, validation_error_model: Type[BaseModel] = ValidationErrorModel, validation_error_callback: Callable = make_validation_error_response, doc_ui: bool = True, @@ -313,7 +308,7 @@ def register_api(self, api: APIBlueprint, **options: Any) -> None: self.register_blueprint(api, **options) def register_api_view( - self, api_view: APIView, url_prefix: Optional[str] = None, view_kwargs: Optional[dict[Any, Any]] = None + self, api_view: APIView, url_prefix: str | None = None, view_kwargs: dict[Any, Any] | None = None ) -> None: """ Register APIView @@ -365,16 +360,18 @@ def _collect_openapi_info( rule: str, func: Callable, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, method: str = HTTPMethod.GET, ) -> ParametersTuple: @@ -394,6 +391,8 @@ def _collect_openapi_info( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. method: HTTP method for the operation. Defaults to GET. """ @@ -442,6 +441,12 @@ def _collect_openapi_info( parse_method(uri, method, self.paths, operation) # Parse parameters - return parse_parameters(func, components_schemas=self.components_schemas, operation=operation) + return parse_parameters( + func, + components_schemas=self.components_schemas, + operation=operation, + request_body_description=request_body_description, + request_body_required=request_body_required, + ) else: return parse_parameters(func, doc_ui=False) diff --git a/flask_openapi3/request.py b/flask_openapi3/request.py index a6e83add..cac792ee 100644 --- a/flask_openapi3/request.py +++ b/flask_openapi3/request.py @@ -5,14 +5,15 @@ import json from functools import wraps from json import JSONDecodeError -from typing import Any, Optional, Type +from types import UnionType +from typing import Any, Type, Union, get_args, get_origin from flask import abort, current_app, request -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, RootModel, ValidationError from pydantic.fields import FieldInfo from werkzeug.datastructures.structures import MultiDict -from .utils import parse_parameters +from flask_openapi3.utils import is_application_json, parse_parameters def _get_list_value(model: Type[BaseModel], args: MultiDict, model_field_key: str, model_field_value: FieldInfo): @@ -60,7 +61,7 @@ def _validate_header(header: Type[BaseModel], func_kwargs: dict): value = request_headers.get(key_alias_title) else: key = model_field_key - value = request_headers[key_title] + value = request_headers.get(key_title) if value is not None: header_dict[key] = value if model_field_schema.get("type") == "null": @@ -146,23 +147,31 @@ def _validate_form(form: Type[BaseModel], func_kwargs: dict): def _validate_body(body: Type[BaseModel], func_kwargs: dict): - obj = request.get_json(silent=True) - if isinstance(obj, str): - body_model = body.model_validate_json(json_data=obj) + if is_application_json(request.mimetype): + if get_origin(body) in (Union, UnionType): + root_model_list = [model for model in get_args(body)] + Body = RootModel[Union[tuple(root_model_list)]] # type: ignore + else: + Body = body # type: ignore + obj = request.get_json(silent=True) + if isinstance(obj, str): + body_model = Body.model_validate_json(json_data=obj) + else: + body_model = Body.model_validate(obj=obj) + func_kwargs["body"] = body_model else: - body_model = body.model_validate(obj=obj) - func_kwargs["body"] = body_model + func_kwargs["body"] = request def _validate_request( - header: Optional[Type[BaseModel]] = None, - cookie: Optional[Type[BaseModel]] = None, - path: Optional[Type[BaseModel]] = None, - query: Optional[Type[BaseModel]] = None, - form: Optional[Type[BaseModel]] = None, - body: Optional[Type[BaseModel]] = None, - raw: Optional[Type[BaseModel]] = None, - path_kwargs: Optional[dict[Any, Any]] = None, + header: Type[BaseModel] | None = None, + cookie: Type[BaseModel] | None = None, + path: Type[BaseModel] | None = None, + query: Type[BaseModel] | None = None, + form: Type[BaseModel] | None = None, + body: Type[BaseModel] | None = None, + raw: Type[BaseModel] | None = None, + path_kwargs: dict[Any, Any] | None = None, ) -> dict: """ Validate requests and responses. diff --git a/flask_openapi3/scaffold.py b/flask_openapi3/scaffold.py index 309ad491..795b2727 100644 --- a/flask_openapi3/scaffold.py +++ b/flask_openapi3/scaffold.py @@ -3,7 +3,7 @@ # @Time : 2022/8/30 9:40 import inspect from functools import wraps -from typing import Any, Callable, Optional +from typing import Any, Callable from flask.wrappers import Response as FlaskResponse @@ -19,16 +19,18 @@ def _collect_openapi_info( rule: str, func: Callable, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, method: str = HTTPMethod.GET, ) -> ParametersTuple: @@ -120,16 +122,16 @@ def get( self, rule: str, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -182,16 +184,18 @@ def post( self, rule: str, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -211,6 +215,8 @@ def post( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -228,6 +234,8 @@ def decorator(func) -> Callable: security=security, servers=servers, openapi_extensions=openapi_extensions, + request_body_description=request_body_description, + request_body_required=request_body_required, doc_ui=doc_ui, method=HTTPMethod.POST, ) @@ -244,16 +252,18 @@ def put( self, rule: str, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -273,6 +283,8 @@ def put( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -290,6 +302,8 @@ def decorator(func) -> Callable: security=security, servers=servers, openapi_extensions=openapi_extensions, + request_body_description=request_body_description, + request_body_required=request_body_required, doc_ui=doc_ui, method=HTTPMethod.PUT, ) @@ -306,16 +320,18 @@ def delete( self, rule: str, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -335,6 +351,8 @@ def delete( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -352,6 +370,8 @@ def decorator(func) -> Callable: security=security, servers=servers, openapi_extensions=openapi_extensions, + request_body_description=request_body_description, + request_body_required=request_body_required, doc_ui=doc_ui, method=HTTPMethod.DELETE, ) @@ -368,16 +388,18 @@ def patch( self, rule: str, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, **options: Any, ) -> Callable: @@ -397,6 +419,8 @@ def patch( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -414,6 +438,8 @@ def decorator(func) -> Callable: security=security, servers=servers, openapi_extensions=openapi_extensions, + request_body_description=request_body_description, + request_body_required=request_body_required, doc_ui=doc_ui, method=HTTPMethod.PATCH, ) diff --git a/flask_openapi3/types.py b/flask_openapi3/types.py index 4b1f2cc8..0984c6af 100644 --- a/flask_openapi3/types.py +++ b/flask_openapi3/types.py @@ -2,26 +2,28 @@ # @Author : llc # @Time : 2023/7/9 15:25 from http import HTTPStatus -from typing import Any, Optional, Type, Union +from typing import Any, Type, TypeVar, Union from pydantic import BaseModel from .models import RawModel, SecurityScheme -_ResponseDictValue = Union[Type[BaseModel], dict[Any, Any], None] +_MultiBaseModel = TypeVar("_MultiBaseModel", bound=Type[BaseModel]) -ResponseDict = dict[Union[str, int, HTTPStatus], _ResponseDictValue] +_ResponseDictValue = Union[Type[BaseModel], _MultiBaseModel, dict[Any, Any], None] + +ResponseDict = dict[str | int | HTTPStatus, _ResponseDictValue] ResponseStrKeyDict = dict[str, _ResponseDictValue] -SecuritySchemesDict = dict[str, Union[SecurityScheme, dict[str, Any]]] +SecuritySchemesDict = dict[str, SecurityScheme | dict[str, Any]] ParametersTuple = tuple[ - Optional[Type[BaseModel]], - Optional[Type[BaseModel]], - Optional[Type[BaseModel]], - Optional[Type[BaseModel]], - Optional[Type[BaseModel]], - Optional[Type[BaseModel]], - Optional[Type[RawModel]], + Type[BaseModel] | None, + Type[BaseModel] | None, + Type[BaseModel] | None, + Type[BaseModel] | None, + Type[BaseModel] | None, + Type[BaseModel] | None, + Type[RawModel] | None, ] diff --git a/flask_openapi3/utils.py b/flask_openapi3/utils.py index aa5aa9c9..5847ef0c 100644 --- a/flask_openapi3/utils.py +++ b/flask_openapi3/utils.py @@ -7,7 +7,17 @@ import sys from enum import Enum from http import HTTPStatus -from typing import Any, Callable, DefaultDict, Optional, Type, get_type_hints +from types import UnionType +from typing import ( + Any, + Callable, + DefaultDict, + Type, + Union, + get_args, + get_origin, + get_type_hints, +) from flask import current_app, make_response from flask.wrappers import Response as FlaskResponse @@ -53,9 +63,9 @@ class HTTPMethod(str, Enum): def get_operation( func: Callable, *, - summary: Optional[str] = None, - description: Optional[str] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + summary: str | None = None, + description: str | None = None, + openapi_extensions: dict[str, Any] | None = None, ) -> Operation: """ Return an Operation object with the specified summary and description. @@ -265,6 +275,11 @@ def parse_form( ) -> tuple[dict[str, MediaType], dict]: """Parses a form model and returns a list of parameters and component schemas.""" schema = get_model_schema(form) + + model_config: DefaultDict[str, Any] = form.model_config # type: ignore + openapi_extra = model_config.get("openapi_extra", {}) + content_type = openapi_extra.get("content_type", "multipart/form-data") + components_schemas = dict() properties = schema.get("properties", {}) @@ -277,14 +292,22 @@ def parse_form( for k, v in properties.items(): if v.get("type") == "array": encoding[k] = Encoding(style="form", explode=True) - content = { - "multipart/form-data": MediaType( - schema=Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{title}"}), - ) - } + + media_type = MediaType(**{"schema": Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{title}"})}) + + if openapi_extra: + openapi_extra_keys = openapi_extra.keys() + if "example" in openapi_extra_keys: + media_type.example = openapi_extra.get("example") + if "examples" in openapi_extra_keys: + media_type.examples = openapi_extra.get("examples") + if "encoding" in openapi_extra_keys: + media_type.encoding = openapi_extra.get("encoding") + if encoding: - content["multipart/form-data"].encoding = encoding + media_type.encoding = encoding + content = {content_type: media_type} # Parse definitions definitions = schema.get("$defs", {}) for name, value in definitions.items(): @@ -297,69 +320,128 @@ def parse_body( body: Type[BaseModel], ) -> tuple[dict[str, MediaType], dict]: """Parses a body model and returns a list of parameters and component schemas.""" - schema = get_model_schema(body) - components_schemas = dict() - original_title = schema.get("title") or body.__name__ - title = normalize_name(original_title) - components_schemas[title] = Schema(**schema) - content = {"application/json": MediaType(schema=Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{title}"}))} + content = {} + components_schemas = {} - # Parse definitions - definitions = schema.get("$defs", {}) - for name, value in definitions.items(): - components_schemas[name] = Schema(**value) + def _parse_body(_model): + model_config: DefaultDict[str, Any] = _model.model_config # type: ignore + openapi_extra = model_config.get("openapi_extra", {}) + content_type = openapi_extra.get("content_type", "application/json") + + if not is_application_json(content_type): + content_schema = openapi_extra.get("content_schema", {"type": DataType.STRING}) + content[content_type] = MediaType(**{"schema": content_schema}) + return + + schema = get_model_schema(_model) + + original_title = schema.get("title") or _model.__name__ + title = normalize_name(original_title) + components_schemas[title] = Schema(**schema) + + media_type = MediaType(**{"schema": Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{title}"})}) + + if openapi_extra: + openapi_extra_keys = openapi_extra.keys() + if "example" in openapi_extra_keys: + media_type.example = openapi_extra.get("example") + if "examples" in openapi_extra_keys: + media_type.examples = openapi_extra.get("examples") + if "encoding" in openapi_extra_keys: + media_type.encoding = openapi_extra.get("encoding") + + content[content_type] = media_type + + # Parse definitions + definitions = schema.get("$defs", {}) + for name, value in definitions.items(): + components_schemas[name] = Schema(**value) + + if get_origin(body) in (Union, UnionType): + for model in get_args(body): + _parse_body(model) + else: + _parse_body(body) return content, components_schemas def get_responses(responses: ResponseStrKeyDict, components_schemas: dict, operation: Operation) -> None: - _responses = {} - _schemas = {} + _responses: dict = {} + _schemas: dict = {} + + def _parse_response(_key, _model): + model_config: DefaultDict[str, Any] = _model.model_config # type: ignore + openapi_extra = model_config.get("openapi_extra", {}) + content_type = openapi_extra.get("content_type", "application/json") + + if not is_application_json(content_type): + content_schema = openapi_extra.get("content_schema", {"type": DataType.STRING}) + media_type = MediaType(**{"schema": content_schema}) + if _responses.get(_key): + _responses[_key].content[content_type] = media_type + else: + _responses[_key] = Response(description=HTTP_STATUS.get(_key, ""), content={content_type: media_type}) + return + + schema = get_model_schema(_model, mode="serialization") + # OpenAPI 3 support ^[a-zA-Z0-9\.\-_]+$ so we should normalize __name__ + original_title = schema.get("title") or _model.__name__ + name = normalize_name(original_title) + + media_type = MediaType(**{"schema": Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{name}"})}) + + if openapi_extra: + openapi_extra_keys = openapi_extra.keys() + if "example" in openapi_extra_keys: + media_type.example = openapi_extra.get("example") + if "examples" in openapi_extra_keys: + media_type.examples = openapi_extra.get("examples") + if "encoding" in openapi_extra_keys: + media_type.encoding = openapi_extra.get("encoding") + if _responses.get(_key): + _responses[_key].content[content_type] = media_type + else: + _responses[_key] = Response(description=HTTP_STATUS.get(_key, ""), content={content_type: media_type}) + + _schemas[name] = Schema(**schema) + definitions = schema.get("$defs") + if definitions: + # Add schema definitions to _schemas + for name, value in definitions.items(): + _schemas[normalize_name(name)] = Schema(**value) for key, response in responses.items(): - if response is None: + if isinstance(response, dict) and "model" in response: + response_model = response.get("model") + response_description = response.get("description") + response_headers = response.get("headers") + response_links = response.get("links") + else: + response_model = response + response_description = None + response_headers = None + response_links = None + + if response_model is None: # If the response is None, it means HTTP status code "204" (No Content) _responses[key] = Response(description=HTTP_STATUS.get(key, "")) - elif isinstance(response, dict): - response["description"] = response.get("description", HTTP_STATUS.get(key, "")) - _responses[key] = Response(**response) + elif isinstance(response_model, dict): + response_model["description"] = response_model.get("description", HTTP_STATUS.get(key, "")) + _responses[key] = Response(**response_model) + elif get_origin(response_model) in [UnionType, Union]: + for model in get_args(response_model): + _parse_response(key, model) else: - # OpenAPI 3 support ^[a-zA-Z0-9\.\-_]+$ so we should normalize __name__ - schema = get_model_schema(response, mode="serialization") - original_title = schema.get("title") or response.__name__ - name = normalize_name(original_title) - _responses[key] = Response( - description=HTTP_STATUS.get(key, ""), - content={"application/json": MediaType(schema=Schema(**{"$ref": f"{OPENAPI3_REF_PREFIX}/{name}"}))}, - ) - - model_config: DefaultDict[str, Any] = response.model_config # type: ignore - openapi_extra = model_config.get("openapi_extra", {}) - if openapi_extra: - openapi_extra_keys = openapi_extra.keys() - # Add additional information from model_config to the response - if "description" in openapi_extra_keys: - _responses[key].description = openapi_extra.get("description") - if "headers" in openapi_extra_keys: - _responses[key].headers = openapi_extra.get("headers") - if "links" in openapi_extra_keys: - _responses[key].links = openapi_extra.get("links") - _content = _responses[key].content - if "example" in openapi_extra_keys: - _content["application/json"].example = openapi_extra.get("example") # type: ignore - if "examples" in openapi_extra_keys: - _content["application/json"].examples = openapi_extra.get("examples") # type: ignore - if "encoding" in openapi_extra_keys: - _content["application/json"].encoding = openapi_extra.get("encoding") # type: ignore - _content.update(openapi_extra.get("content", {})) # type: ignore - - _schemas[name] = Schema(**schema) - definitions = schema.get("$defs") - if definitions: - # Add schema definitions to _schemas - for name, value in definitions.items(): - _schemas[normalize_name(name)] = Schema(**value) + _parse_response(key, response_model) + + if response_description is not None: + _responses[key].description = response_description + if response_headers is not None: + _responses[key].headers = response_headers + if response_links is not None: + _responses[key].links = response_links components_schemas.update(**_schemas) operation.responses = _responses @@ -395,8 +477,10 @@ def parse_and_store_tags( def parse_parameters( func: Callable, *, - components_schemas: Optional[dict] = None, - operation: Optional[Operation] = None, + components_schemas: dict | None = None, + operation: Operation | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, ) -> ParametersTuple: """ @@ -407,6 +491,8 @@ def parse_parameters( func: The function to parse the parameters from. components_schemas: Dictionary to store the parsed components schemas (default: None). operation: Operation object to populate with parsed parameters (default: None). + request_body_description: A brief description of the request body (default: None). + request_body_required: Determines if the request body is required in the request (default: True). doc_ui: Flag indicating whether to return types for documentation UI (default: True). Returns: @@ -427,13 +513,13 @@ def parse_parameters( annotations = get_type_hints(func) # Get the types for header, cookie, path, query, form, and body parameters - header: Optional[Type[BaseModel]] = annotations.get("header") - cookie: Optional[Type[BaseModel]] = annotations.get("cookie") - path: Optional[Type[BaseModel]] = annotations.get("path") - query: Optional[Type[BaseModel]] = annotations.get("query") - form: Optional[Type[BaseModel]] = annotations.get("form") - body: Optional[Type[BaseModel]] = annotations.get("body") - raw: Optional[Type[RawModel]] = annotations.get("raw") + header: Type[BaseModel] | None = annotations.get("header") + cookie: Type[BaseModel] | None = annotations.get("cookie") + path: Type[BaseModel] | None = annotations.get("path") + query: Type[BaseModel] | None = annotations.get("query") + form: Type[BaseModel] | None = annotations.get("form") + body: Type[BaseModel] | None = annotations.get("body") + raw: Type[RawModel] | None = annotations.get("raw") # If doc_ui is False, return the types without further processing if doc_ui is False: @@ -465,47 +551,31 @@ def parse_parameters( _content, _components_schemas = parse_form(form) components_schemas.update(**_components_schemas) request_body = RequestBody(content=_content, required=True) - model_config: DefaultDict[str, Any] = form.model_config # type: ignore - openapi_extra = model_config.get("openapi_extra", {}) - if openapi_extra: - openapi_extra_keys = openapi_extra.keys() - if "description" in openapi_extra_keys: - request_body.description = openapi_extra.get("description") - if "example" in openapi_extra_keys: - request_body.content["multipart/form-data"].example = openapi_extra.get("example") - if "examples" in openapi_extra_keys: - request_body.content["multipart/form-data"].examples = openapi_extra.get("examples") - if "encoding" in openapi_extra_keys: - request_body.content["multipart/form-data"].encoding = openapi_extra.get("encoding") + if request_body_description: + request_body.description = request_body_description + request_body.required = request_body_required operation.requestBody = request_body if body: _content, _components_schemas = parse_body(body) components_schemas.update(**_components_schemas) request_body = RequestBody(content=_content, required=True) - model_config: DefaultDict[str, Any] = body.model_config # type: ignore - openapi_extra = model_config.get("openapi_extra", {}) - if openapi_extra: - openapi_extra_keys = openapi_extra.keys() - if "description" in openapi_extra_keys: - request_body.description = openapi_extra.get("description") - request_body.required = openapi_extra.get("required", True) - if "example" in openapi_extra_keys: - request_body.content["application/json"].example = openapi_extra.get("example") - if "examples" in openapi_extra_keys: - request_body.content["application/json"].examples = openapi_extra.get("examples") - if "encoding" in openapi_extra_keys: - request_body.content["application/json"].encoding = openapi_extra.get("encoding") + if request_body_description: + request_body.description = request_body_description + request_body.required = request_body_required operation.requestBody = request_body if raw: _content = {} for mimetype in raw.mimetypes: - if mimetype.startswith("application/json"): - _content[mimetype] = MediaType(schema=Schema(type=DataType.OBJECT)) + if is_application_json(mimetype): + _content[mimetype] = MediaType(**{"schema": Schema(type=DataType.OBJECT)}) else: - _content[mimetype] = MediaType(schema=Schema(type=DataType.STRING)) + _content[mimetype] = MediaType(**{"schema": Schema(type=DataType.STRING)}) request_body = RequestBody(content=_content) + if request_body_description: + request_body.description = request_body_description + request_body.required = request_body_required operation.requestBody = request_body if parameters: @@ -595,3 +665,7 @@ def convert_responses_key_to_string(responses: ResponseDict) -> ResponseStrKeyDi def normalize_name(name: str) -> str: return re.sub(r"[^\w.\-]", "_", name) + + +def is_application_json(content_type: str) -> bool: + return "application" in content_type and "json" in content_type diff --git a/flask_openapi3/view.py b/flask_openapi3/view.py index abefd795..44cfc0b6 100644 --- a/flask_openapi3/view.py +++ b/flask_openapi3/view.py @@ -2,7 +2,7 @@ # @Author : llc # @Time : 2022/10/14 16:09 import typing -from typing import Any, Callable, Optional +from typing import Any, Callable from .models import ExternalDocumentation, Server, Tag from .types import ResponseDict @@ -25,10 +25,10 @@ class APIView: def __init__( self, - url_prefix: Optional[str] = None, - view_tags: Optional[list[Tag]] = None, - view_security: Optional[list[dict[str, list[str]]]] = None, - view_responses: Optional[ResponseDict] = None, + url_prefix: str | None = None, + view_tags: list[Tag] | None = None, + view_security: list[dict[str, list[str]]] | None = None, + view_responses: ResponseDict | None = None, doc_ui: bool = True, operation_id_callback: Callable = get_operation_id_for_path, ): @@ -100,16 +100,18 @@ def wrapper(cls): def doc( self, *, - tags: Optional[list[Tag]] = None, - summary: Optional[str] = None, - description: Optional[str] = None, - external_docs: Optional[ExternalDocumentation] = None, - operation_id: Optional[str] = None, - responses: Optional[ResponseDict] = None, - deprecated: Optional[bool] = None, - security: Optional[list[dict[str, list[Any]]]] = None, - servers: Optional[list[Server]] = None, - openapi_extensions: Optional[dict[str, Any]] = None, + tags: list[Tag] | None = None, + summary: str | None = None, + description: str | None = None, + external_docs: ExternalDocumentation | None = None, + operation_id: str | None = None, + responses: ResponseDict | None = None, + deprecated: bool | None = None, + security: list[dict[str, list[Any]]] | None = None, + servers: list[Server] | None = None, + openapi_extensions: dict[str, Any] | None = None, + request_body_description: str | None = None, + request_body_required: bool | None = True, doc_ui: bool = True, ) -> Callable: """ @@ -127,6 +129,8 @@ def doc( security: A declaration of which security mechanisms can be used for this operation. servers: An alternative server array to service this operation. openapi_extensions: Allows extensions to the OpenAPI Schema. + request_body_description: A brief description of the request body. + request_body_required: Determines if the request body is required in the request. doc_ui: Declares this operation to be shown. Default to True. """ @@ -171,7 +175,13 @@ def decorator(func): parse_and_store_tags(tags, self.tags, self.tag_names, operation) # Parse parameters - parse_parameters(func, components_schemas=self.components_schemas, operation=operation) + parse_parameters( + func, + components_schemas=self.components_schemas, + operation=operation, + request_body_description=request_body_description, + request_body_required=request_body_required, + ) # Parse response get_responses(combine_responses, self.components_schemas, operation) @@ -182,7 +192,7 @@ def decorator(func): return decorator def register( - self, app: "OpenAPI", url_prefix: Optional[str] = None, view_kwargs: Optional[dict[Any, Any]] = None + self, app: "OpenAPI", url_prefix: str | None = None, view_kwargs: dict[Any, Any] | None = None ) -> None: """ Register the API views with the given OpenAPI app. diff --git a/pyproject.toml b/pyproject.toml index 9b487075..274c5394 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,13 +20,13 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = ["Flask>=2.0", "pydantic>=2.4"] dynamic = ["version"] @@ -76,7 +76,8 @@ select = [ "E7", # Statement issues "E9", # Runtime errors "F", # Pyflakes rules - "Q" # flake8-quotes + "Q", # flake8-quotes + "UP045", # Use X | None for type annotations ] [tool.ruff.lint.per-file-ignores] diff --git a/tests/test_api_blueprint.py b/tests/test_api_blueprint.py index 7d93dcc8..faf88bde 100644 --- a/tests/test_api_blueprint.py +++ b/tests/test_api_blueprint.py @@ -2,12 +2,11 @@ # @Author : llc # @Time : 2021/5/17 15:25 -from typing import Optional import pytest from pydantic import BaseModel, Field -from flask_openapi3 import APIBlueprint, Info, OpenAPI, Tag +from flask_openapi3 import APIBlueprint, ExternalDocumentation, Info, OpenAPI, Server, Tag info = Info(title="book API", version="1.0.0") @@ -56,7 +55,7 @@ def client(): class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") @@ -84,7 +83,14 @@ def update_book1(path: BookPath, body: BookBody): return {"code": 0, "message": "ok"} -@api.patch("/v2/book/") +@api.patch( + "/v2/book/", + servers=[Server(url="http://127.0.0.1:5000", variables=None)], + external_docs=ExternalDocumentation( + url="https://www.openapis.org/", description="Something great got better, get excited!" + ), + deprecated=True, +) def update_book1_v2(path: BookPath, body: BookBody): assert path.bid == 1 assert body.age == 3 @@ -143,7 +149,7 @@ def test_delete(client): class AuthorBody(BaseModel): - age: Optional[int] = Field(..., ge=1, le=100, description="Age") + age: int | None = Field(..., ge=1, le=100, description="Age") @author_api.post("/") diff --git a/tests/test_api_view.py b/tests/test_api_view.py index 0fa1fb8b..b80d3235 100644 --- a/tests/test_api_view.py +++ b/tests/test_api_view.py @@ -2,12 +2,11 @@ # @Author : llc # @Time : 2022/11/4 14:41 -from typing import Optional import pytest from pydantic import BaseModel, Field -from flask_openapi3 import APIView, Info, OpenAPI, Tag +from flask_openapi3 import APIView, ExternalDocumentation, Info, OpenAPI, Server, Tag info = Info(title="book API", version="1.0.0") jwt = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} @@ -28,11 +27,11 @@ class BookPath(BaseModel): class BookQuery(BaseModel): - age: Optional[int] = Field(None, description="Age") + age: int | None = Field(None, description="Age") class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") @@ -63,7 +62,14 @@ def put(self, path: BookPath): print(path) return "put" - @api_view.doc(summary="delete book", deprecated=True) + @api_view.doc( + summary="delete book", + servers=[Server(url="http://127.0.0.1:5000", variables=None)], + external_docs=ExternalDocumentation( + url="https://www.openapis.org/", description="Something great got better, get excited!" + ), + deprecated=True, + ) def delete(self, path: BookPath): print(path) return "delete" diff --git a/tests/test_async.py b/tests/test_async.py index 1fe9a791..a5ff2639 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -2,7 +2,6 @@ # @Author : llc # @Time : 2022/12/5 10:27 -from typing import Optional import pytest from pydantic import BaseModel, Field @@ -19,11 +18,11 @@ class Query(BaseModel): class BookQuery(BaseModel): - age: Optional[int] = Field(None, description="Age") + age: int | None = Field(None, description="Age") class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") diff --git a/tests/test_form.py b/tests/test_form.py index c2e00adf..400377b8 100644 --- a/tests/test_form.py +++ b/tests/test_form.py @@ -2,7 +2,7 @@ # @Author : llc # @Time : 2023/8/6 13:47 from enum import Enum -from typing import Any, Union +from typing import Any import pytest from pydantic import BaseModel @@ -49,9 +49,9 @@ class FormParameters(BaseModel): parameter: MetadataParameter parameter_dict: dict[str, MetadataParameter] parameter_list: list[MetadataParameter] - parameter_list_union: list[Union[bool, float, str, int, FileType, MetadataParameter]] - parameter_union: Union[MetadataParameter, MetadataParameter2] - union_all: Union[str, int, float, bool, FileType, MetadataParameter] + parameter_list_union: list[bool | float | str | int | FileType | MetadataParameter] + parameter_union: MetadataParameter | MetadataParameter2 + union_all: str | int | float | bool | FileType | MetadataParameter none: None = None default_value: str = "default_value" diff --git a/tests/test_model_config.py b/tests/test_model_config.py index 8db1a64b..593654b3 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -34,7 +34,6 @@ class BookBody(BaseModel): model_config = dict( openapi_extra={ - "description": "This is post RequestBody", "example": {"age": 12, "author": "author1"}, "examples": { "example1": { @@ -73,7 +72,7 @@ def api_form(form: UploadFilesForm): print(form) # pragma: no cover -@app.post("/body", responses={"200": MessageResponse}) +@app.post("/body", request_body_description="This is post RequestBody", responses={"200": MessageResponse}) def api_error_json(body: BookBody): print(body) # pragma: no cover diff --git a/tests/test_model_extra.py b/tests/test_model_extra.py index d2a155a4..a8ec5e9d 100644 --- a/tests/test_model_extra.py +++ b/tests/test_model_extra.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # @Author : llc # @Time : 2024/11/20 14:45 -from typing import Optional import pytest from pydantic import BaseModel, ConfigDict, Field @@ -13,7 +12,7 @@ class BookQuery(BaseModel): - age: Optional[int] = Field(None, description="Age") + age: int | None = Field(None, description="Age") model_config = ConfigDict(extra="allow") diff --git a/tests/test_multi_content_type.py b/tests/test_multi_content_type.py new file mode 100644 index 00000000..115f0da0 --- /dev/null +++ b/tests/test_multi_content_type.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# @Author : llc +# @Time : 2025/1/6 16:37 +from typing import Union + +import pytest +from flask import Request +from pydantic import BaseModel + +from flask_openapi3 import OpenAPI + +app = OpenAPI(__name__) +app.config["TESTING"] = True + + +class DogBody(BaseModel): + a: int = None + b: str = None + + model_config = {"openapi_extra": {"content_type": "application/vnd.dog+json"}} + + +class CatBody(BaseModel): + c: int = None + d: str = None + + model_config = {"openapi_extra": {"content_type": "application/vnd.cat+json"}} + + +class BsonModel(BaseModel): + e: int = None + f: str = None + + model_config = {"openapi_extra": {"content_type": "application/bson"}} + + +class ContentTypeModel(BaseModel): + model_config = {"openapi_extra": {"content_type": "text/csv"}} + + +@app.post("/a", responses={200: Union[DogBody, CatBody, ContentTypeModel, BsonModel]}) +def index_a(body: Union[DogBody, CatBody, ContentTypeModel, BsonModel]): + """ + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + ... + else: + # DogBody or CatBody + ... + return {"hello": "world"} + + +@app.post("/b", responses={200: Union[ContentTypeModel, BsonModel]}) +def index_b(body: Union[ContentTypeModel, BsonModel]): + """ + This may be confusing, if the content-type is application/json, the type of body will be auto parsed to + DogBody or CatBody, otherwise it cannot be parsed to ContentTypeModel or BsonModel. + The body is equivalent to the request variable in Flask, and you can use body.data, body.text, etc ... + """ + print(body) + if isinstance(body, Request): + if body.mimetype == "text/csv": + # processing csv data + ... + elif body.mimetype == "application/bson": + # processing bson data + ... + else: + # DogBody or CatBody + ... + return {"hello": "world"} + + +@pytest.fixture +def client(): + client = app.test_client() + + return client + + +def test_openapi(client): + resp = client.get("/openapi/openapi.json") + assert resp.status_code == 200 + + resp = client.post("/a", json={"a": 1, "b": "2"}) + assert resp.status_code == 200 + + resp = client.post("/a", data="a,b,c\n1,2,3", headers={"Content-Type": "text/csv"}) + assert resp.status_code == 200 diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 4d9d38b7..3bf5baab 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Generic, Literal, Optional, TypeVar +from typing import Generic, Literal, TypeVar from pydantic import BaseModel, Field @@ -18,16 +18,17 @@ class BaseResponse(BaseModel): test: int - model_config = dict( - openapi_extra={ + @test_app.get( + "/test", + responses={ + "201": { + "model": BaseResponse, "description": "Custom description", "headers": {"location": {"description": "URL of the new resource", "schema": {"type": "string"}}}, - "content": {"text/plain": {"schema": {"type": "string"}}}, "links": {"dummy": {"description": "dummy link"}}, } - ) - - @test_app.get("/test", responses={"201": BaseResponse}) + }, + ) def endpoint_test(): return b"", 201 # pragma: no cover @@ -39,9 +40,7 @@ def endpoint_test(): "headers": {"location": {"description": "URL of the new resource", "schema": {"type": "string"}}}, "content": { # This content is coming from responses - "application/json": {"schema": {"$ref": "#/components/schemas/BaseResponse"}}, - # While this one comes from responses - "text/plain": {"schema": {"type": "string"}}, + "application/json": {"schema": {"$ref": "#/components/schemas/BaseResponse"}} }, "links": {"dummy": {"description": "dummy link"}}, } @@ -407,8 +406,8 @@ def endpoint_test(header: HeaderParam, cookie: CookieParam): class Model(BaseModel): - one: Optional[int] = Field(default=None) - two: Optional[int] = Field(default=2) + one: int | None = Field(default=None) + two: int | None = Field(default=2) def test_default_none(request): diff --git a/tests/test_request.py b/tests/test_request.py index 2f4a1129..dc2975d4 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -3,7 +3,6 @@ # @Time : 2022/9/2 15:35 from enum import Enum from functools import wraps -from typing import Optional import pytest from pydantic import BaseModel, Field @@ -35,7 +34,7 @@ class BookForm(BaseModel): class BookQuery(BaseModel): age: list[int] - book_type: Optional[TypeEnum] = None + book_type: TypeEnum | None = None class BookBody(BaseModel): @@ -43,8 +42,8 @@ class BookBody(BaseModel): class BookCookie(BaseModel): - token: Optional[str] = None - token_type: Optional[TypeEnum] = None + token: str | None = None + token_type: TypeEnum | None = None class BookHeader(BaseModel): @@ -52,7 +51,7 @@ class BookHeader(BaseModel): # required hello2: str = Field(..., max_length=12, description="sds") api_key: str = Field(..., description="API Key") - api_type: Optional[TypeEnum] = None + api_type: TypeEnum | None = None x_hello: str = Field(..., max_length=12, description="Header with alias to support dash", alias="x-hello") diff --git a/tests/test_restapi.py b/tests/test_restapi.py index ffb995fd..55088a2f 100644 --- a/tests/test_restapi.py +++ b/tests/test_restapi.py @@ -5,13 +5,12 @@ import json from http import HTTPStatus -from typing import Optional import pytest from flask import Response from pydantic import BaseModel, Field, RootModel -from flask_openapi3 import ExternalDocumentation, Info, OpenAPI, Tag +from flask_openapi3 import ExternalDocumentation, Info, OpenAPI, Server, Tag info = Info(title="book API", version="1.0.0") @@ -43,11 +42,13 @@ def get_operation_id_for_path_callback(*, name: str, path: str, method: str) -> class BookQuery(BaseModel): - age: Optional[int] = Field(None, description="Age") + age: int | None = Field(None, description="Age") + author: str + none: None = None class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") @@ -57,7 +58,7 @@ class BookPath(BaseModel): class BookBodyWithID(BaseModel): bid: int = Field(..., description="book id") - age: Optional[int] = Field(None, ge=2, le=4, description="Age") + age: int | None = Field(None, ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") @@ -98,8 +99,10 @@ def client(): external_docs=ExternalDocumentation( url="https://www.openapis.org/", description="Something great got better, get excited!" ), + servers=[Server(url="http://127.0.0.1:5000", variables=None)], responses={"200": BookResponse}, security=security, + deprecated=True, ) def get_book(path: BookPath): """Get a book @@ -111,7 +114,7 @@ def get_book(path: BookPath): @app.get("/book", tags=[book_tag], responses={"200": BookListResponseV1}) -def get_books(query: BookBody): +def get_books(query: BookQuery): """get books to get all books """ diff --git a/tests/test_server.py b/tests/test_server.py index 731de4ce..c1b4dacd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3,29 +3,33 @@ # @Time : 2024/11/10 12:17 from pydantic import ValidationError -from flask_openapi3 import Server, ServerVariable +from flask_openapi3 import ExternalDocumentation, OpenAPI, Server, ServerVariable def test_server_variable(): Server(url="http://127.0.0.1:5000", variables=None) + error = 0 try: variables = {"one": ServerVariable(default="one", enum=[])} - Server(url="http://127.0.0.1:5000", variables=variables) - error = 0 except ValidationError: error = 1 assert error == 1 - try: - variables = {"one": ServerVariable(default="one")} - Server(url="http://127.0.0.1:5000", variables=variables) - error = 0 - except ValidationError: - error = 1 + variables = {"one": ServerVariable(default="one")} + Server(url="http://127.0.0.1:5000", variables=variables) + error = 0 assert error == 0 - try: - variables = {"one": ServerVariable(default="one", enum=["one", "two"])} - Server(url="http://127.0.0.1:5000", variables=variables) - error = 0 - except ValidationError: - error = 1 + variables = {"one": ServerVariable(default="one", enum=["one", "two"])} + Server(url="http://127.0.0.1:5000", variables=variables) + error = 0 assert error == 0 + + app = OpenAPI( + __name__, + servers=[Server(url="http://127.0.0.1:5000", variables=None)], + external_docs=ExternalDocumentation( + url="https://www.openapis.org/", description="Something great got better, get excited!" + ), + ) + + assert "servers" in app.api_doc + assert "externalDocs" in app.api_doc diff --git a/tests/test_validate_request.py b/tests/test_validate_request.py index 3906601e..2f44b2d2 100644 --- a/tests/test_validate_request.py +++ b/tests/test_validate_request.py @@ -1,5 +1,4 @@ from functools import wraps -from typing import Optional import pytest from flask import request @@ -14,7 +13,7 @@ class BookNamePath(BaseModel): class BookBody(BaseModel): - age: Optional[int] = Field(..., ge=2, le=4, description="Age") + age: int | None = Field(..., ge=2, le=4, description="Age") author: str = Field(None, min_length=2, max_length=4, description="Author") name: str