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, **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.
**kwargsExtra FastAPI route parameters, passed through.

auth selects which authentication dependency the route is registered with. The four values map directly onto the dependencies in engine/middleware.py:

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:

  • 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, and portal all extend web, 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.

Every request passes through RequestMiddleware (in engine/middleware.py) before reaching your controller method. The middleware:

  1. Resolves the database from the X-DB-NAME header or the db cookie. 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 a 400 with code REQUEST_NOT_TIED_TO_DB.
  2. 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 503 with Retry-After. After waiting it clears stale model/controller caches so the request sees the new code.
  3. 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 return 404.
  4. 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. (A DryRunComplete rolls back and still returns the results with 200.)
  5. Maps exceptions to HTTP responses. You raise the engine exceptions and the middleware 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 /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}

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.

  • 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 is self, then request, then anything FastAPI should inject.
  • 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 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.