A module's HTTP endpoints
Almost everything works without an endpoint: the generic engine serves your models on
/api/v1/{model} and your screens come from JSON views. But some cases need an entry point of
your own: a public form, a callback from a payment provider, an action that looks like no CRUD.
A module then drops a file:
modules/library/├── public_routes.py <- reachable without an account└── admin_routes.py <- behind authenticationTwo surfaces, never mixed
| Required prefix | Authentication | |
|---|---|---|
RouterSurface.ADMIN | /api/v1/... | token + groups |
RouterSurface.PUBLIC | /public/... | none, the tenant is still resolved |
The prefix is not a naming convention, it is a constraint checked at startup:
InvalidRouterPrefixError : module router 'library_public' (public) must have aprefix under '/public', got '/api/v1/library'The instance refuses to boot. That is deliberate: mounting a public route under the admin prefix opens a door in a wall that separates two audiences (rules 27 and 32). A mistake like that must not wait for a penetration test; it must fall at boot.
A public router, in full
from fastapi import APIRouter, HTTPException, Requestfrom sqlalchemy import select
from kewos.api.module_routers import ( ModuleRouter, RouterSurface, has_module_router, register_module_router,)from kewos.auth.context import current_tenant_id, with_user_contextfrom kewos.orm.session import make_session_factoryfrom kewos.security import SYSTEM_GROUP
_ROUTER_NAME = "library_public"
def make_library_public_router() -> APIRouter: router = APIRouter(prefix="/public/library", tags=["library-public"])
@router.get("/catalogue") def catalogue(request: Request) -> dict: engine = request.state.tenant_engine build = request.app.state.tenant_build_cache.get(current_tenant_id(), engine=engine) title = build.classes["library_title"] factory = make_session_factory(engine, build.classes, frozen=build.frozen) with factory() as session, with_user_context(user_id=0, groups=(SYSTEM_GROUP,)): rows = session.execute(select(title).order_by(title.name)).scalars() return {"titles": [{"name": t.name, "author": t.author} for t in rows]}
return router
def register_library_public_router() -> None: if not has_module_router(_ROUTER_NAME): # boot re-imports: idempotent register_module_router( ModuleRouter( name=_ROUTER_NAME, surface=RouterSurface.PUBLIC, factory=make_library_public_router, ) )One line in the module’s register(), and that is all:
def register(registry): models.register_models(registry) ... register_library_public_router()Run on a real instance, with no token at all:
$ curl http://localhost:8000/public/library/catalogue{"titles":[{"name":"A Brief History of Time","author":"Stephen Hawking"}, {"name":"Dune","author":"Frank Herbert"}, ...]}
$ curl -o /dev/null -w '%{http_code}' http://localhost:8000/api/v1/library_title401Same server, same data, two doors: one open, one shut.
Three things that are not optional
1. A public read still goes through the filtered session. The
with_user_context(user_id=0, groups=(SYSTEM_GROUP,)) is not a bypass: it is a controlled,
single operation on the tenant’s behalf, on a session that applies record rules. It is never
generic ORM access from the outside.
2. You expose what you chose to expose. The router above returns a title and an author, not
the record. The product’s support module publishes only articles in the published state:
drafts stay in. A model is not public because a route reads it; the fields you name are.
3. Your error messages go through i18n. detail=t("library.error.no_tenant"), not a hardcoded
sentence: the rule holds on the server too, not only in the interface.
And an admin route?
Same file shape, RouterSurface.ADMIN and a prefix under /api/v1. It arrives behind
authentication, so current_user_id() and the groups are there, and you write no token check at
all. Business permissions still live in the ORM: an endpoint is not where you check who is
allowed (rule 1).
Nine of the product’s modules ship a public surface, six an admin one. It is the same mechanism in both cases, and it fits in one file.