Class-based routing with dynamic Pydantic model generation for FastAPI
Project description
fastschema
Class-based routing with dynamic Pydantic model generation for FastAPI.
Why fastschema?
Traditional FastAPI development scatters code across Pydantic models, route handlers, and router configuration. fastschema consolidates everything into a single class:
- Routes inside the class — defined with
@routedecorator - Typed input —
UserRoute.schema("add")auto-validates request body - Auto-discovered schemas — no manual registration
- SelfDerivedModel — derive schemas from own fields for bulk operations
- One-liner router —
route_factory()returns a FastAPIAPIRouter - Works with any ORM — Pydantic, Beanie, SQLAlchemy/SQLModel
pip install fastschema
30-Second Demo
from __future__ import annotations
from fastapi import FastAPI, Request
from fastschema import FieldConfig, RouteField, RouteBase, route, route_factory
app = FastAPI()
class Add(FieldConfig):
required = True
class Edit(FieldConfig):
default = None
class UserRoute(RouteBase):
name: str = RouteField(add=Add(), edit=Edit(), min_length=1, max_length=100)
email: str = RouteField(add=Add(), edit=Edit())
@classmethod
@route(path="/users", method="POST", status_code=201)
async def create_user(cls, request: Request, data: UserRoute.schema("add")):
return {"id": "1", "name": data.name}
@classmethod
@route(path="/users", method="GET")
async def get_users(cls, request: Request):
return {"users": []}
app.include_router(route_factory(UserRoute))
Note: Always use from __future__ import annotations at the top of your file.
Table of Contents
Installation
pip install fastschema
Optional dependencies:
# For Beanie (MongoDB)
pip install beanie motor
# For SQLModel (SQLAlchemy)
pip install sqlmodel aiosqlite
Quick Start
1. Define Schema Configs
from fastschema import FieldConfig
class Add(FieldConfig):
required = True
class Edit(FieldConfig):
default = None
class Output(FieldConfig):
pass
2. Define Route Class
from fastschema import RouteField, RouteBase, route
class UserRoute(RouteBase):
name: str = RouteField(add=Add(), edit=Edit(), output=Output(), min_length=1)
email: str = RouteField(add=Add(), edit=Edit(), output=Output())
@classmethod
@route(path="/users", method="POST", status_code=201)
async def create_user(cls, request: Request, data: UserRoute.schema("add")):
# data.name: str (required, min_length=1)
# data.email: str (required)
return {"id": "1", "name": data.name}
3. Register Routes
from fastapi import FastAPI
from fastschema import route_factory
app = FastAPI()
app.include_router(route_factory(UserRoute))
Core Concepts
1. FieldConfig - Schema Type Configurations
FieldConfig defines how a field behaves in different schema contexts:
from fastschema import FieldConfig
class Add(FieldConfig):
required = True
class Edit(FieldConfig):
default = None
class Filter(FieldConfig):
default = None
class Output(FieldConfig):
pass
| Attribute | Type | Default | Description |
|---|---|---|---|
required |
bool |
False |
No default — must be provided |
default |
Any |
_UNSET |
Default value |
default_factory |
Callable |
None |
Factory for mutable defaults |
alias |
str |
None |
Field alias |
description |
str |
None |
Field description |
type_override |
type |
None |
Override field type |
exclude |
bool |
False |
Exclude from this schema |
frozen |
bool |
False |
Immutable field |
apply_func |
Callable |
None |
Transform function |
before |
bool |
True |
Run apply_func before/after validators |
metadata |
dict |
None |
Extra kwargs for pydantic.Field |
2. RouteField - Declarative Fields
RouteField inherits from Pydantic's FieldInfo, so it works everywhere:
from fastschema import RouteField, FieldConfig
class Add(FieldConfig):
required = True
# In RouteBase — schema configs work
class UserRoute(RouteBase):
title: str = RouteField(
add=Add(),
edit=Edit(),
alias="product_title",
description="The product title",
min_length=1,
max_length=200,
)
# In BaseModel — Pydantic Field params work
from pydantic import BaseModel
class MyModel(BaseModel):
title: str = RouteField(alias="x", description="test", min_length=1)
All Pydantic Field params are supported:
RouteField(
# Pydantic params
alias="x",
validation_alias="title",
description="Field description",
exclude=False,
frozen=False,
min_length=1,
max_length=100,
gt=0,
ge=0,
lt=100,
le=100,
pattern=r'^[A-Z]+$',
multiple_of=5,
json_schema_extra={"example": "hello"},
# Schema configs
add=Add(),
edit=Edit(),
output=Output(),
)
3. RouteBase - Base Class
RouteBase provides schema generation and introspection:
class UserRoute(RouteBase):
name: str = RouteField(add=Add(), edit=Edit())
# Generate schemas
UserRoute.schema("add") # => name: str (required)
UserRoute.schema("edit") # => name: str | None (optional)
# Introspection
UserRoute.schema_types() # => ["add", "edit"]
UserRoute.schema_fields("add") # => ["name"]
UserRoute.all_fields() # => {"name": FieldInfo(...)}
UserRoute.field_names() # => ["name"]
4. @route Decorator
Define endpoints inside the class:
from fastschema import route
class UserRoute(RouteBase):
@classmethod
@route(
path="/users",
method="POST",
name="create_user",
description="Create a new user",
status_code=201,
tags=["users"],
)
async def create_user(cls, request: Request, data: UserRoute.schema("add")):
return {"id": "1"}
| Parameter | Type | Default | Description |
|---|---|---|---|
path |
str |
required | URL path |
method |
str |
"GET" |
HTTP method |
name |
str |
None |
Route name |
description |
str |
None |
Route description |
status_code |
int |
200 |
Response status code |
tags |
list[str] |
None |
OpenAPI tags |
5. route_factory
Collect all routes into a FastAPI router:
from fastschema import route_factory
router = route_factory(UserRoute, ProductRoute)
app.include_router(router)
Examples
Raw Pydantic
Full example without any ORM:
from __future__ import annotations
from fastapi import FastAPI, Request
from fastschema import FieldConfig, RouteField, RouteBase, route, route_factory
from pydantic import BaseModel, field_validator
app = FastAPI()
# --- Configs ---
class Add(FieldConfig):
required = True
class Edit(FieldConfig):
default = None
class Output(FieldConfig):
pass
# --- Route Class ---
class UserRoute(RouteBase):
name: str = RouteField(
add=Add(),
edit=Edit(),
output=Output(),
min_length=1,
max_length=100,
)
email: str = RouteField(add=Add(), edit=Edit(), output=Output())
age: int = RouteField(add=Add(default=0), edit=Edit(), output=Output(), ge=0)
@classmethod
@route(path="/users", method="GET")
async def get_users(cls, request: Request):
return {"users": []}
@classmethod
@route(path="/users", method="POST", status_code=201)
async def create_user(cls, request: Request, data: UserRoute.schema("add")):
return {"id": "1", "name": data.name}
@classmethod
@route(path="/users/{user_id}", method="PUT")
async def update_user(cls, request: Request, user_id: str, data: UserRoute.schema("edit")):
return {"id": user_id}
@classmethod
@route(path="/users/{user_id}", method="DELETE")
async def delete_user(cls, request: Request, user_id: str):
return {"deleted": True}
app.include_router(route_factory(UserRoute))
Beanie (MongoDB)
Full example with Beanie ODM:
from __future__ import annotations
from fastapi import FastAPI, Request
from fastschema import FieldConfig, RouteField, RouteBase, route, route_factory
from beanie import Document, PydanticObjectId
from contextlib import asynccontextmanager
from pymongo import AsyncMongoClient
from beanie import init_beanie
from typing import ClassVar
app = FastAPI()
# --- Configs ---
class Add(FieldConfig):
required = True
class Edit(FieldConfig):
default = None
class Output(FieldConfig):
pass
class Get(FieldConfig):
default = None
# --- Document + Route Class ---
class UserRoute(RouteBase, Document):
name: str = RouteField(add=Add(), edit=Edit(), output=Output(), min_length=1)
email: str = RouteField(add=Add(), edit=Edit(), output=Output())
# ClassVar — not in DB, available for schema generation
token: ClassVar[str | None] = RouteField(get=Get(), add=Add(exclude=True))
class Settings:
name = "users"
@classmethod
@route(path="/users", method="GET")
async def get_users(cls, request: Request):
users = await cls.find_all().to_list()
return [{"id": str(u.id), "name": u.name} for u in users]
@classmethod
@route(path="/users", method="POST", status_code=201)
async def create_user(cls, request: Request, data: UserRoute.schema("add")):
user = cls.from_schema(data)
await user.insert()
return {"id": str(user.id), "name": user.name}
@classmethod
@route(path="/users/{user_id}", method="GET")
async def get_user(cls, request: Request, user_id: str):
user = await cls.get(PydanticObjectId(user_id))
if not user:
return {"error": "not found"}
return {"id": str(user.id), "name": user.name}
@classmethod
@route(path="/users/{user_id}", method="DELETE")
async def delete_user(cls, request: Request, user_id: str):
user = await cls.get(PydanticObjectId(user_id))
if user:
await user.delete()
return {"deleted": True}
# --- Lifespan ---
@asynccontextmanager
async def lifespan(app: FastAPI):
client = AsyncMongoClient("mongodb://localhost:27017")
await init_beanie(database=client["mydb"], document_models=[UserRoute])
yield
client.close()
app = FastAPI(lifespan=lifespan)
app.include_router(route_factory(UserRoute))
SQLModel (SQLAlchemy)
Full example with SQLModel:
from __future__ import annotations
from fastapi import FastAPI, Request
from fastschema import FieldConfig, RouteField, RouteBase, route, route_factory
from sqlmodel import SQLModel, Field, Session, create_engine
from typing import ClassVar
# --- Database ---
engine = create_engine("sqlite:///database.db")
# --- Configs ---
class Add(FieldConfig):
required = True
class Edit(FieldConfig):
default = None
class Output(FieldConfig):
pass
# --- Model + Route Class ---
class UserRoute(RouteBase, SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str = RouteField(
add=Add(),
edit=Edit(),
output=Output(),
min_length=1,
max_length=100,
)
email: str = RouteField(add=Add(), edit=Edit(), output=Output())
# ClassVar — not in DB, available for schema generation
token: ClassVar[str | None] = RouteField(
get=Get(),
add=Add(exclude=True),
)
@classmethod
@route(path="/users", method="GET")
async def get_users(cls, request: Request):
with Session(engine) as session:
users = session.exec(select(cls)).all()
return [{"id": u.id, "name": u.name} for u in users]
@classmethod
@route(path="/users", method="POST", status_code=201)
async def create_user(cls, request: Request, data: UserRoute.schema("add")):
user = cls.from_schema(data)
with Session(engine) as session:
session.add(user)
session.commit()
session.refresh(user)
return {"id": user.id, "name": user.name}
@classmethod
@route(path="/users/{user_id}", method="GET")
async def get_user(cls, request: Request, user_id: int):
with Session(engine) as session:
user = session.get(cls, user_id)
if not user:
return {"error": "not found"}
return {"id": user.id, "name": user.name}
@classmethod
@route(path="/users/{user_id}", method="DELETE")
async def delete_user(cls, request: Request, user_id: int):
with Session(engine) as session:
user = session.get(cls, user_id)
if user:
session.delete(user)
session.commit()
return {"deleted": True}
# --- Create tables and app ---
SQLModel.metadata.create_all(engine)
app = FastAPI()
app.include_router(route_factory(UserRoute))
Advanced Features
Chain - Compose Functions
Chain multiple functions for apply_func:
from fastschema import FieldConfig, RouteField, Chain
def strip(v):
return v.strip() if isinstance(v, str) else v
def upper(v):
return v.upper() if isinstance(v, str) else v
def remove_spaces(v):
return v.replace(" ", "_") if isinstance(v, str) else v
class Add(FieldConfig):
required = True
class ProductRoute(RouteBase):
title: str = RouteField(
add=Add(apply_func=Chain(strip, upper, remove_spaces)),
)
# " hello world " → "hello_world"
SelfDerivedModel - Bulk Operations
Derive a field's schema from the route's own fields:
from fastschema import FieldConfig, RouteField, RouteBase, SelfDerivedModel
class Add(FieldConfig):
required = True
class Output(FieldConfig):
pass
class BulkAdd(FieldConfig):
required = True
class BulkEdit(FieldConfig):
default = None
class UserRoute(RouteBase):
name: str = RouteField(add=Add(), output=Output())
email: str = RouteField(add=Add(), output=Output())
items: list = RouteField(
bulk_add=BulkAdd(
default=SelfDerivedModel(schema="add", exclude_fields=["email"])
),
)
# UserRoute.schema("bulk_add") => items: list[name: str] (email excluded)
ClassVar Fields
Use ClassVar for fields not in the database but available for schema generation:
from typing import ClassVar
class UserRoute(RouteBase, Document):
name: str = RouteField(add=Add(), edit=Edit())
# ClassVar — not in DB, available for schema generation
token: ClassVar[str | None] = RouteField(get=Get(), add=Add(exclude=True))
# token is NOT in DB columns
# token IS in _fields for schema generation
# token appears in "get" schema, excluded from "add" schema
from_schema()
Create Document instances without re-validating nested models:
class UserRoute(RouteBase, Document):
name: str = RouteField(add=Add())
@classmethod
@route(path="/users", method="POST", status_code=201)
async def create_user(cls, request: Request, data: UserRoute.schema("add")):
# WRONG — re-validates nested models
# user = cls(**data.dict())
# CORRECT — preserves already-validated instances
user = cls.from_schema(data)
await user.insert()
return {"id": str(user.id)}
Validators
Validators defined on the route class are propagated to generated models:
from pydantic import BaseModel, field_validator
class Data(BaseModel):
username: str
password: str
@field_validator("username", mode="before")
@classmethod
def validate_username(cls, v):
return v.strip()
class UserRoute(RouteBase, Document):
data: Data = RouteField(add=Add())
@field_validator("name", mode="before")
@classmethod
def validate_name(cls, v):
return v.strip()
# Both validators run when UserRoute.schema("add") is used
Constraint Params
All Pydantic constraint params work in RouteField:
class ProductRoute(RouteBase):
title: str = RouteField(add=Add(), min_length=1, max_length=200)
price: float = RouteField(add=Add(), gt=0, le=9999.99)
quantity: int = RouteField(add=Add(), ge=0, multiple_of=1)
sku: str = RouteField(add=Add(), pattern=r"^[A-Z]{3}-\d{4}$")
description: str = RouteField(add=Add(), max_length=5000)
API Reference
FieldConfig
class FieldConfig:
required: bool = False
default: Any = _UNSET
default_factory: Callable | None = None
alias: str | None = None
description: str | None = None
type_override: type | None = None
exclude: bool = False
frozen: bool = False
apply_func: Callable | None = None
before: bool = True
metadata: dict | None = None
RouteField
class RouteField(PydanticFieldInfo):
def __init__(
self,
default: Any = _UNSET,
*,
default_factory: Callable | None = None,
alias: str | None = None,
validation_alias: str | None = None,
serialization_alias: str | None = None,
description: str | None = None,
title: str | None = None,
exclude: bool = False,
frozen: bool = False,
deprecated: str | None = None,
json_schema_extra: dict | None = None,
validate_default: bool = False,
repr: bool = True,
**kwargs: Any, # Pydantic constraint params + schema configs
): ...
RouteBase
class RouteBase(BaseModel, metaclass=RouteMetaclass):
@classmethod
def schema(
cls,
schema_type: str,
*,
name: str | None = None,
include_fields: list[str] | None = None,
exclude_fields: list[str] | None = None,
forbid_extra: bool = True,
as_literal: bool = False,
) -> type[BaseModel]: ...
@classmethod
def Schema(cls, schema_type: str) -> type[BaseModel]: ...
@classmethod
def schema_fields(cls, schema_type: str) -> list[str]: ...
@classmethod
def all_fields(cls) -> dict[str, FieldInfo]: ...
@classmethod
def field_names(cls) -> list[str]: ...
@classmethod
def schema_types(cls) -> list[str]: ...
@classmethod
def from_schema(cls, data) -> Self: ...
@classmethod
def field_config_for(cls, field_name: str, schema_type: str) -> FieldConfig | None: ...
SelfDerivedModel
class SelfDerivedModel:
def __init__(
self,
schema: str,
is_optional: bool = True,
format: str = "model",
include_fields: list[str] | None = None,
exclude_fields: list[str] | None = None,
): ...
route
def route(
path: str,
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "GET",
name: str | None = None,
description: str | None = None,
status_code: int = 200,
tags: list[str] | None = None,
) -> Callable: ...
route_factory
def route_factory(*route_classes: type) -> APIRouter: ...
Chain
class Chain:
def __init__(self, *funcs: Callable): ...
def __call__(self, v: Any) -> Any: ...
Skills
fastschema includes a SKILL.md file for AI code agents (Codex, MiMo, etc.). This file provides agents with specialized knowledge to work effectively with the fastschema package.
What it covers:
- Core concepts (FieldConfig, RouteField, RouteBase, @route, route_factory)
- ORM integration patterns (SQLModel, Beanie)
- Advanced features (SelfDerivedModel, Chain, ClassVar, validators)
- Common CRUD and bulk operation patterns
Usage: Agents automatically discover and load the skill when working with fastschema-related tasks. The skill file is located at the project root alongside README.md.
License
Apache License 2.0 — see LICENSE for details.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file formaxapi-0.1.8.tar.gz.
File metadata
- Download URL: formaxapi-0.1.8.tar.gz
- Upload date:
- Size: 26.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ce72d97ab52a6b4b0cb4f3332e6cb790c49736bf3e60e09120928422de4478dd
|
|
| MD5 |
0ae45fd45eac5108cc8495379e2c4a1b
|
|
| BLAKE2b-256 |
066fc7cde187e1cdd62dcd86b224284d6cd20b3fce62ef97a7b786bacc6b9450
|
File details
Details for the file formaxapi-0.1.8-py3-none-any.whl.
File metadata
- Download URL: formaxapi-0.1.8-py3-none-any.whl
- Upload date:
- Size: 47.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
15c226a98d2008a44090478d83b15244d8347e15f6b3e8d7e5dba584990ea0bc
|
|
| MD5 |
df3f19b6878464fc7486749c59d8f9a3
|
|
| BLAKE2b-256 |
7253c77e3a286c4be8e49538752cc92917d1fb24b9df2108a91bfa4c18842b20
|