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:

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

    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

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.9.tar.gz (26.6 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.9-py3-none-any.whl (47.2 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: formaxapi-0.1.9.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

Hashes for formaxapi-0.1.9.tar.gz
Algorithm Hash digest
SHA256 90c989982d1111c39df1c1c3f65d1d79c827152642bf39ad3036ba58c395ac2a
MD5 4c124a5479dc75c6efa94f4d70320e53
BLAKE2b-256 827961493289de212bcf8eb00befd32c53c2e8c40d367fd15bba143e961a0c8e

See more details on using hashes here.

File details

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

File metadata

  • Download URL: formaxapi-0.1.9-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

Hashes for formaxapi-0.1.9-py3-none-any.whl
Algorithm Hash digest
SHA256 6f4e4a548c7b23c62dbb9d45a628ddfee466dd937cffd34082dc395ca2c378b6
MD5 8eb72fe5833d67a276d19c0dbec0da1e
BLAKE2b-256 1f8ded51187f3ca386d1af93459b3973551a9991dfc1d645067f62806862bd97

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