Skip to content

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 authentication

Two surfaces, never mixed

Required prefixAuthentication
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 a
prefix 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, Request
from 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_context
from kewos.orm.session import make_session_factory
from 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_title
401

Same 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.