From a4e65c2c7ee3364589b91c47d0195f481191a5ca Mon Sep 17 00:00:00 2001 From: Keming Date: Mon, 25 Nov 2024 00:12:46 +0800 Subject: [PATCH] feat: support pydantic v1 & v2 through Protocol (#387) * feat: support pydantic v2 Signed-off-by: Keming * bump version Signed-off-by: Keming --------- Signed-off-by: Keming --- README.md | 155 ++++-------------- examples/falcon_asgi_demo.py | 17 +- examples/falcon_demo.py | 19 +-- examples/flask_demo.py | 37 ++--- examples/quart_demo.py | 33 ++-- examples/security_demo.py | 13 +- examples/starlette_demo.py | 14 +- pyproject.toml | 2 +- spectree/_pydantic.py | 77 ++++++++- spectree/config.py | 4 +- spectree/models.py | 15 +- spectree/page.py | 27 ++- spectree/plugins/falcon_plugin.py | 7 +- spectree/plugins/quart_plugin.py | 2 +- spectree/plugins/starlette_plugin.py | 8 +- spectree/response.py | 6 +- spectree/utils.py | 5 +- .../test_plugin_spec[falcon][full_spec].json | 2 +- .../test_plugin_spec[flask][full_spec].json | 2 +- ...ugin_spec[flask_blueprint][full_spec].json | 2 +- ...st_plugin_spec[flask_view][full_spec].json | 2 +- ...est_plugin_spec[starlette][full_spec].json | 2 +- 22 files changed, 214 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index 75531eaa..c074b676 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ If all you need is a framework-agnostic library that can generate OpenAPI docume * Less boilerplate code, only annotations, no need for YAML :sparkles: * Generate API document with [Redoc UI](https://github.com/Redocly/redoc), [Scalar UI](https://github.com/scalar/scalar) or [Swagger UI](https://github.com/swagger-api/swagger-ui) :yum: -* Validate query, JSON data, response data with [pydantic](https://github.com/samuelcolvin/pydantic/) :wink: - * If you're using Pydantic V2, you will need to import the `BaseModel` from `pydantic.v1` to make it compatible +* Validate query, JSON data, response data with [pydantic](https://github.com/samuelcolvin/pydantic/) (both v1 & v2) :wink: * Current support: * Flask [demo](#flask) * Quart [demo](#quart) @@ -48,10 +47,7 @@ Check the [examples](examples) folder. * `tags` *(no tags on endpoint)* * `security` *(`None` - endpoint is not secured)* * `deprecated` *(`False` - endpoint is not marked as deprecated)* -4. access these data with `context(query, json, headers, cookies)` (of course, you can access these from the original place where the framework offered) - * flask: `request.context` - * falcon: `req.context` - * starlette: `request.context` +4. access these data from the function annotations (see the examples below). Of course, you can still access them from the original place where the framework offered. 5. register to the web application `api.register(app)` 6. check the document at URL location `/apidoc/redoc` or `/apidoc/swagger` or `/apidoc/scalar` @@ -279,7 +275,7 @@ You can change the `validation_error_status` in SpecTree (global) or a specific > How can I return my model directly? -Yes, returning an instance of `BaseModel` will assume the model is valid and bypass spectree's validation and automatically call `.dict()` on the model. +Yes, returning an instance of `BaseModel` will assume the model is valid and bypass spectree's validation and automatically call `.dict()` on the model. For starlette you should return a `PydanticResponse`: ```py @@ -295,13 +291,14 @@ Try it with `http post :8000/api/user name=alice age=18`. (if you are using `htt ### Flask ```py -from flask import Flask, request, jsonify -from pydantic import BaseModel, Field, constr -from spectree import SpecTree, Response +from flask import Flask, jsonify +from pydantic import BaseModel, Field + +from spectree import Response, SpecTree class Profile(BaseModel): - name: constr(min_length=2, max_length=40) # constrained str + name: str age: int = Field(..., gt=0, lt=150, description="user age(Human)") class Config: @@ -323,16 +320,14 @@ spec = SpecTree("flask") @app.route("/api/user", methods=["POST"]) -@spec.validate( - json=Profile, resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"] -) -def user_profile(): +@spec.validate(resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]) +def user_profile(json: Profile): """ verify user profile (summary of this endpoint) user's name, user's age, ... (long description) """ - print(request.context.json) # or `request.json` + print(json) # or `request.json` return jsonify(text="it works") # or `Message(text='it works')` @@ -341,36 +336,17 @@ if __name__ == "__main__": app.run(port=8000) ``` -#### Flask example with type annotation - -```python -# opt in into annotations feature -spec = SpecTree("flask", annotations=True) - - -@app.route("/api/user", methods=["POST"]) -@spec.validate(resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]) -def user_profile(json: Profile): - """ - verify user profile (summary of this endpoint) - - user's name, user's age, ... (long description) - """ - print(json) # or `request.json` - return jsonify(text="it works") # or `Message(text='it works')` -``` - ### Quart ```py -from quart import Quart, jsonify, request -from pydantic import BaseModel, Field, constr +from pydantic import BaseModel, Field +from quart import Quart, jsonify -from spectree import SpecTree, Response +from spectree import Response, SpecTree class Profile(BaseModel): - name: constr(min_length=2, max_length=40) # constrained str + name: str age: int = Field(..., gt=0, lt=150, description="user age") class Config: @@ -392,16 +368,14 @@ spec = SpecTree("quart") @app.route("/api/user", methods=["POST"]) -@spec.validate( - json=Profile, resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"] -) -async def user_profile(): +@spec.validate(resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]) +async def user_profile(json: Profile): """ verify user profile (summary of this endpoint) user's name, user's age, ... (long description) """ - print(request.context.json) # or `request.json` + print(json) # or `request.json` return jsonify(text="it works") # or `Message(text="it works")` @@ -410,36 +384,19 @@ if __name__ == "__main__": app.run(port=8000) ``` -#### Quart example with type annotation - -```python -# opt in into annotations feature -spec = SpecTree("quart", annotations=True) - - -@app.route("/api/user", methods=["POST"]) -@spec.validate(resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]) -def user_profile(json: Profile): - """ - verify user profile (summary of this endpoint) - - user's name, user's age, ... (long description) - """ - print(json) # or `request.json` - return jsonify(text="it works") # or `Message(text='it works')` -``` - ### Falcon ```py -import falcon from wsgiref import simple_server -from pydantic import BaseModel, Field, constr -from spectree import SpecTree, Response + +import falcon +from pydantic import BaseModel, Field + +from spectree import Response, SpecTree class Profile(BaseModel): - name: constr(min_length=2, max_length=40) # Constrained Str + name: str age: int = Field(..., gt=0, lt=150, description="user age(Human)") @@ -451,16 +408,14 @@ spec = SpecTree("falcon") class UserProfile: - @spec.validate( - json=Profile, resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"] - ) - def on_post(self, req, resp): + @spec.validate(resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]) + def on_post(self, req, resp, json: Profile): """ verify user profile (summary of this endpoint) user's name, user's age, ... (long description) """ - print(req.context.json) # or `req.media` + print(json) # or `req.media` resp.media = {"text": "it works"} # or `resp.media = Message(text='it works')` @@ -473,40 +428,22 @@ if __name__ == "__main__": httpd.serve_forever() ``` -#### Falcon with type annotations - -```python -# opt in into annotations feature -spec = SpecTree("falcon", annotations=True) - - -class UserProfile: - @spec.validate(resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]) - def on_post(self, req, resp, json: Profile): - """ - verify user profile (summary of this endpoint) - - user's name, user's age, ... (long description) - """ - print(req.context.json) # or `req.media` - resp.media = {"text": "it works"} # or `resp.media = Message(text='it works')` -``` - ### Starlette ```py import uvicorn +from pydantic import BaseModel, Field from starlette.applications import Starlette -from starlette.routing import Route, Mount from starlette.responses import JSONResponse -from pydantic import BaseModel, Field, constr -from spectree import SpecTree, Response +from starlette.routing import Mount, Route + +from spectree import Response, SpecTree # from spectree.plugins.starlette_plugin import PydanticResponse class Profile(BaseModel): - name: constr(min_length=2, max_length=40) # Constrained Str + name: str age: int = Field(..., gt=0, lt=150, description="user age(Human)") @@ -517,16 +454,14 @@ class Message(BaseModel): spec = SpecTree("starlette") -@spec.validate( - json=Profile, resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"] -) -async def user_profile(request): +@spec.validate(resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]) +async def user_profile(request, json: Profile): """ verify user profile (summary of this endpoint) user's name, user's age, ... (long description) """ - print(request.context.json) # or await request.json() + print(json) # or await request.json() return JSONResponse( {"text": "it works"} ) # or `return PydanticResponse(Message(text='it works'))` @@ -536,7 +471,7 @@ if __name__ == "__main__": app = Starlette( routes=[ Mount( - "api", + "/api", routes=[ Route("/user", user_profile, methods=["POST"]), ], @@ -548,24 +483,6 @@ if __name__ == "__main__": uvicorn.run(app) ``` -#### Starlette example with type annotations - -```python -# opt in into annotations feature -spec = SpecTree("flask", annotations=True) - - -@spec.validate(resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]) -async def user_profile(request, json=Profile): - """ - verify user profile (summary of this endpoint) - - user's name, user's age, ... (long description) - """ - print(request.context.json) # or await request.json() - return JSONResponse({"text": "it works"}) # or `return PydanticResponse(Message(text='it works'))` -``` - ## FAQ diff --git a/examples/falcon_asgi_demo.py b/examples/falcon_asgi_demo.py index d7462e6d..e5e3babd 100644 --- a/examples/falcon_asgi_demo.py +++ b/examples/falcon_asgi_demo.py @@ -15,6 +15,7 @@ "falcon-asgi", title="Demo Service", version="0.1.2", + annotations=True, ) demo = Tag( @@ -75,18 +76,16 @@ async def on_get(self, req, resp, source, target): """ resp.media = {"msg": f"hello from {source} to {target}"} - @spec.validate( - query=Query, json=Data, resp=Response(HTTP_200=Resp, HTTP_403=BadLuck) - ) - async def on_post(self, req, resp, source, target): + @spec.validate(resp=Response(HTTP_200=Resp, HTTP_403=BadLuck)) + async def on_post(self, req, resp, source, target, query: Query, json: Data): """ post demo - demo for `query`, `data`, `resp`, `x` + demo for `query`, `data`, `resp` """ logger.debug("%s => %s", source, target) - logger.info(req.context.query) - logger.info(req.context.json) + logger.info(query) + logger.info(json) if random() < 0.5: resp.status = falcon.HTTP_403 resp.media = {"loc": "unknown", "msg": "bad luck", "typ": "random"} @@ -99,8 +98,8 @@ class FileUpload: file-handling demo """ - @spec.validate(form=File, resp=Response(HTTP_200=FileResp), tags=["file-upload"]) - async def on_post(self, req, resp): + @spec.validate(resp=Response(HTTP_200=FileResp), tags=["file-upload"]) + async def on_post(self, req, resp, form: File): """ post multipart/form-data demo diff --git a/examples/falcon_demo.py b/examples/falcon_demo.py index 97daddc6..df961163 100644 --- a/examples/falcon_demo.py +++ b/examples/falcon_demo.py @@ -13,6 +13,7 @@ spec = SpecTree( "falcon", + annotations=True, title="Demo Service", version="0.1.2", description="This is a demo service.", @@ -79,18 +80,16 @@ def on_get(self, req, resp, source, target): """ resp.media = {"msg": f"hello from {source} to {target}"} - @spec.validate( - query=Query, json=Data, resp=Response(HTTP_200=Resp, HTTP_403=BadLuck) - ) - def on_post(self, req, resp, source, target): + @spec.validate(resp=Response(HTTP_200=Resp, HTTP_403=BadLuck)) + def on_post(self, req, resp, source, target, query: Query, json: Data): """ post demo - demo for `query`, `data`, `resp`, `x` + demo for `query`, `data`, `resp` """ logger.debug("%s => %s", source, target) - logger.info(req.context.query) - logger.info(req.context.json) + logger.info(query) + logger.info(json) if random() < 0.5: resp.status = falcon.HTTP_403 resp.media = {"loc": "unknown", "msg": "bad luck", "typ": "random"} @@ -103,14 +102,14 @@ class FileUpload: file-handling demo """ - @spec.validate(form=File, resp=Response(HTTP_200=FileResp), tags=["file-upload"]) - def on_post(self, req, resp): + @spec.validate(resp=Response(HTTP_200=FileResp), tags=["file-upload"]) + def on_post(self, req, resp, form: File): """ post multipart/form-data demo demo for 'form' """ - file = req.context.form.file + file = form.file resp.media = {"filename": file.filename, "type": file.type} diff --git a/examples/flask_demo.py b/examples/flask_demo.py index a7758788..d5f94709 100644 --- a/examples/flask_demo.py +++ b/examples/flask_demo.py @@ -1,7 +1,7 @@ from enum import Enum from random import random -from flask import Flask, abort, jsonify, request +from flask import Flask, abort, jsonify from flask.views import MethodView from pydantic import BaseModel, Field @@ -52,21 +52,16 @@ class Cookie(BaseModel): @app.route( "/api/predict//", methods=["POST"] ) -@spec.validate( - query=Query, json=Data, resp=Response("HTTP_403", HTTP_200=Resp), tags=["model"] -) -def predict(source, target): +@spec.validate(resp=Response("HTTP_403", HTTP_200=Resp), tags=["model"]) +def predict(source, target, query: Query, json: Data): """ predict demo - demo for `query`, `data`, `resp`, `x` - - query with - ``http POST ':8000/api/predict/zh/en?text=hello' uid=xxx limit=5 vip=false `` + demo for `query`, `data`, `resp` """ print(f"=> from {source} to {target}") # path - print(f"JSON: {request.context.json}") # Data - print(f"Query: {request.context.query}") # Query + print(f"JSON: {json}") # Data + print(f"Query: {query}") # Query if random() < 0.5: abort(403) @@ -74,33 +69,29 @@ def predict(source, target): @app.route("/api/header", methods=["POST"]) -@spec.validate( - headers=Header, cookies=Cookie, resp=Response("HTTP_203"), tags=["test", "demo"] -) -def with_code_header(): +@spec.validate(resp=Response("HTTP_203"), tags=["test", "demo"]) +def with_code_header(headers: Header, cookies: Cookie): """ demo for JSON with status code and header - - query with ``http POST :8000/api/header Lang:zh-CN Cookie:key=hello`` """ - return jsonify(language=request.context.headers.Lang), 203, {"X": 233} + return jsonify(language=headers.Lang), 203, {"X": 233} @app.route("/api/file_upload", methods=["POST"]) -@spec.validate(form=File, resp=Response(HTTP_200=FileResp), tags=["file-upload"]) -def with_file(): +@spec.validate(resp=Response(HTTP_200=FileResp), tags=["file-upload"]) +def with_file(form: File): """ post multipart/form-data demo demo for 'form' """ - file = request.context.form.file + file = form.file return {"filename": file.filename, "type": file.content_type} class UserAPI(MethodView): - @spec.validate(json=Data, resp=Response(HTTP_200=Resp), tags=["test"]) - def post(self): + @spec.validate(resp=Response(HTTP_200=Resp), tags=["test"]) + def post(self, json: Data): return jsonify(label=int(10 * random()), score=random()) diff --git a/examples/quart_demo.py b/examples/quart_demo.py index 5807ff78..5954332d 100644 --- a/examples/quart_demo.py +++ b/examples/quart_demo.py @@ -2,13 +2,13 @@ from random import random from pydantic import BaseModel, Field -from quart import Quart, abort, jsonify, request +from quart import Quart, abort, jsonify from quart.views import MethodView from spectree import Response, SpecTree app = Quart(__name__) -spec = SpecTree("quart") +spec = SpecTree("quart", annotations=True) class Query(BaseModel): @@ -55,21 +55,16 @@ class Cookie(BaseModel): @app.route( "/api/predict//", methods=["POST"] ) -@spec.validate( - query=Query, json=Data, resp=Response("HTTP_403", HTTP_200=Resp), tags=["model"] -) -def predict(source, target): +@spec.validate(resp=Response("HTTP_403", HTTP_200=Resp), tags=["model"]) +def predict(source, target, json: Data, query: Query): """ predict demo - demo for `query`, `data`, `resp`, `x` - - query with - ``http POST ':8000/api/predict/zh/en?text=hello' uid=xxx limit=5 vip=false `` + demo for `query`, `data`, `resp` """ print(f"=> from {source} to {target}") # path - print(f"JSON: {request.json}") # Data - print(f"Query: {request.args}") # Query + print(f"JSON: {json}") # Data + print(f"Query: {query}") # Query if random() < 0.5: abort(403) @@ -77,21 +72,17 @@ def predict(source, target): @app.route("/api/header", methods=["POST"]) -@spec.validate( - headers=Header, cookies=Cookie, resp=Response("HTTP_203"), tags=["test", "demo"] -) -async def with_code_header(): +@spec.validate(resp=Response("HTTP_203"), tags=["test", "demo"]) +async def with_code_header(headers: Header, cookies: Cookie): """ demo for JSON with status code and header - - query with ``http POST :8000/api/header Lang:zh-CN Cookie:key=hello`` """ - return jsonify(language=request.headers.get("Lang")), 203, {"X": 233} + return jsonify(language=headers.get("Lang")), 203, {"X": 233} class UserAPI(MethodView): - @spec.validate(json=Data, resp=Response(HTTP_200=Resp), tags=["test"]) - async def post(self): + @spec.validate(resp=Response(HTTP_200=Resp), tags=["test"]) + async def post(self, json: Data): return jsonify(label=int(10 * random()), score=random()) # return Resp(label=int(10 * random()), score=random()) diff --git a/examples/security_demo.py b/examples/security_demo.py index e1d864b2..d1817082 100644 --- a/examples/security_demo.py +++ b/examples/security_demo.py @@ -64,19 +64,14 @@ class Req(BaseModel): @app.route("/ping", methods=["POST"]) -@spec.validate( - json=Req, -) -def ping(): +@spec.validate() +def ping(json: Req): return "pong" @app.route("/ping/oauth", methods=["POST"]) -@spec.validate( - json=Req, - security=[{"auth_oauth2": ["read"]}], -) -def oauth_only(): +@spec.validate(security=[{"auth_oauth2": ["read"]}]) +def oauth_only(json: Req): return "pong" diff --git a/examples/starlette_demo.py b/examples/starlette_demo.py index 8445f2eb..9d299e5d 100644 --- a/examples/starlette_demo.py +++ b/examples/starlette_demo.py @@ -8,7 +8,7 @@ from examples.common import File, FileResp, Query from spectree import Response, SpecTree -spec = SpecTree("starlette") +spec = SpecTree("starlette", annotations=True) class Resp(BaseModel): @@ -30,27 +30,27 @@ class Data(BaseModel): vip: bool -@spec.validate(query=Query, json=Data, resp=Response(HTTP_200=Resp), tags=["api"]) -async def predict(request): +@spec.validate(resp=Response(HTTP_200=Resp), tags=["api"]) +async def predict(request, query: Query, json: Data): """ async api descriptions about this function """ print(request.path_params) - print(request.context) + print(query, json) return JSONResponse({"label": 5, "score": 0.5}) # return PydanticResponse(Resp(label=5, score=0.5)) -@spec.validate(form=File, resp=Response(HTTP_200=FileResp), tags=["file-upload"]) -async def file_upload(request): +@spec.validate(resp=Response(HTTP_200=FileResp), tags=["file-upload"]) +async def file_upload(request, form: File): """ post multipart/form-data demo demo for 'form' """ - file = request.context.form.file + file = form.file return JSONResponse({"filename": file.filename, "type": file.type}) diff --git a/pyproject.toml b/pyproject.toml index 0bb525d2..794ae38d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spectree" -version = "1.3.0" +version = "1.4.0" dynamic = [] description = "generate OpenAPI document and validate request&response with Python annotations." readme = "README.md" diff --git a/spectree/_pydantic.py b/spectree/_pydantic.py index 15194983..7a8c2105 100644 --- a/spectree/_pydantic.py +++ b/spectree/_pydantic.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Protocol, runtime_checkable from pydantic.version import VERSION as PYDANTIC_VERSION @@ -33,6 +33,7 @@ root_validator, validator, ) + from pydantic_core import core_schema # noqa else: from pydantic import ( # type: ignore[no-redef,assignment] AnyUrl, @@ -46,6 +47,80 @@ ) +@runtime_checkable +class PydanticModelProtocol(Protocol): + def dict( + self, + *, + include=None, + exclude=None, + by_alias=False, + skip_defaults=None, + exclude_unset=False, + exclude_defaults=False, + exclude_none=False, + ): + pass + + def json( + self, + *, + include=None, + exclude=None, + by_alias=False, + skip_defaults=None, + exclude_unset=False, + exclude_defaults=False, + exclude_none=False, + encoder=None, + models_as_dict=True, + **dumps_kwargs, + ): + pass + + @classmethod + def parse_obj(cls, obj): + pass + + @classmethod + def parse_raw( + cls, b, *, content_type=None, encoding="utf8", proto=None, allow_pickle=False + ): + pass + + @classmethod + def parse_file( + cls, path, *, content_type=None, encoding="utf8", proto=None, allow_pickle=False + ): + pass + + @classmethod + def construct(cls, _fields_set=None, **values): + pass + + @classmethod + def copy(cls, *, include=None, exclude=None, update=None, deep=False): + pass + + @classmethod + def schema(cls, by_alias=True, ref_template="#/definitions/{model}"): + pass + + @classmethod + def schema_json( + cls, *, by_alias=True, ref_template="#/definitions/{model}", **dumps_kwargs + ): + pass + + @classmethod + def validate(cls, value): + pass + + +def is_pydantic_model(t: Any) -> bool: + return issubclass(t, PydanticModelProtocol) + + def is_base_model(t: Any) -> bool: """Check whether a type is a Pydantic BaseModel""" try: diff --git a/spectree/config.py b/spectree/config.py index 2e2087c2..aca6f82a 100644 --- a/spectree/config.py +++ b/spectree/config.py @@ -69,7 +69,7 @@ class Configuration(BaseSettings): #: OpenAPI file route path suffix (i.e. /apidoc/openapi.json) filename: str = "openapi.json" #: OpenAPI version (doesn't affect anything) - openapi_version: str = "3.0.3" + openapi_version: str = "3.1.0" #: the mode of the SpecTree validator :class:`ModeEnum` mode: ModeEnum = ModeEnum.normal #: A dictionary of documentation page templates. The key is the @@ -79,7 +79,7 @@ class Configuration(BaseSettings): #: the rendered documentation page page_templates: Dict[str, str] = DEFAULT_PAGE_TEMPLATES #: opt-in type annotation feature, see the README examples - annotations: bool = False + annotations: bool = True #: servers section of OAS :py:class:`spectree.models.Server` servers: Optional[List[Server]] = [] #: OpenAPI `securitySchemes` :py:class:`spectree.models.SecurityScheme` diff --git a/spectree/models.py b/spectree/models.py index 4b76ea9d..df9e3902 100644 --- a/spectree/models.py +++ b/spectree/models.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Any, Dict, Optional, Sequence, Set -from ._pydantic import BaseModel, Field, root_validator, validator +from ._pydantic import PYDANTIC2, BaseModel, Field, root_validator, validator # OpenAPI names validation regexp OpenAPI_NAME_RE = re.compile(r"^[A-Za-z0-9-._]+") @@ -190,6 +190,19 @@ def __get_validators__(cls): def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: field_schema.update(format="binary", type="string") + # pydantic v2 + @classmethod + def __get_pydantic_json_schema__(cls, _core_schema: Dict[str, Any], _handler): + return {"format": "binary", "type": "string"} + + # pydantic v2 + @classmethod + def __get_pydantic_core_schema__(cls, _source_type, _handler): + if PYDANTIC2: + from ._pydantic import core_schema + + return core_schema.with_info_plain_validator_function(cls.validate) + @classmethod def validate(cls, value: Any): # https://github.com/luolingchun/flask-openapi3/blob/master/flask_openapi3/models/file.py diff --git a/spectree/page.py b/spectree/page.py index 7afa0fab..72230e40 100644 --- a/spectree/page.py +++ b/spectree/page.py @@ -10,6 +10,7 @@ + @@ -39,14 +40,15 @@ SwaggerUI - + +
- - + + @@ -172,6 +170,7 @@ margin: 0; }} +