Custom Routes (Controller & @route)
Modules add HTTP endpoints — portal pages, website pages, webhooks, custom JSON APIs — by
subclassing Controller and decorating methods with @route. This is the routing
mechanism in Fullfinity: do not register endpoints with bare FastAPI @router.get(...)
decorators. The Controller/@route system is what gives you two things FastAPI alone
does not: per-database composition (a route only exists for databases whose module is
installed) and __inherit__ chaining (one module can override or wrap another module’s
route via super(), the same MRO mechanism models use).
Every real web module uses it — portal, website, blog, ecommerce,
online_payment. Controllers live in a module’s routes/ directory.
Defining a controller
Section titled “Defining a controller”A base controller declares a _name; route methods take self first (the controller
instance), then request and any FastAPI-injectable parameters.
from fastapi import Requestfrom fastapi.responses import JSONResponsefrom fullfinity.engine.controller import Controller, routefrom fullfinity.engine.base import env_ctx, get_model
class MessageController(Controller): _name = "message"
@route("/api/send_message", methods=["PUT"], auth="internal") async def send_message(self, request: Request, request_body: dict): env = env_ctx.get() # ... business logic ... return JSONResponse({"status": "ok"})The metaclass collects every @route-decorated method and auto-registers the controller
under the current module being loaded — there is no manifest entry and no manual
include_router call. After all modules load, controllers are composed and their routes
registered onto the app router.
The @route decorator
Section titled “The @route decorator”def route(path, methods=None, auth="internal", response_class=None, **kwargs)| Argument | Default | Meaning |
|---|---|---|
path | — | URL path, e.g. "/blog/{slug}". Path/query/body params are injected by FastAPI from the method signature (minus self). |
methods | ["GET"] | HTTP methods, e.g. ["GET", "POST"]. |
auth | "internal" | Authentication mode (see below). |
response_class | None | A FastAPI response class, e.g. HTMLResponse, JSONResponse. |
**kwargs | — | Extra FastAPI route parameters, passed through. |
The auth modes
Section titled “The auth modes”auth selects which authentication dependency the route is registered with. The four values
map directly onto the dependencies in engine/middleware.py:
auth value | Dependency | Who can call it |
|---|---|---|
"internal" (default) | requires_internal | Logged-in internal user; 401 if no token, 403 if not internal. |
"user" | requires_user | Any logged-in user (internal or portal); 403 for guest-only. |
"public" | allows_public | Anyone — authenticates if a token is present, otherwise falls back to a guest user. Never raises 401/403. |
"none" | (no auth dependency) | No authentication middleware at all. Use for external callbacks (webhooks) that authenticate themselves. |
The semantics of each dependency (user types, the core_internal/core_portal/core_public
groups, redirect-to-login behaviour) are covered in
Route Authentication — auth="..." is the controller-level
shorthand for wiring those exact dependencies, so read that page for the access model.
# A public website page that personalizes for a logged-in user but works for guests:@route("/blog/{slug}", methods=["GET"], auth="public", response_class=HTMLResponse)async def blog_detail(self, request: Request, slug: str): ...
# An external payment gateway callback — no platform auth; the handler verifies the# provider signature itself:@route("/payment/webhook/{integration_id}", methods=["POST"], auth="none")async def handle_webhook(self, request: Request, integration_id: int): ...Extending another module’s controller with __inherit__
Section titled “Extending another module’s controller with __inherit__”A module can extend a controller defined by another module by setting __inherit__ to
that controller’s _name and omitting its own _name. The extension’s routes and method
overrides are composed into the target controller via real Python MRO, so super() reaches
the next implementation in the chain.
# Module: coreclass WebController(Controller): _name = "web"
@route("/", methods=["GET"]) async def home(self, request: Request): return RedirectResponse(url="/app")
# Module: portal (depends on core)class PortalController(Controller): __inherit__ = "web" # extends "web"; no _name of its own
@route("/", methods=["GET"]) async def home(self, request: Request): if is_portal_user(): return RedirectResponse(url="/my") return await super().home(request) # fall back to core's homeHow composition works:
- An extension (single
__inherit__) is keyed under the inherited controller name, so it merges into that controller rather than creating a new one. Multiple modules can each__inherit__ = "web"—blog,website, andportalall extendweb, and all their extensions are composed together. - Extensions are ordered by module load order: a module loaded later sits earlier in
the MRO, so a downstream module overrides an upstream one. The composed class is
(latest_ext, ..., earliest_ext, base_class), exactly like model__inherit__. - Routes are merged across the chain; if two classes define a route method of the same name,
the one higher in the MRO (the later module) wins, and it can call
super()to invoke the one it overrode. - A controller with multiple
__inherit__entries is treated as a new base, not an extension.
This is the same override-via-dependency-order model used throughout the framework, so a route can be progressively specialized as you add modules without any module editing another’s source.
Per-request lifecycle & transactions
Section titled “Per-request lifecycle & transactions”Every request passes through RequestMiddleware (in engine/middleware.py) before reaching
your controller method. The middleware:
- Resolves the database from the
X-DB-NAMEheader or thedbcookie. Paths under/static/,/assets/, the control panel, etc. are DB-free and skip this. If there is exactly one database, it is selected automatically; an API/auth request with no DB resolvable gets a400with codeREQUEST_NOT_TIED_TO_DB. - Waits out in-flight module operations. If an install/upgrade/uninstall is in progress
for this DB (a flag in Valkey, plus a per-worker local flag), the request waits up to
~120s; if it is still blocked it returns
503withRetry-After. After waiting it clears stale model/controller caches so the request sees the new code. - Initializes the DB and rebuilds caches on version change. It compares the model cache
version, and if it changed it clears caches, re-runs
init_db, and composes the controllers for this database from its installed modules. This is why a route only resolves on databases where its owning module is installed — uninstalled-module routes return404. - Wraps the handler in a transaction. When a DB is resolved, the controller method runs
inside
db_manager.transaction(db_name, ...). The transaction commits if the handler returns normally and rolls back if it raises — you do not manage transactions in route code. (ADryRunCompleterolls back and still returns the results with200.) - Maps exceptions to HTTP responses. You raise the engine exceptions and the middleware
produces the right status and payload:
UserError→400,ValidationError→400,AccessError→403,MissingError→404(toast-style, no traceback).InternalError/ConfigurationError/ORMError/ unhandled exceptions →500with a traceback (HTML error page for browser requests, JSON for API requests).- Database constraint violations (unique / FK / not-null / check) are translated into
friendly
400messages. - An
HTTPExceptionwith401on an HTML page redirects to/login?next=....
So in a controller method you simply raise the appropriate exception and return a response or plain dict — never catch-and-format HTTP errors yourself, and never open your own DB transaction.
class ReviewController(Controller): _name = "review_api"
@route("/api/reviews/{product_id}", methods=["POST"], auth="user") async def create_review(self, request: Request, product_id: int, body: dict): Product = get_model("Product") product = await Product.filter(id=product_id).first() if not product: raise MissingError("Product not found") # -> 404 toast if not body.get("rating"): raise ValidationError("Rating is required") # -> 400 toast # runs inside the per-request transaction; commits on return review = await get_model("ProductReview").create( product=product, rating=body["rating"], body=body.get("body", ""), ) return {"id": review[0].id}Where controllers live, and how they load
Section titled “Where controllers live, and how they load”Put controller files in your module’s routes/ directory. At startup, main.py imports
every route module (so the @route decorators run and controllers register against their
module), then calls compose_controllers(...) and register_routes_to_fastapi(router).
Per-database composition happens lazily on first request to each DB and is re-run when that
DB’s module set changes — there is nothing to wire up by hand beyond defining the class.
Quick checklist
Section titled “Quick checklist”- Subclass
Controller; give a base controller a unique_name, give an extension an__inherit__(and no_name). - Decorate handler methods with
@route(path, methods=[...], auth=...); first parameter isself, thenrequest, then anything FastAPI should inject. - Pick
authdeliberately:"internal"for backend tools,"user"for any logged-in user,"public"for guest-friendly pages,"none"only for self-authenticating external callbacks. See Route Authentication. - Raise engine exceptions (
UserError,MissingError, …) and let the middleware build the HTTP response; the transaction commits/rolls back around your handler automatically. - Place the file under the module’s
routes/directory — no manifest entry needed.