Skip to main content

Class-based routing with dynamic Pydantic model generation for FastAPI

Project description

formaxapi

formaxapi

Class-based routing with dynamic Pydantic model generation for FastAPI.

Python 3.10+ FastAPI License

Why formaxapi?

Traditional FastAPI development scatters code across Pydantic models, route handlers, and router configuration. formaxapi consolidates everything into a single class:

  • Routes inside the class — defined with @route decorator
  • Typed inputUserRoute.schema("add") auto-validates request body
  • Auto-discovered schemas — no manual registration
  • SelfDerivedModel — derive schemas from own fields for bulk operations
  • One-liner routerroute_factory() returns a FastAPI APIRouter
  • Works with any ORM — Pydantic, Beanie, SQLAlchemy/SQLModel
pip install formaxapi

30-Second Demo

from __future__ import annotations
from fastapi import FastAPI, Request
from formaxapi 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 formaxapi

Optional dependencies:

# For Beanie (MongoDB)
pip install beanie motor

# For SQLModel (SQLAlchemy)
pip install sqlmodel aiosqlite

Quick Start

1. Define Schema Configs

from formaxapi import FieldConfig

class Add(FieldConfig):
    required = True

class Edit(FieldConfig):
    default = None

class Output(FieldConfig):
    pass

2. Define Route Class

from formaxapi 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 formaxapi 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 formaxapi 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 formaxapi 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 formaxapi 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 formaxapi 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 formaxapi 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 formaxapi 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 formaxapi 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 formaxapi 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. Supports list and dict containers:

from formaxapi 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())

    # list container (default)
    items: list = RouteField(
        bulk_add=BulkAdd(
            default=SelfDerivedModel(schema="add", exclude_fields=["email"])
        ),
    )

    # dict container
    mapping: dict = RouteField(
        bulk_add=BulkAdd(
            default=SelfDerivedModel(schema="add", container="dict")
        ),
    )

# UserRoute.schema("bulk_add") => items: list[name: str]  (email excluded)
# UserRoute.schema("bulk_add") => mapping: dict[str, {name: str, email: str}]

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,
        container: str = "list",  # "list" or "dict"
    ): ...

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

formaxapi includes a SKILL.md file for AI code agents (Codex, MiMo, etc.). This file provides agents with specialized knowledge to work effectively with the formaxapi 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 formaxapi-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

formaxapi-0.1.11.tar.gz (27.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

formaxapi-0.1.11-py3-none-any.whl (47.7 kB view details)

Uploaded Python 3

File details

Details for the file formaxapi-0.1.11.tar.gz.

File metadata

  • Download URL: formaxapi-0.1.11.tar.gz
  • Upload date:
  • Size: 27.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for formaxapi-0.1.11.tar.gz
Algorithm Hash digest
SHA256 d831d19452d5240ab9d7fa6773f2f6ed7fec57f541f192459057e3594a664212
MD5 7d2f46e96c9c3e1de94afb6b39841b29
BLAKE2b-256 901ddd0697ea3bdd690322c666e07f1754a58d5d64f12740a60193b680ee331c

See more details on using hashes here.

File details

Details for the file formaxapi-0.1.11-py3-none-any.whl.

File metadata

  • Download URL: formaxapi-0.1.11-py3-none-any.whl
  • Upload date:
  • Size: 47.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for formaxapi-0.1.11-py3-none-any.whl
Algorithm Hash digest
SHA256 f864140ba89cfd3e7a05f45551aa16ec7ed7c04b93a0025e59eda5e42d0e278f
MD5 ab130d983f9e4973f79a91275d80d0d8
BLAKE2b-256 bdb8bcb61a2d0be618207b4fdd8e8af2e263aa0e788f1b74dcae73548663a6f5

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page