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 Response instance and its native subclasses like JSONResponse, RedirectResponse, etc.
  • Any response returned by the response helpers like json(), html(), etc.
  • Annotated database models.
  • Paginators.
  • Simple types like str, int, or dict.

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.
    """
    ...