FAPI003 router-wiring¶
What it does¶
Opt-in. Reports three ways a FastAPI router's routes go missing without any error:
- Unmounted router: an
APIRouter(...)bound to a name at module level, with at least one path operation, that no app reaches throughinclude_routerormount, directly or through other routers. Reported on theAPIRouter(...)line. - Inclusion cycle: routers that include each other, directly or through others, or a router that includes itself. Each group of routers is reported once, with the whole cycle in the message, on the
include_routercall that closes it. - Included before its routes: in one file,
parent.include_router(child)at module level runs above a@child.get(...)decorator.include_routercopies the routes that exist when it runs, so the routes below it are missing.
Why is this bad¶
An agent adds app/invoices/router.py with three endpoints and tests them against the router, but never adds app.include_router(...) to main.py. The tests pass, and the endpoints don't exist. FastAPI doesn't complain about a router nobody includes, and Ruff checks one file at a time, so nothing else reports it. A cycle, or an inclusion above the routes, gives an app a route set that depends on the order the calls run in.
Example¶
[tool.inwards]
layers = [{ name = "app", modules = ["app"] }]
[tool.inwards.rules]
extend-select = ["FAPI003"]
from fastapi import FastAPI
from app.orders import router as orders
app = FastAPI()
app.include_router(orders.router)
from fastapi import APIRouter
router = APIRouter(prefix="/orders", tags=["orders"])
@router.get("/{order_id}")
async def get_order(order_id: int) -> dict[str, int]:
return {"id": order_id}
from fastapi import APIRouter
router = APIRouter(prefix="/invoices", tags=["invoices"])
@router.get("/{invoice_id}")
async def get_invoice(invoice_id: int) -> dict[str, int]:
return {"id": invoice_id}
app/invoices/router.py:3:10: FAPI003 APIRouter `app.invoices.router.router` has path operations, but no app includes it, directly or through another router, so its routes don't exist at runtime.
fix: Include `router` in the app or router that serves its siblings.
1. In app/main.py, which builds the app `app`, where its sibling routers are included: import `app.invoices.router.router` and add `app.include_router(...)` for it.
2. If it is meant to stay unmounted (a later release, a test-only router), tell the user: they can add it to allow-unmounted in [tool.inwards.rules.router-wiring], or suppress this line with a reason.
3. Don't delete the router or its routes to make this finding go away.
docs: https://sircypkowskyy.github.io/inwards/rules/FAPI003/
Fixed: main.py includes both routers.
from fastapi import FastAPI
from app.invoices import router as invoices
from app.orders import router as orders
app = FastAPI()
app.include_router(orders.router)
app.include_router(invoices.router)
How it works¶
FAPI003 reads the shared FastAPI model (ADR-037) and builds a graph of apps and routers from it:
- Roots: every
FastAPI(...)in the project, including one built inside a factory function such ascreate_app(). Withentrypoints, only the apps it names. - Edges: each
include_routerandmountcall. The receiver and the target are resolved across files through the imports, never by importing the app:router,invoices.routerafterfrom app import invoices,invoices_routerafterfrom app.invoices.router import router as invoices_router, a re-export in an__init__.py, and each name in a list or tuple literal that aforloop callsinclude_routeron. - One hop of first-party code (#391). When the receiver or the target is a parameter of the module-level function the call sits in (
def wire(app, api_router): app.include_router(api_router)), Inwards finds every call of that function in the project (in files that spell its name, through re-exports too) and adds one edge per call, with the names that call passes, by position, keyword or default. A function used other than by a call (hooks = [wire]) or imported under another name (from app.wiring import wire as hook), a parameter the body assigns again, a call with*argsor**kwargs, or an argument that isn't a name leaves that inclusion unresolved.include_router(make_router())includes the routermake_routerbuilds and returns (router = APIRouter(...)...return router); a factory whose returned router can't be read stays unresolved. Amounton a parameter is followed the same way. - Receivers Inwards can't find, such as the
appparameter of aregister(app)function nothing calls: what they include counts as reached, since something includes it.
Files are found by a text pre-filter (fastapi, APIRouter, include_router...) and parsed once. The graph is built only when a checked file holds a router, an app or an include_router call.
Where it runs:
inwards check,inwards baselineand the Stop gate report all three kinds, but only in the files they check: a partial run checks its files against the whole project's graph. The Stop gate checks the files the session changed, so a router created or changed in the session blocks if it is still unmounted at the end, and a router that was already unmounted at session start doesn't.- The PostToolUse hook and the editor report only what one file shows: an inclusion above the routes, and a router that includes itself. Creating a router and wiring it into
main.pyare two edits, and blocking the first would be wrong.
The graph is also what the FastAPI rules that need an inclusion path read (#183, #224, #227): rules/fastapi/model/_graph.ts.
How to fix¶
- Unmounted: include the router in the app, or in the parent router where its sibling routers are included. The fix names that module. Don't delete the router.
- Cycle: remove one of the
include_routercalls the fix lists. Keep the one that goes from the router closer to the app down to its child. - Included before its routes: move the
include_routercall below the last decorator of the included router, or into the module that builds the app.
Fix safety¶
No automatic fix (autofix: false). Where a router belongs, and which inclusion of a cycle is wrong, are design decisions.
Configuration¶
Severity error. Opt-in: turn it on with extend-select. Its options go in [tool.inwards.rules.router-wiring]:
[tool.inwards.rules]
extend-select = ["FAPI003"]
[tool.inwards.rules.router-wiring]
entrypoints = ["app.main:app"]
allow-unmounted = ["app.experimental"]
unresolved-includes = "warn"
check-order = true
entrypoints(default: every app): the apps unmounted routers are measured from, asmodule:name. The name is the app's variable or the top-level function that builds it (app.main:create_app). Other apps, in tests or scripts, then don't count. An entry that names no app counts for nothing.allow-unmounted(default none): routers that may stay unmounted, as module prefixes or selectors (the grammar of a layer'smodules) matched against the router's qualified name, such asapp.experimentalorapp.*.internal.unresolved-includes(default"warn"): what aninclude_routercall whose target Inwards can't resolve does."warn"turns every unmounted router into a warning that names the call;"silent"drops them.check-order(defaulttrue):falsestops reporting an inclusion above the routes.modules, as for every rule (options tables).check-tests:truemakes FAPI003 report in test modules too, which it skips by default (options tables).
Every finding sits on a line of its own and can be suppressed inline with # inwards: ignore[FAPI003] reason="..." on that line. A suppression on an APIRouter(...) line counts as used in the hook too, which doesn't report the router.
Known limitations¶
- Dynamic wiring:
importlib.import_module(...),getattr(m, "router"), a loop over anything but a literal, more than one hop of functions (a parameter passed on to another function, a factory that calls another factory), entry-point plugins. Aninclude_routerwhose target Inwards can't resolve makes unmounted routers warnings (or nothing, with"silent"), never errors on a guess. A library's router (from fastapi_users import ...) isn't an unresolved call; it just isn't in the graph. A router a first-party class or factory builds (#358) isn't reported at all while such a call could include it or a router above it: a factory call whose router can't be read could include no router Inwards knows, any other unresolved target (a parameter whose calls can't all be read,getattr) any router. Polar wires its API router through a parameter ofadd_versioned_routers(app, api_router)and its OAuth routers through factories; before #391 followed those calls, that kept all 72 of its routers unknown. Now every inclusion in Polar is followed, and FAPI003 reports none of them, correctly: an app reaches each one. - Conditional wiring:
if settings.DEBUG: app.include_router(debug.router)counts as included. - Routers meant to be unused (a shared router other packages include, a test-only router, one kept for a later release): use
allow-unmountedor a suppression with a reason. - Apps outside the project aren't seen: a router a library's app includes is unmounted as far as FAPI003 knows. Use
allow-unmountedorignorefor such code. - A cycle through dynamic wiring isn't seen. A cycle doesn't hang FastAPI, since inclusion copies routes, but the route set then depends on call order, so it is an error.
- In the Stop gate, a router the session didn't touch isn't reported, even if the session removed its
include_routercall frommain.py.inwards checkreports it.
References¶
- ADR-037: the FastAPI family, opt-in, and the shared model.
- FastAPI docs, Bigger Applications:
include_routeron apps and routers.