API design
Eden Treaty + TypeBox + Elysia, conventions de routing, auth, validation, erreurs
Une seule source de vérité : l'app Elysia dans
packages/api. Le client Eden Treaty infère les types directement depuisexport type App. Aucun artefact généré, aucun schéma dupliqué.
Philosophie
- Pas de REST hybride bricolé : chaque endpoint est déclaratif avec TypeBox
- Pas de tRPC : Eden Treaty est le client natif Elysia (cf. ADR-07)
- Pas de validation runtime dispersée : tout passe par TypeBox côté routes
- Pas de types manuels dupliqués côté client : l'inférence fait le job
Composition de l'app
packages/api/src/index.ts
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { openapi } from "@elysiajs/openapi";
import { errorHandler } from "./plugins/error-handler";
import { betterAuthPlugin } from "./plugins/auth";
import { cardexModule } from "./modules/cardex/cardex.routes";
import { roomsModule } from "./modules/rooms/rooms.routes";
import { bookingModule } from "./modules/booking/booking.routes";
import { chatModule } from "./modules/chat/chat.routes";
import { aiModule } from "./modules/ai/ai.routes";
import { paymentModule } from "./modules/payment/payment.routes";
import { integrationsModule } from "./modules/integrations/integrations.routes";
import { ticketsModule } from "./modules/tickets/tickets.routes";
import { todayModule } from "./modules/today/today.routes";
import { menuModule } from "./modules/menu/menu.routes";
import { usersModule } from "./modules/users/users.routes";
import { analyticsModule } from "./modules/analytics/analytics.routes";
export const app = new Elysia()
.use(cors({
origin: (process.env.CORS_ORIGIN ?? "").split(","),
credentials: true,
}))
.use(openapi({
path: "/openapi",
documentation: {
info: {
title: "Bell API",
version: "1.0.0",
description: "Plateforme de conciergerie hôtelière — éditée par HOAIY",
},
tags: [
{ name: "cardex", description: "Guest management" },
{ name: "rooms", description: "Inventory" },
{ name: "booking", description: "Services bookings" },
{ name: "chat", description: "Conversations guest ↔ AI ↔ staff" },
{ name: "ai", description: "AI concierge (Vercel AI SDK via @elysiajs/ai-sdk)" },
{ name: "payment", description: "Stripe Payment Intents" },
{ name: "integrations", description: "PMS adapters (Mews, Opera, ...)" },
],
},
}))
.use(errorHandler)
.use(betterAuthPlugin)
.use(cardexModule)
.use(roomsModule)
.use(bookingModule)
.use(chatModule)
.use(aiModule)
.use(paymentModule)
.use(integrationsModule)
.use(ticketsModule)
.use(todayModule)
.use(menuModule)
.use(usersModule)
.use(analyticsModule);
export type App = typeof app;L'app est un seul objet Elysia composé. Le type App exporté est l'unique source pour le client Eden Treaty.
Plugins réutilisables
plugins/auth.ts
import { Elysia } from "elysia";
import { auth as betterAuth } from "@bell/auth";
// Plugin qui résout la session depuis le cookie + derive { user, auth }
export const betterAuthPlugin = new Elysia({ name: "auth" })
.derive({ as: "global" }, async ({ request }) => {
const session = await betterAuth.api.getSession({ headers: request.headers });
return {
user: session?.user ?? null,
auth: session
? {
userId: session.user.id,
organizationId: (session.session as any).activeOrganizationId ?? null,
role: null as string | null, // résolu à la demande via member
}
: null,
};
});
// Macros : requireAuth, requireStaff, requireAdmin
export const requireAuth = (app: Elysia) =>
app.onBeforeHandle(({ auth, set }) => {
if (!auth) {
set.status = 401;
return { error: "Authentication required" };
}
});
export const requireStaff = (app: Elysia) =>
app.use(requireAuth).onBeforeHandle(async ({ auth, set }) => {
// ... lookup member role, 403 si pas staff+
});Chaque module utilise le macro approprié via .use(requireStaff) ou .use(requireAuth).
plugins/openapi.ts
Utilise @elysiajs/openapi. La doc Swagger est exposée sur /openapi en dev, protégée par role admin en prod (flag OPENAPI_ENABLED).
plugins/error-handler.ts
Centralise le mapping exception → HTTP status :
export const errorHandler = new Elysia({ name: "error-handler" })
.error({
NotFoundError,
InvalidTransitionError,
ValidationError,
ForbiddenError,
})
.onError(({ code, error, set }) => {
if (error instanceof NotFoundError) {
set.status = 404;
return { error: error.message };
}
if (error instanceof InvalidTransitionError) {
set.status = 400;
return { error: error.message, code: "INVALID_TRANSITION" };
}
if (error instanceof ForbiddenError) {
set.status = 403;
return { error: error.message };
}
if (code === "VALIDATION") {
set.status = 422;
return { error: "Validation failed", details: error.message };
}
// Fallback
logger.error(error);
set.status = 500;
return { error: "Internal server error" };
});plugins/pubsub.ts
Wrapper Redis pour publier/souscrire aux events (SSE).
export const pubsub = {
publish(channel: string, payload: unknown) {
return redis.publish(channel, JSON.stringify(payload));
},
subscribe(channel: string): AsyncIterable<unknown> {
// Retourne un async iterable que les routes SSE consomment
},
};Convention des routes
Préfixes par module
// packages/api/src/modules/cardex/cardex.routes.ts
export const cardexModule = new Elysia({ prefix: "/cardex" })
.use(requireStaff)
// tous les endpoints préfixés /cardex/*
;Verbes et paths
| Action | Pattern |
|---|---|
| Liste | GET /resource |
| Détail | GET /resource/:id |
| Création | POST /resource |
| Update complet | PUT /resource/:id |
| Update partiel | PATCH /resource/:id |
| Suppression | DELETE /resource/:id |
| Action métier | POST /resource/action-name (kebab-case) |
Exemples d'actions métier :
POST /cardex/confirm-arrivalPOST /cardex/send-check-in-emailPOST /booking/room-service/update-statusPOST /integrations/full-sync
Règle : un verbe qui ne mappe pas proprement à un CRUD est une action (POST /resource/action-name). On ne bricole pas en faisant un PUT /resource/:id avec 12 champs qui modifient des trucs différents.
TypeBox schemas
Chaque route déclare son body, ses params, son query, et sa response :
import { Elysia, t } from "elysia";
import { confirmArrivalBody, confirmArrivalResponse } from "./cardex.schemas";
export const cardexModule = new Elysia({ prefix: "/cardex" })
.use(requireStaff)
.get("/guests", ({ auth }) =>
service.getAllGuests({ organizationId: auth.organizationId }),
{
query: t.Object({
search: t.Optional(t.String()),
vipOnly: t.Optional(t.Boolean()),
staying: t.Optional(t.Boolean()),
}),
response: t.Array(guestSummarySchema),
detail: { tags: ["cardex"], summary: "List guests of org (with filters)" },
},
)
.post("/confirm-arrival", ({ body, auth }) =>
service.confirmGuestArrival({
organizationId: auth.organizationId,
guestId: body.guestId,
}),
{
body: confirmArrivalBody,
response: confirmArrivalResponse,
detail: { tags: ["cardex"], summary: "Mark guest arrived, trigger PMS check-in" },
},
);Macros d'auth par route
Les 4 niveaux d'auth :
| Macro | Vérifie | HTTP |
|---|---|---|
.use(requireAuth) | Session valide | 401 sinon |
.use(requireAuth).use(requireRole(["guest"])) | Guest logged in | 403 si autre role |
.use(requireStaff) | Role ∈ staff/manager/admin/owner | 403 sinon |
.use(requireAdmin) | Role ∈ admin/owner | 403 sinon |
Utilisé comme :
const readOnly = new Elysia().use(requireAuth);
const staffOnly = new Elysia().use(requireStaff);
const adminOnly = new Elysia().use(requireAdmin);Client Eden Treaty
Setup
import { treaty } from "@elysiajs/eden";
import type { App } from "@bell/api";
export const eden = treaty<App>(
typeof window === "undefined"
? process.env.SERVER_URL!
: "/api/eden",
{ fetch: { credentials: "include" } },
);En dev, /api/eden est proxifié vers http://localhost:3000 via Next.js rewrites (voir ADR-02). En prod, on pointe directement sur bell-api.hoaiy.com.
Appels typiques
// GET
const { data, error } = await eden.cardex.guests.get({
query: { vipOnly: true },
});
// POST avec body
const { data, error } = await eden.cardex["confirm-arrival"].post({
guestId: "...",
});
// GET dynamique
const { data, error } = await eden.guests({ id: "abc" }).get();Intégration TanStack Query
Hook helper dans apps/<app>/src/lib/use-eden-query.ts :
import { useQuery, useMutation } from "@tanstack/react-query";
import { eden } from "./eden";
export function useGuests(opts: { vipOnly?: boolean } = {}) {
return useQuery({
queryKey: ["cardex", "guests", opts],
queryFn: async () => {
const { data, error } = await eden.cardex.guests.get({ query: opts });
if (error) throw new Error(String(error.value));
return data;
},
});
}
export function useConfirmArrival() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (guestId: string) => {
const { data, error } = await eden.cardex["confirm-arrival"].post({ guestId });
if (error) throw new Error(String(error.value));
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["cardex", "guests"] }),
});
}Gestion des erreurs
Eden retourne { data, error } sans throw. On normalise au niveau du hook helper :
error.status === 401→ refresh session ou redirect loginerror.status === 403→ toast "action non autorisée"error.status === 422→ affiche les erreurs de validation (par champ viaerror.value.details)error.status >= 500→ toast "erreur serveur, réessayez"
SSE (Server-Sent Events)
Les endpoints streaming utilisent async function* d'Elysia :
.get("/check-in/stream", async function* ({ auth }) {
yield sse({ event: "ready", data: { connected: true } });
for await (const update of pubsub.subscribe(`guest:${auth.userId}:status`)) {
yield sse({ event: "status", data: update });
}
}, {
detail: { tags: ["check-in"], summary: "SSE stream for check-in status updates" },
});Côté client Eden :
// EventSource natif, le type inféré est correct pour les events
const es = new EventSource("/api/eden/check-in/stream");
es.addEventListener("status", (e) => {
const update = JSON.parse(e.data);
if (update.status === "arrived") window.location.replace("/");
});Voir ADR-03 pour le raisonnement.
OpenAPI / Swagger
- Dev : ouvert sur
/openapi - Prod : protégé par role admin + flag
OPENAPI_ENABLED=true
Utile pour :
- Debug incidents (voir exactement quel payload une route attend)
- Documentation pour partenaires (quand on ouvrira une API key system)
- Génération d'un SDK tiers (auto-gen via openapi-generator)
Versioning
Pas de versioning d'API au MVP. Eden Treaty garde les types sync entre client et serveur — si on change la signature d'une route, le client explose au build.
Si on ouvre l'API à des partenaires externes plus tard, on introduira un /v1/* prefix et un @elysiajs/versioning plugin.
Lien avec les autres pages
- Modules — liste exhaustive des modules API
- ADR-07 Eden Treaty
- Conventions modulaires
- State machines — utilisées dans les services