Contrats d'API
@fluixi/api fait d'une route d'API un contrat : ce qu'elle accepte, ce
qu'elle renvoie, qui peut l'appeler. Le contrat valide la requête avant que votre code ne
s'exécute, met en forme la réponse à la sortie, décrit la route dans un
document OpenAPI, et type un client qui l'appelle.
// src/api/v1/projects/index.ts
import { route } from '@fluixi/api';
import { z } from 'zod';
export const POST = route({
summary: 'Create a project',
body: z.object({ name: z.string().min(1).max(80) }),
response: Project,
status: 201,
handler: ({ body }) => createProject(body.name),
});
handler reçoit l'entrée validée, déjà typée par les schémas : body.name est ici une chaîne,
pas unknown.
Entrée
| Champ | Valide | Arrive au handler sous |
|---|---|---|
params |
les paramètres de chemin, d'après le nom du fichier ([id].ts donne { id }) |
params |
query |
la chaîne de requête, les clés répétées en tableaux | query |
body |
le corps JSON | body |
N'importe quel Standard Schema convient : Zod, Valibot, ArkType. Tous les problèmes sont signalés d'un coup, pas seulement le premier, en 400 :
{
"error": {
"code": "validation_failed",
"message": "The request is not valid.",
"details": [
{ "path": "body.name", "message": "String must contain at least 1 character(s)" },
{ "path": "query.limit", "message": "Expected number, received string" }
]
}
}
Sans schéma body, le corps n'est pas lu, et le handler peut le lire lui-même. Avec un schéma,
le corps doit être du JSON (415 unsupported_media_type), doit se lire (400 invalid_json), et
doit rester sous bodyLimit, 1 Mio par défaut (413 payload_too_large), vérifié sur la
longueur annoncée et sur les octets réellement lus.
Sortie
response s'applique à la sortie : les champs inconnus sont retirés et les transformations
s'exécutent. Un champ que vous gardez côté serveur, comme createdBy ou une empreinte de mot de
passe, n'atteint jamais l'appelant tant que le schéma de réponse ne le nomme pas.
export const GET = route({
params: z.object({ id: z.string().uuid() }),
response: Project,
handler: ({ params }) => {
const project = findProject(params.id);
if (!project) throw notFound('No project with that id.');
return project;
},
});
status fixe le statut en cas de succès : 200 par défaut, 204 quand le handler ne renvoie rien
et que la route n'a pas de response. Un handler peut aussi renvoyer sa propre Response, qui
part telle quelle.
Si le handler renvoie quelque chose que son propre schéma response refuse, c'est une faute du
serveur : l'appelant reçoit un 500, jamais les mauvaises données. Désactivez la vérification avec
validateResponse: false seulement pour une raison que vous avez mesurée.
Erreurs
Chaque échec a une seule forme, { error: { code, message, details? } }, et le statut qui va
avec. Le code est pour les programmes, le message pour les personnes.
import { ApiError, badRequest, conflict, forbidden, notFound, unauthorized } from '@fluixi/api';
throw notFound('No project with that id.'); // 404 not_found
throw conflict('That name is taken.'); // 409 conflict
throw new ApiError(402, 'plan_limit', 'Upgrade to add more.'); // le vôtre
Tout autre chose levée par un handler devient un 500 internal avec un message générique : rien
des rouages du serveur n'atteint l'appelant. L'erreur elle-même part dans console.error, ou
dans votre journal ou votre outil de suivi avec setErrorReporter((error, request) => ...).
Gardes
Une garde décide qui peut appeler une route. Elle s'exécute avant la lecture de l'entrée : un
appelant qui n'a pas le droit n'apprend rien de ce que la route accepte, et ce qu'elle renvoie
arrive au handler sous auth, typé.
import { member, signedIn } from '@fluixi/auth/server';
export const GET = route({
guard: signedIn(),
handler: ({ auth }) => auth.user,
});
export const DELETE = route({
guard: member({ roles: ['owner', 'admin'] }),
params: z.object({ id: z.string() }),
handler: ({ auth, params }) => projects.remove(auth.member.organizationId, params.id),
});
signedIn() refuse en 401 sans session. member() exige aussi une organisation active dont
l'appelant est membre, et vérifie roles (l'un d'eux) et permissions (toutes). Ses refus
disent pourquoi : 403 no_active_organization, 403 not_a_member, 403 forbidden.
auth.member.organizationId est le locataire : passez-le à chaque requête que fait la route.
Une garde est n'importe quel objet avec check(request), qui renvoie ce que reçoit le handler ou
lève un refus :
const apiKey = {
async check(request: Request) {
const key = await keys.find(request.headers.get('x-api-key'));
if (!key) throw unauthorized('Send a valid x-api-key.');
return { key };
},
security: { name: 'apiKey', scheme: { type: 'apiKey', in: 'header', name: 'x-api-key' } },
refuses: [401],
};
security et refuses servent au document OpenAPI : le schéma de sécurité que l'appelant
satisfait, sous un nom stable, et les refus que la route peut renvoyer. Un refus levé avec un
code le garde dans le corps de l'erreur, pour qu'un client distingue « choisissez une
organisation » de « non autorisé ».
Dire au navigateur ce qui a changé
revalidates nomme les données en cache qu'un appel réussi rend obsolètes. La réponse le porte,
et le client rafraîchit les pages qui les affichent. Voir
Mise en cache des données.
export const POST = route({ guard: signedIn(), body: invite, revalidates: ['members'], handler });
Un client typé
@fluixi/start écrit src/api.gen.ts dans l'application API : chaque modèle de chemin associé à
son module de route. Un client typé avec lui connaît, par méthode, les paramètres, la requête et
le corps que prend chaque route et ce qu'elle renvoie. Rien du serveur n'est embarqué : la table
n'est que des types.
import { createClient } from '@fluixi/api/client';
import type { ApiRoutes } from '@acme/api/routes'; // le paquet de l'API exporte src/api.gen.ts
const api = createClient<ApiRoutes>();
const project = await api.get('/api/v1/projects/{id}', { params: { id } });
await api.post('/api/v1/projects', { body: { name: 'Atlas' } }); // vérifié contre le schéma du corps
Un échec lève ApiClientError, avec le status, le code et les details du corps d'erreur.
Options : baseUrl (vide par défaut, l'origine de la page), headers (une fonction s'exécute à
chaque appel, par exemple pour transmettre le cookie du visiteur pendant un rendu serveur),
credentials, fetch et onRevalidate.
Protéger l'API
apiSecurity réunit les protections habituelles dans un seul middleware. Placez-le en premier,
pour que les refus de tout ce qui suit portent aussi les en-têtes.
// src/middleware.ts
import { defineMiddleware } from '@fluixi/start';
import { apiSecurity, postgresRateLimitStore } from '@fluixi/api';
export default defineMiddleware([
apiSecurity({
origins: ['https://app.example.com'],
rateLimits: [
{
name: 'auth',
match: ['/api/auth/sign-in/*', '/api/auth/sign-up/*'],
methods: ['POST'],
limit: 10,
window: 60,
store: postgresRateLimitStore(sql),
},
],
}),
]);
Il ajoute les en-têtes de sécurité, répond au CORS pour les origines listées, refuse une écriture
portant des cookies depuis une page d'un autre site (403 cross_origin_refused), et applique
chaque limite dans l'ordre (429 rate_limited). Sans option, tout appel d'une autre origine est
refusé et rien n'est limité : listez les origines de votre application web, et limitez les routes
qui devinent des mots de passe. Les compteurs vivent en mémoire par défaut ; un stockage partagé
(postgresRateLimitStore) garde toutes les instances sur le même compte.
Les morceaux sont aussi exportés séparément : securityHeaders, cors, originCheck,
rateLimit.