Skip to content

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.

A base controller declares a _name; route methods take self first (the controller instance), then request and any FastAPI-injectable parameters.

from fastapi import Request
from fastapi.responses import JSONResponse
from fullfinity.engine.controller import Controller, route
from 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.

def route(path, methods=None, auth="internal", response_class=None, login_url=None, **kwargs)
ArgumentDefaultMeaning
pathURL 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_classNoneA FastAPI response class, e.g. HTMLResponse, JSONResponse.
login_urlNoneSign-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.
**kwargsExtra 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 /login
async 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.

auth selects which authentication dependency the route is registered with. The four values map onto the framework’s built-in auth dependencies:

auth valueDependencyWho can call it
"internal" (default)requires_internalLogged-in internal user; 401 if no token, 403 if not internal.
"user"requires_userAny logged-in user (internal or portal); 403 for guest-only.
"public"allows_publicAnyone — 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 Authenticationauth="..." 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: core
class 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 home

How composition works:

  • Multiple modules can each extend the same controller. blog, website, and portal can 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 @route for the same path and methods on the same controller, the one whose module is loaded later (the downstream dependant) wins, and it can call super() 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 ConfigurationError for 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_payment
class 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 profile

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

Before your controller method runs, the framework applies the following behavior around it:

  1. Resolves the database from the X-DB-NAME header or the db cookie. 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 a 400 with code REQUEST_NOT_TIED_TO_DB.
  2. 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 503 with Retry-After. Once the operation finishes, the request sees the new code.
  3. 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 404 where it is not — and it starts resolving as soon as the module is installed, with no restart.
  4. 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.)
  5. Maps exceptions to HTTP responses. You raise the engine exceptions and the framework produces the right status and payload:
    • UserError400, ValidationError400, AccessError403, MissingError404 (toast-style, no traceback).
    • InternalError / ConfigurationError / ORMError / unhandled exceptions → 500 with a traceback (HTML error page for browser requests, JSON for API requests).
    • Database constraint violations (unique / FK / not-null / check) are translated into friendly 400 messages.
    • An HTTPException with 401 on an HTML page redirects to a login page with ?next=... — the route’s login_url if 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}

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"'},
)

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 controller
from fullfinity.modules.core.routes.web import get_base_template

Your controller stays yours and theirs stays theirs, whichever import runs first.

  • 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 _name alone does not make one a base, and every web surface (portal included) extends web.
  • Decorate handler methods with @route(path, methods=[...], auth=...); first parameter is self, then request, then anything FastAPI should inject.
  • To override an inherited route, repeat its @route decorator (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 auth deliberately: "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.