OpenAPI documentation
Expanse can generate an OPenAPI documentation for your application automatically via a built-in package called Schematic. The idea behind Schematic is to provide a simple and effective way to document your API endpoints by leveraging standard Python typing, documentation strings — in various formats — and code inference.
This allows you to focus on code and let Schematic keep up with it automatically to always have up-to-date documentation.
The generated OpenAPI documentation follows the OpenAPI 3.1 specification and can be accessed via a beautiful UI powered by Stoplight Elements.
Schematic is still considered experimental and, as such, it is not feature-complete and its API is not guaranteed and is subject to change between Expanse versions.
Setup
To get started with Schematic, you only need to add its service provider to your application. Open the:
config/app.py file and add the SchematicProvider to the list of providers:
class Config:
providers: list[str] = Field(
default=(
ServiceProvidersList.default()
.merge(
[
# Package-provided providers
"expanse.schematic.schematic_service_provider.SchematicServiceProvider"
]
)
.merge(
[
# Application-specific providers
...
]
)
.to_list()
)
)
By default, this adds two new routes to your application:
/docs/api: the UI for the OpenAPI documentation/docs/api.json: the OpenAPI JSON specification
Configuration file
If you need to customize the behavior of Schematic, you can add a schematic.py configuration file in the config
directory of your application. By default, this file looks like this:
from pydantic import BaseModel
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
class APIInfo(BaseModel):
version: str = "0.0.1"
description: str = ""
class Config(BaseSettings):
# URL path for the API.
# By default, all routes prefixed with this path will be considered part of the API
# and be added to the documentation.
api_path: str = "/api"
info: APIInfo = APIInfo()
model_config: SettingsConfigDict = SettingsConfigDict(
env_prefix="SCHEMATIC_", env_nested_delimiter="__"
)
Getting started
Once Schematic is set up, you want to make sure that your API endpoints are present in the OpenAPI documentation.
Routes resolution
By default, Schematic will only document routes that are prefixed with the path defined in the api_path setting of the
schematic configuration file. If you kept the default value, this means that only routes prefixed with /api will be
documented. For instance, if you have configured an endpoint like /api/users, it will be present in the generated
documentation, while an endpoint like /health will not.
This can be changed by modifying the api_path setting in the schematic configuration file.
API overview
The landing page of the OpenAPI documentation provides an overview of your API, but is mostly empty by default. You can
customize it by modifying the info setting in the schematic configuration file. You can add a
description
that can be as long as you want, and it supports Markdown formatting.
class APIInfo(BaseModel):
version: str = "0.0.1"
description: str = "This is the **API documentation** for the application."
Documenting requests
Schematic documents the request part of your API endpoints automatically by using a combination of function signature analysis, documentation string analysis and code inference. For instance, request bodies are documented based on the pydantic models used to validate them while path parameters based on their type-hints and documentation.
Path parameters
Schematic identifies and documents path parameters automatically. For instance:
@patch("/users/{user_id}")
def get_user(user_id: int) -> Response:
...
Here, the user_id path parameter is automatically identified as an integer parameter and documented as such in the
OpenAPI specification.
However, Schematic wil not be able to generate a description for the parameter automatically. To add one, you can use the documentation string of the function:
@patch("/users/{user_id}")
def get_user(user_id: int) -> Response:
"""
:param user_id: The ID of the user to update.
"""
@patch("/users/{user_id}")
def get_user(user_id: int) -> Response:
"""
Args:
user_id: The ID of the user to update.
"""
Request body
Request body documentation is generated based on the pydantic models used to validate all or part of it. For instance:
from datetime import date
from expanse.http.json import JSON
from pydantic import BaseModel
class UserUpdateRequest(BaseModel):
name: str
email: str
birth_date: date | None = None
@patch("/users/{user_id}")
def update_user(user_id: int, body: JSON[UserUpdateRequest]) -> Response:
...
from datetime import date
from expanse.http.form import Form
from pydantic import BaseModel
class UserUpdateRequest(BaseModel):
name: str
email: str
birth_date: date | None = None
@patch("/users/{user_id}")
def update_user(user_id: int, body: Form[UserUpdateRequest]) -> Response:
...
This will generate an OpenAPI schema that will be used to document the request body of the update_user endpoint.
Note that here again, Schematic will not be able to generate descriptions for the individual fields of the request body automatically. To add them, you can provide descriptions in the pydantic model directly:
from datetime import date
from pydantic import BaseModel, Field
class UserUpdateRequest(BaseModel):
name: str = Field(..., description="The name of the user.")
email: str = Field(..., description="The email address of the user.")
birth_date: date | None = Field(
None, description="The birth date of the user."
)
Query parameters
Similarly, to request bodies, query parameters are documented based on the pydantic models used to validate them. For instance:
from expanse.http.query import Query
from pydantic import BaseModel, Field
class ListArticleModel(BaseModel):
category: str = Field(..., description="The category of articles to filter by.")
with_drafts: bool = Field(False, description="Whether to include draft articles.")
def list_articles(q: Query[ListArticleModel]) -> Response:
...
Documenting responses
Schematic documents the response part of your API endpoints automatically by using a combination of function signature analysis — mostly the return type —, documentation string analysis and code inference.
Currently, Schematic supports the following response types:
- Any
Responseinstance and its native subclasses likeJSONResponse,RedirectResponse, etc. - Any response returned by the response helpers like
json(),html(), etc. - Annotated database models.
- Paginators.
- Simple types like
str,int, ordict.
Error responses
Schematic analyses the code of your endpoints to identify possible error responses that can be returned. Here are what Schematic currently supports:
Pydantic models as request bodies or query parameters
When a pydantic model is used to validate a request body or query parameters,
Schematic will automatically add a 422 Unprocessable Entity response to the documentation of the endpoint.
Raising HTTP exceptions
When an endpoint raises an HTTPException, Schematic will automatically add the corresponding response to the
documentation of the endpoint. For instance:
from expanse.core.http.exceptions import HTTPException
def get_user(user_id: int) -> Response:
user = ...
if not user:
raise HTTPException(404, "User not found")
...
Here, Schematic will add a 404 Not Found response to the documentation of the get_user endpoint.
The abort() helper
When an endpoint calls the abort() helper function, Schematic will automatically add the corresponding response to
the documentation of the endpoint. For instance:
from expanse.http.helpers import abort
def get_user(user_id: int) -> Response:
user = ...
if not user:
abort(404, "User not found")
...
Explicit documentation
You can explicitly document error responses by using the documentation string of the endpoint function. For instance:
def get_user(user_id: int) -> Response:
"""
:raises HTTPException: (404) The user does not exist.
"""
# find_user() raises HTTPException(404, "User not found")
# if the user is not found
user = find_user(user_id)
...
def get_user(user_id: int) -> Response:
"""
Raises:
HTTPException: (404) The user does not exist.
"""
# find_user() raises HTTPException(404, "User not found")
# if the user is not found
user = find_user(user_id)
...
The format is the following: HTTPException: (<status_code>) <description>. You can add as many exceptions as you
want by adding multiple :raises or Raises entries in the documentation string.
More specialized exception types may be supported in the future.
Response description
A description of the response may be added by using the documentation string of the endpoint function:
def get_user(user_id: int) -> Response:
"""
:return: The user information.
"""
...
def get_user(user_id: int) -> Response:
"""
Returns:
The user information.
"""
...