Aller au contenu

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'authentification

Deux 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 a
prefix 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, 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): # 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_title
401

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