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, login_url=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. |
login_url | None | Sign-in page an unauthenticated browser is redirected to for this route (see below). Defaults to the single sign-in page, /login. Override it only if a route needs a different sign-in page. |
**kwargs | — | Extra FastAPI route parameters, passed through. |
login_url — a single sign-in page for staff and customers
Section titled “login_url — a single sign-in page for staff and customers”When a browser hits an auth-guarded HTML route without a valid session, the framework
redirects to a login page (?next= carries the original URL back) instead of returning a
bare 401. There is one sign-in page, /login, for everyone — staff and customers
alike. After signing in, the visitor is routed by account type: an internal (staff) user is
served the backoffice at /app, while a customer account is forwarded to the portal home.
So a customer-facing route (a self-service portal page) needs no special login page — it
inherits the default /login.
login_url still exists as an escape hatch for the rare route that genuinely needs a
different sign-in page, but you will almost never set it:
@route("/portal/{document_type}", methods=["GET"], auth="user", response_class=HTMLResponse) # unauthenticated visitors go to /loginasync def portal_document_list(self, request, document_type): ...The mapping is keyed by the route’s static path prefix, so one login_url declaration (when
used) covers the whole sub-tree. Only browser navigations (HTML requests) are redirected;
XHR/JSON callers still receive the raw 401.
The auth modes
Section titled “The auth modes”auth selects which authentication dependency the route is registered with. The four values
map onto the framework’s built-in auth dependencies:
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:
- Multiple modules can each extend the same controller.
blog,website, andportalcan all__inherit__ = "web", and their routes are composed together onto that one controller. - A module that depends on another overrides it. If two modules declare
@routefor the same path and methods on the same controller, the one whose module is loaded later (the downstream dependant) wins, and it can callsuper()to invoke the implementation it overrode — the same dependency-ordered override model as model__inherit__. Note that it is the route the two share, not the method name; see route identity below.
So a route can be progressively specialized as you add modules, without any module editing another’s source.
__inherit__ must name a BASE controller — not just any _name
Section titled “__inherit__ must name a BASE controller — not just any _name”A base is a controller declared without __inherit__. Only base names are composed,
so an extension must target one. The catch is that a controller having a _name does not
make it a base:
class PortalController(Controller): _name = "portal" # a label, NOT a base __inherit__ = "web" # portal is itself an extension of "web"So to add a page to the customer portal from your own module, extend web, not
"portal":
class MyPortalController(Controller): _name = "my_portal" __inherit__ = "web" # correct — "portal" is not a base
@route("/portal/things/new", methods=["GET"], auth="user") async def thing_new(self, request: Request): ...Every web surface — the portal, the shop, the blog, events, surveys, online payment —
extends the single web base and composes into one class per database, so the portal’s
helpers are reachable from your controller through the shared MRO regardless.
This is checked in two places, because before either existed the failure was completely silent — the routes were never merged and the method overrides never entered an MRO, so the module imported cleanly, its tests passed, and the page simply 404’d with nothing logged anywhere:
./fullfinity-server check --only controllers(CI + pre-commit, and per-module for app-store intake via--module) — a static scan that names the offending class and the bad target. This is the one that matters day to day.- Composition itself raises a
ConfigurationErrorfor the databases that installed the module. Per-database composition is reached lazily from ordinary runtime paths, so this is a backstop for code that never went through the gate, not the primary defence.
A unit test on the class cannot catch this, because the class itself is fine. Drive the composed controller instead:
composed = compose_controllers_for_db(probe_key, installed_modules).get("web")assert hasattr(composed, "thing_new")A route is identified by its path and verbs — not by the handler’s name
Section titled “A route is identified by its path and verbs — not by the handler’s name”Controller composition flattens every extension of a base into one class, so handler method
names are a single namespace across every module in the install. A route is therefore not
bound to your method’s name: it is keyed by (path, methods) and dispatched through the
function that declared it. Two handlers may share a name; they cannot share a route.
# Module: online_paymentclass OnlinePaymentController(Controller): _name = "online_payment" __inherit__ = "web"
@route("/api/online_payment/return", methods=["GET"], auth="public") async def payment_return(self, request: Request): ...
# Module: payment_acme — same method name, different path. Both routes exist.class AcmeController(Controller): _name = "payment_acme" __inherit__ = "web"
@route("/payment_acme/return", methods=["GET", "POST"], auth="public") async def payment_return(self, request: Request): ...To override an inherited route, re-declare it — repeat the base’s @route with the same
path and methods. That is what makes it the same route, and what makes it replace the base’s
handler:
class AiProfileController(Controller): _name = "ai_profile" __inherit__ = "main"
@route("/api/load_profile", methods=["GET"], auth="internal") # ← required async def load_profile(self, request: Request): profile = await super().load_profile(request) profile["_config"]["_ai"] = await is_enabled() return profilesuper() works exactly as it does on a bound method: the handler is called against the
composed instance, so its MRO is intact and super().load_profile(request) reaches the
implementation it replaced.
Redefining the method name without re-declaring the route binds nothing — the endpoint keeps
answering with the implementation it already had, and your override silently never runs. That
is the one silence left here, and ./fullfinity-server check --only controllers fails on
it, naming the class and method.
Per-request lifecycle & transactions
Section titled “Per-request lifecycle & transactions”Before your controller method runs, the framework applies the following behavior around it:
- Resolves the database from the
X-DB-NAMEheader or thedbcookie. Static/asset paths and the control panel 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 running for
this DB, the request waits briefly; if it is still blocked it returns
503withRetry-After. Once the operation finishes, the request sees the new code. - Only resolves routes whose module is installed on that DB. A route exists per-database:
it resolves on databases where its owning module is installed and returns
404where it is not — and it starts resolving as soon as the module is installed, with no restart. - Wraps the handler in a transaction. The controller method runs inside a per-request
transaction: it commits if the handler returns normally and rolls back if it raises — you
do not open or manage transactions in route code. (A dry-run request rolls back and still
returns its results with
200.) - Maps exceptions to HTTP responses. You raise the engine exceptions and the framework
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 a login page with?next=...— the route’slogin_urlif it declares one (see above), else/login.
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}Returning a file
Section titled “Returning a file”Most downloads should not be a route at all. A button or wizard that generates a file
returns a file_download result, and a PDF from the report engine returns a report
result — both described in Action Results. Write a route only when the
file needs its own URL: something already stored, something large, or a link you hand out.
A file on disk — hand the path to FileResponse and let it stream:
from fastapi.responses import FileResponse
@route("/exports/{export_id}", methods=["GET"], auth="user")async def download_export(self, request: Request, export_id: int): export = await get_model("DataExport").filter(id=export_id).first() if not export: raise MissingError("Export not found") return FileResponse( export.file_path, media_type=export.mimetype, filename=export.name, # let it build Content-Disposition content_disposition_type="attachment", # or "inline" to view in the browser )Pass filename= rather than writing Content-Disposition yourself: it quotes and
RFC 5987-encodes the value for you. A hand-formatted filename={name} leaves a name like
Image Jun 24, 2026.png unquoted, an intermediary splits it on the commas into several
headers, and the browser rejects the whole response.
Bytes you generate in the handler — return a plain buffered Response:
from fastapi.responses import Response
@route("/exports/today.csv", methods=["GET"], auth="user")async def todays_csv(self, request: Request): rows = await get_model("SaleOrder").filter(date=date.today()).all() body = render_csv(rows).encode("utf-8") return Response( content=body, media_type="text/csv", headers={"Content-Disposition": 'attachment; filename="today.csv"'}, )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 the framework imports
every route module (so the @route decorators run and each controller registers against its
module) and registers the composed routes onto the app. 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.
A controller belongs to the module whose file defines it
Section titled “A controller belongs to the module whose file defines it”Which module a controller belongs to is decided by the file it is defined in — the
framework walks up from that file to the nearest manifest.yaml and takes its identifier.
It is not decided by which module happened to import the file first.
This matters because of step 3 above: composition only includes contributors whose module is installed on that database. Attribution is therefore what decides whether your controller exists on a given DB — and if a base controller were attributed to the wrong module, it would disappear from every database that lacks that module, taking every extension of it with it and silently falling back to the framework’s own base class.
The practical consequence for you is that importing another module’s controller file at module level is safe:
# In your module's routes/, at module level — this does NOT re-attribute their controllerfrom fullfinity.modules.core.routes.web import get_base_templateYour controller stays yours and theirs stays theirs, whichever import runs first.
Quick checklist
Section titled “Quick checklist”- Subclass
Controller; give a base controller a unique_name, give an extension an__inherit__(and no_name).__inherit__must name a controller declared without__inherit__— a_namealone does not make one a base, and every web surface (portal included) extendsweb. - Decorate handler methods with
@route(path, methods=[...], auth=...); first parameter isself, thenrequest, then anything FastAPI should inject. - To override an inherited route, repeat its
@routedecorator (same path, same methods). A route is bound by path and verbs, not by the handler’s name, so redefining the name alone never runs. Handler names need not be unique across modules. - 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 framework 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.