Les points d'entrée HTTP du module
Presque tout se fait sans endpoint : le moteur générique sert vos modèles sur /api/v1/{modèle}
et vos écrans viennent des vues JSON. Mais il reste des cas où il faut un point d’entrée à vous :
un formulaire public, un rappel d’un prestataire de paiement, une action qui ne ressemble à aucun
CRUD.
Un module dépose alors un fichier :
modules/library/├── public_routes.py <- joignable sans compte└── admin_routes.py <- derrière l'authentificationDeux surfaces, jamais mélangées
| Préfixe imposé | Authentification | |
|---|---|---|
RouterSurface.ADMIN | /api/v1/... | jeton + groupes |
RouterSurface.PUBLIC | /public/... | aucune, le locataire est quand même résolu |
Le préfixe n’est pas une convention de nommage, c’est une contrainte vérifiée au démarrage :
InvalidRouterPrefixError : module router 'library_public' (public) must have aprefix under '/public', got '/api/v1/library'L’instance refuse de démarrer. C’est voulu : monter une route publique sous le préfixe de l’administration, c’est ouvrir une porte dans un mur qui sépare deux publics (interdictions n°27 et n°32). Une faute pareille ne doit pas attendre un test d’intrusion, elle doit tomber au boot.
Un routeur public, en entier
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): # le boot réimporte : idempotent register_module_router( ModuleRouter( name=_ROUTER_NAME, surface=RouterSurface.PUBLIC, factory=make_library_public_router, ) )Une ligne dans le register() du module, et c’est tout :
def register(registry): models.register_models(registry) ... register_library_public_router()Exécuté sur une instance réelle, sans aucun jeton :
$ 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_title401Le même serveur, les mêmes données, deux portes : l’une ouverte, l’autre fermée.
Trois choses qui ne sont pas facultatives
1. Une lecture publique passe quand même par la session filtrée. Le
with_user_context(user_id=0, groups=(SYSTEM_GROUP,)) n’est pas un contournement : c’est une
opération contrôlée et unique, faite au nom du locataire, sur une session qui applique les
règles d’enregistrement. Ce n’est jamais un accès générique à l’ORM depuis l’extérieur.
2. Vous exposez ce que vous avez choisi d’exposer. Le routeur ci-dessus renvoie un titre et
un auteur, pas la fiche. Le module support du produit ne publie que les articles à l’état
published : les brouillons ne sortent pas. Un modèle n’est pas public parce qu’une route le
lit ; ce sont les champs que vous nommez qui le sont.
3. Vos messages d’erreur passent par l’i18n. detail=t("library.error.no_tenant"), pas une
phrase en dur : la règle vaut aussi côté serveur, pas seulement dans l’interface.
Et une route d’administration ?
Même fichier, même forme, RouterSurface.ADMIN et un préfixe sous /api/v1. Elle arrive
derrière l’authentification, donc current_user_id() et les groupes sont là, et vous n’avez
aucun contrôle de jeton à écrire. Les permissions métier, elles, restent dans l’ORM : un endpoint
n’est pas l’endroit où l’on vérifie qui a le droit (interdiction n°1).
Neuf modules du produit livrent une surface publique, six une surface d’administration. C’est le même mécanisme dans les deux cas, et il tient en un fichier.