SpecTree
Yet another library to generate OpenAPI documents and validate requests & responses with Python annotations.
If all you need is a framework-agnostic library that can generate OpenAPI document, check defspec.
Features
- Less boilerplate code, only annotations, no need for YAML :sparkles:
- Generate API document with Redoc UI, Scalar UI or Swagger UI :yum:
- Validate query, JSON data, response data with pydantic :wink:
- If you're using Pydantic V2, you will need to import the
BaseModel
from pydantic.v1
to make it compatible
- Current support:
Quick Start
Install with pip: pip install spectree
. If you'd like for email fields to be validated, use pip install spectree[email]
.
Examples
Check the examples folder.
Step by Step
- Define your data structure used in (query, json, headers, cookies, resp) with
pydantic.BaseModel
- create
spectree.SpecTree
instance with the web framework name you are using, like api = SpecTree('flask')
api.validate
decorate the route with (the default value is given in parentheses):
query
json
headers
cookies
resp
tags
(no tags on endpoint)security
(None
- endpoint is not secured)deprecated
(False
- endpoint is not marked as deprecated)
- 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
- register to the web application
api.register(app)
- check the document at URL location
/apidoc/redoc
or /apidoc/swagger
or /apidoc/scalar
If the request doesn't pass the validation, it will return a 422 with a JSON error message(ctx, loc, msg, type).
Falcon response validation
For Falcon response, this library only validates against media as it is the serializable object. Response.text is a string representing response content and will not be validated. For no assigned media situation, resp
parameter in api.validate
should be like Response(HTTP_200=None)
Opt-in type annotation feature
This library also supports the injection of validated fields into view function arguments along with parameter annotation-based type declaration. This works well with linters that can take advantage of typing features like mypy. See the examples section below.
How-To
How to add summary and description to endpoints?
Just add docs to the endpoint function. The 1st line is the summary, and the rest is the description for this endpoint.
How to add a description to parameters?
Check the pydantic document about description in Field
.
Any config I can change?
Of course. Check the config document.
You can update the config when init the spectree like:
SpecTree('flask', title='Demo API', version='v1.0', path='doc')
What is Response
and how to use it?
To build a response for the endpoint, you need to declare the status code with format HTTP_{code}
and corresponding data (optional).
Response(HTTP_200=None, HTTP_403=ForbidModel)
Response('HTTP_200')
Response(HTTP_403=(ForbidModel, "custom code description"))
How to secure API endpoints?
For secure API endpoints, it is needed to define the security_schemes
argument in the SpecTree
constructor. security_schemes
argument needs to contain an array of SecurityScheme
objects. Then there are two ways to enforce security:
- You can enforce security on individual API endpoints by defining the
security
argument in the api.validate
decorator of relevant function/method (this corresponds to define security section on operation level, under paths
, in OpenAPI
). security
argument is defined as a dictionary, where each key is the name of security used in security_schemes
argument of SpecTree
constructor and its value is required security scope, as is showed in the following example:
Click to expand the code example:
api = SpecTree(security_schemes=[
SecurityScheme(
name="auth_apiKey",
data={"type": "apiKey", "name": "Authorization", "in": "header"},
),
SecurityScheme(
name="auth_oauth2",
data={
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://example.com/oauth/authorize",
"tokenUrl": "https://example.com/oauth/token",
"scopes": {
"read": "Grants read access",
"write": "Grants write access",
"admin": "Grants access to admin operations",
},
},
},
},
),
],
)
@api.validate(
resp=Response(HTTP_200=None),
)
def foo():
...
@api.validate(
resp=Response(HTTP_200=None),
security={"auth_apiKey": [], "auth_oauth2": ["read", "write"]},
)
def bar():
...
- You can enforce security on the whole API by defining the
security
argument in the SpecTree
constructor (this corresponds to the define security section on the root level in OpenAPI
). It is possible to override global security by defining local security, as well as override to no security on some API endpoint, in the security
argument of api.validate
decorator of relevant function/method as was described in the previous point. It is also shown in the following small example:
Click to expand the code example:
api = SpecTree(security_schemes=[
SecurityScheme(
name="auth_apiKey",
data={"type": "apiKey", "name": "Authorization", "in": "header"},
),
SecurityScheme(
name="auth_oauth2",
data={
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://example.com/oauth/authorize",
"tokenUrl": "https://example.com/oauth/token",
"scopes": {
"read": "Grants read access",
"write": "Grants write access",
"admin": "Grants access to admin operations",
},
},
},
},
),
],
security={"auth_apiKey": []},
)
@api.validate(
resp=Response(HTTP_200=None),
security={},
)
def foo():
...
@api.validate(
resp=Response(HTTP_200=None),
security={"auth_oauth2": ["read"]},
)
def bar():
...
@api.validate(
resp=Response(HTTP_200=None),
)
def foobar():
...
How to mark deprecated endpoint?
Use deprecated
attribute with value True
in api.validate()
decorator. This way, an endpoint will be marked as
deprecated and will be marked with a strikethrough in API documentation.
Code example:
@api.validate(
deprecated=True,
)
def deprecated_endpoint():
...
What should I return when I'm using the library?
No need to change anything. Just return what the framework required.
How to log when the validation failed?
Validation errors are logged with the INFO level. Details are passed into extra
. Check the falcon example for details.
How can I write a customized plugin for another backend framework?
Inherit spectree.plugins.base.BasePlugin
and implement the functions you need. After that, init like api = SpecTree(backend=MyCustomizedPlugin)
.
How to use a customized template page?
SpecTree(page_templates={"page_name": "customized page contains {spec_url} for rendering"})
In the above example, the key "page_name" will be used in the URL to access this page "/apidoc/page_name". The value should be a string that contains {spec_url}
which will be used to access the OpenAPI JSON file.
How can I change the response when there is a validation error? Can I record some metrics?
This library provides before
and after
hooks to do these. Check the doc or the test case. You can change the handlers for SpecTree or a specific endpoint validation.
How to change the default ValidationError
status code?
You can change the validation_error_status
in SpecTree (global) or a specific endpoint (local). This also takes effect in the OpenAPI documentation.
How can I skip the validation?
Add skip_validation=True
to the decorator. For now, this only skip the response validation.
@api.validate(json=Profile, resp=Response(HTTP_200=Message, HTTP_403=None), skip_validation=True)
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.
For starlette you should return a PydanticResponse
:
from spectree.plugins.starlette_plugin import PydanticResponse
return PydanticResponse(MyModel)
Demo
Try it with http post :8000/api/user name=alice age=18
. (if you are using httpie
)
Flask
from flask import Flask, request, jsonify
from pydantic import BaseModel, Field, constr
from spectree import SpecTree, Response
class Profile(BaseModel):
name: constr(min_length=2, max_length=40)
age: int = Field(..., gt=0, lt=150, description="user age(Human)")
class Config:
schema_extra = {
"example": {
"name": "very_important_user",
"age": 42,
}
}
class Message(BaseModel):
text: str
app = Flask(__name__)
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():
"""
verify user profile (summary of this endpoint)
user's name, user's age, ... (long description)
"""
print(request.context.json)
return jsonify(text="it works")
if __name__ == "__main__":
spec.register(app)
app.run(port=8000)
Flask example with type annotation
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)
return jsonify(text="it works")
Quart
from quart import Quart, jsonify, request
from pydantic import BaseModel, Field, constr
from spectree import SpecTree, Response
class Profile(BaseModel):
name: constr(min_length=2, max_length=40)
age: int = Field(..., gt=0, lt=150, description="user age")
class Config:
schema_extra = {
"example": {
"name": "very_important_user",
"age": 42,
}
}
class Message(BaseModel):
text: str
app = Quart(__name__)
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():
"""
verify user profile (summary of this endpoint)
user's name, user's age, ... (long description)
"""
print(request.context.json)
return jsonify(text="it works")
if __name__ == "__main__":
spec.register(app)
app.run(port=8000)
Quart example with type annotation
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)
return jsonify(text="it works")
Falcon
import falcon
from wsgiref import simple_server
from pydantic import BaseModel, Field, constr
from spectree import SpecTree, Response
class Profile(BaseModel):
name: constr(min_length=2, max_length=40)
age: int = Field(..., gt=0, lt=150, description="user age(Human)")
class Message(BaseModel):
text: str
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):
"""
verify user profile (summary of this endpoint)
user's name, user's age, ... (long description)
"""
print(req.context.json)
resp.media = {"text": "it works"}
if __name__ == "__main__":
app = falcon.App()
app.add_route("/api/user", UserProfile())
spec.register(app)
httpd = simple_server.make_server("localhost", 8000, app)
httpd.serve_forever()
Falcon with type annotations
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)
resp.media = {"text": "it works"}
Starlette
import uvicorn
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
class Profile(BaseModel):
name: constr(min_length=2, max_length=40)
age: int = Field(..., gt=0, lt=150, description="user age(Human)")
class Message(BaseModel):
text: str
spec = SpecTree("starlette")
@spec.validate(
json=Profile, resp=Response(HTTP_200=Message, HTTP_403=None), tags=["api"]
)
async def user_profile(request):
"""
verify user profile (summary of this endpoint)
user's name, user's age, ... (long description)
"""
print(request.context.json)
return JSONResponse(
{"text": "it works"}
)
if __name__ == "__main__":
app = Starlette(
routes=[
Mount(
"api",
routes=[
Route("/user", user_profile, methods=["POST"]),
],
)
]
)
spec.register(app)
uvicorn.run(app)
Starlette example with type annotations
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)
return JSONResponse({"text": "it works"})
FAQ
ValidationError: missing field for headers
The HTTP headers' keys in Flask are capitalized, in Falcon are upper cases, in Starlette are lower cases.
You can use pydantic.root_validators(pre=True)
to change all the keys into lower cases or upper cases.
ValidationError: value is not a valid list for the query
Since there is no standard for HTTP queries with multiple values, it's hard to find a way to handle this for different web frameworks. So I suggest not to use list type in query until I find a suitable way to fix it.