Module API
Objectif
@sia-ui/api fournit une base HTTP typée et indépendante du framework. Il reprend les besoins courants observés dans les applications métier: client HTTP, erreurs normalisées, pagination, services CRUD et points d'extension pour l'authentification.
Existant
Client
createApiClient construit un client avec fetch par défaut:
import { createApiClient } from "@sia-ui/api";
const api = createApiClient({
baseURL: "https://api.example.com",
getToken: () => authStore.token,
getLanguage: () => "fr-FR",
});
const user = await api.get<User>("/users/123");Le client expose:
get,post,put,patch,delete;queryParams,headers,body,signal,timeoutMs,retry;getPaginatedpour les pagespage/limit;getCursorPagepour les flux à curseur;query,swrFetcheretrtkBaseQuerypour les librairies de data fetching.
Nouvelles tentatives
Une erreur réseau ou un statut transitoire (408, 429, 502, 503, 504) est rejoué defaultRetry fois (2 par défaut), avec un délai exponentiel — pour les seules lectures : GET et HEAD. Une erreur réseau ne dit pas si le serveur a reçu la requête ; rejouer un POST peut créer deux fois la même facture.
| Situation | Rejouée ? |
|---|---|
GET, HEAD | oui, defaultRetry fois |
POST, PUT, PATCH, DELETE | non |
n'importe quelle méthode avec retry: n sur l'appel | oui, n fois |
méthodes listées dans retryMethods | oui, defaultRetry fois |
Rejouer une écriture n'est sûr qu'avec une clé d'idempotence : monter createIdempotencyPlugin et poser retry sur l'appel. Une application qui confie les relectures à TanStack Query pose defaultRetry: 0.
Transports
Le transport par défaut repose sur fetch:
const api = createApiClient({ baseURL: "/api" });Un projet qui utilise Axios peut brancher un transport compatible sans que @sia-ui/api dépende d'Axios:
import axios from "axios";
import { createApiClient, createAxiosTransport } from "@sia-ui/api";
const api = createApiClient({
baseURL: "/api",
transport: createAxiosTransport(axios.create()),
});Le contrat interne reste volontairement petit:
interface ApiTransport {
request<T>(request: ApiTransportRequest): Promise<ApiTransportResponse<T>>;
}Cela permet d'ajouter un transport ky, ofetch, GraphQL ou propriétaire sans changer les services applicatifs.
Corps multipart
Les deux transports livrés retirent l'en-tête Content-Type lorsque le corps est un FormData. C'est le moteur d'exécution qui doit poser multipart/form-data avec sa propre limite de séparation; un Content-Type hérité de defaultHeaders la remplacerait et la requête arriverait au serveur sans ses parties.
const body = new FormData();
body.append("file", file);
await api.post("/documents", { body });Un Blob, un File ou un URLSearchParams conservent en revanche l'en-tête fourni: une URL présignée impose souvent un Content-Type précis.
Erreurs
HttpError normalise les erreurs HTTP:
status;body;headers;messageextrait depuismessage,errors[].messageou le statut.
Les réponses en échec conservent le corps d'origine pour que l'UI puisse afficher des messages métier.
Réponses
ResponseHandler gère par défaut:
- réponse directe;
- enveloppe
{ data }; - pagination
{ data, meta.pagination }; - pagination
{ items, page, limit, total }; - pagination à plat
{ data, total, page, limit }, la forme NestJS/TypeORM courante; - curseurs
nextCursoretprevCursor.
get déballe l'enveloppe { data }, sauf quand l'objet porte aussi une clé de pagination (total, page, limit, totalPages) : rendre data seul donnerait une liste qui paraît complète alors qu'elle n'est qu'une page. Une page se lit avec getList ou getPaginated, qui rendent { items, meta }.
Les extracteurs peuvent être remplacés pour un backend spécifique:
const api = createApiClient({
baseURL: "/api",
responseHandler: {
extractData: (raw) => raw,
},
});Services CRUD
BaseService et createResourceService exposent les opérations standard:
import { createResourceService } from "@sia-ui/api";
const users = createResourceService<User>(api, "/users");
await users.list({ search: "marie" });
await users.getById("123");
await users.create({ name: "Marie" });
await users.update("123", { name: "Marie N." });
await users.remove("123");Plugins
Le module fournit des plugins neutres:
createAuthPlugin;createRefreshTokenPlugin;createUnauthorizedPlugin.
Ils ne dépendent pas d'un store, d'un routeur ou d'un chemin applicatif. Les projets branchent leurs propres callbacks:
api.addPlugin(
createUnauthorizedPlugin({
onUnauthorized: () => authStore.logout(),
}),
);Librairies de data fetching
TanStack Query peut utiliser directement les promesses du client:
useQuery({
queryKey: ["users"],
queryFn: api.query(() => users.list()),
});SWR peut utiliser le fetcher intégré:
useSWR<User[]>("/users", api.swrFetcher<User[]>());RTK Query peut utiliser rtkBaseQuery sans import direct depuis Redux Toolkit:
const baseQuery = api.rtkBaseQuery();TanStack Query
api.query se contentait de rendre le chargeur : chaque écran inventait ses clés de cache, et la moitié oubliait d'invalider la liste après une création. createQueryResource(service) rend des options — pas des hooks — que useQuery et useMutation acceptent telles quelles. Aucune dépendance à TanStack : le QueryClient satisfait l'interface QueryInvalidator.
const clients = createQueryResource(clientService);
useQuery(clients.pageQuery({ page, limit: 20 }));
useQuery(clients.detailQuery(id));
const enregistrer = useMutation(clients.saveMutation(queryClient));
enregistrer.mutate({ id, values }); // sans id : création
useMutation(clients.removeMutation(queryClient));Une API qui n'accepte que des mises à jour partielles le déclare une fois : createQueryResource(service, { updateMethod: "PATCH" }) — saveMutation passe alors par service.patch au lieu de service.update (PUT).
Les clés s'emboîtent : invalider lists() couvre toutes les listes, quels qu'en soient les filtres. Chaque queryFn transmet le signal d'annulation au client. La racine est le chemin du service (service.path), ou name.
Temps réel
Un flux d'événements — Socket.IO, SSE — qui tient le cache à jour : chaque projet réécrivait la connexion, le patch du détail, l'invalidation des listes et le rattrapage après une coupure. createRealtimeBinding les relie :
import { createRealtimeBinding, socketIoSource } from "@sia-ui/api";
const source = socketIoSource(socket); // authentification : celle du socket
const liaison = createRealtimeBinding({
source,
cache: queryClient,
refetchOnReconnect: [operations.keys.lists()],
on: {
"operation.updated": (op: Operation, { patch, invalidate }) => {
patch<Operation>(operations.keys.detail(op.id), (ancien) => ancien && { ...ancien, ...op });
invalidate(operations.keys.lists(), ["inventaire"]);
if (op.statut === "terminee") toast.success(`${op.nom} est terminée.`);
},
},
});
useEffect(() => liaison.start(), [liaison]); // start() rend le débranchement- Aucune dépendance :
socketIoSourceeteventSourceSourceprennent la forme d'un socket ou d'unEventSource, pas le paquet. Un autre transport fournitsubscribe,statusetonStatusChange. - Le cache est tout objet qui a
invalidateQueriesetsetQueryData— leQueryClientde TanStack Query tel quel. Les clés decreateQueryResources'y emploient directement. - Une coupure se rattrape : au retour de la connexion, les clés de
refetchOnReconnectsont invalidées — ce qui a changé pendant l'absence revient. - Un gestionnaire qui lève est journalisé sans couper les autres.
L'état de la connexion s'affiche avec LiveIndicator, alimenté par useRealtimeStatus(source) de @sia-ui/headless.
Prévu
- Ajouter des exemples dédiés pour
ky,ofetchet GraphQL. - Fournir un template de registre copiable si les applications veulent installer le module API dans leur propre arborescence au lieu d'importer
@sia-ui/api. - Des mutations optimistes (mise à jour du cache avant la réponse) dans
createQueryResource.
Version 2
Ce qui manquait au client d'origine, et que les applications finissaient par réécrire, a été ajouté — en généralisant chaque fois qu'un cas particulier cachait un motif réutilisable.
Stockage des jetons
import { createBrowserTokenStorage, createMemoryTokenStorage } from "@sia-ui/api";
const storage = createBrowserTokenStorage(localStorage);
const api = createApiClient({ baseURL: "/api", getToken: storage.getAccessToken });Une interface, deux implémentations : mémoire pour les tests, navigateur pour le reste. Le module n'importe aucune API de plateforme — on lui passe le stockage, ce qui laisse la porte ouverte à un trousseau natif.
Chaque accès est protégé. En navigation privée, ou avec les données de site bloquées, une simple lecture lève : une session absente est un cas normal, une page qui tombe pour cette raison ne l'est pas.
Erreurs par champ
try {
await service.create(payload);
} catch (error) {
if (error instanceof HttpError) {
setFieldError("email", error.fieldError("email"));
}
}HttpError.fields lit trois formes de corps d'erreur, parce que trois serveurs sur quatre en utilisent une : la liste (errors: [{ field, message }]), la carte (errors: { email: "…" }) et la carte de listes, que produit Laravel.
C'est ce qui permet d'afficher « adresse invalide » sous l'adresse plutôt qu'un bandeau générique en haut de page.
Des messages lisibles
Un 401, 403, 404, 409 ou 429 arrive souvent avec un message générique, en anglais : « Conflict », « Forbidden resource », « Cannot GET /x ». Form l'affichait tel quel. statusMessages le remplace :
import { createApiClient, FRENCH_STATUS_MESSAGES } from "@sia-ui/api";
const api = createApiClient({
baseURL: "/api",
statusMessages: { ...FRENCH_STATUS_MESSAGES, 409: "Ce nom est déjà pris." },
});| Message du serveur | Remplacé ? |
|---|---|
absent, ou la phrase HTTP du statut (Conflict) | oui |
le champ error de NestJS, Forbidden resource | oui |
Cannot GET /x, ThrottlerException: … | oui |
| un message métier — « Ce client existe déjà » | non |
| une erreur qui porte des erreurs de champ | non |
Deux tables sont livrées, aux mêmes statuts : FRENCH_STATUS_MESSAGES et ENGLISH_STATUS_MESSAGES. statusMessages étant une simple table, une application multilingue passe celle de la langue en cours, ou la remplit depuis ses propres fichiers de traduction.
0 vaut pour l'échec réseau : la TypeError de fetch (« Failed to fetch ») garde son type, seul son message change — et seulement pour ces messages-là, pour qu'un vrai bug ne se déguise pas en panne réseau. La traduction a lieu au dernier moment : les greffons onError et le rejeu voient l'erreur d'origine, et body garde le corps du serveur.
Les messages de validation (« name must be shorter than… ») viennent des DTO du serveur : c'est là qu'ils se traduisent, avec l'option message de class-validator.
Un serveur NestJS
ValidationPipe ne rend aucune de ces trois formes : il renvoie { message: string[] }, chaque phrase commençant par le chemin du champ (« email must be an email »). La forme se règle une fois, sur le client :
import { createApiClient, nestFieldErrors } from "@sia-ui/api";
const api = createApiClient({ baseURL: "/api", fieldErrors: nestFieldErrors });nestFieldErrors essaie d'abord les trois formes génériques — un exceptionFactory personnalisé les produit souvent — puis lit le premier mot de chaque message comme nom de champ (address.city compris). Le message reste la phrase entière. fieldErrors accepte n'importe quelle fonction (body) => FieldError[] pour un autre serveur. Sans réglage, un message: string[] ne produit aucun champ : deviner un nom de champ dans « Something went wrong » serait pire que de ne rien poser.
Vers un formulaire
error.toFormErrors() rend { champ: message } — la forme de FormAdapter.setErrors, et ce qu'un onSubmit peut rendre. Form la lit lui-même quand l'envoi lève : voir Les erreurs du serveur.
Greffons
const api = createApiClient({ baseURL: "/api" })
.use(createAuthPlugin({ getToken: storage.getAccessToken }))
.use(createLoggerPlugin())
.use(createIdempotencyPlugin());use() renvoie le client : l'assemblage se relit d'un coup d'œil, et l'ordre compte — le premier greffon qui renvoie une valeur court-circuite les suivants. Chaque greffon porte un name, ce qui rend les traces lisibles quand six sont montés.
| Greffon | Rôle |
|---|---|
createLoggerPlugin | méthode, URL, statut et durée; la sortie est injectable |
createIdempotencyPlugin | Idempotency-Key sur POST, PUT et PATCH |
createReadOnlyPlugin | coupe toute écriture, avant l'envoi |
createHeaderSignalPlugin | réagit à un en-tête de réponse |
Le dernier mérite une note. Il naît d'un cas précis : le serveur renvoie x-permissions-refreshed: true, le client recharge l'utilisateur. Le motif — « le serveur signale, le client réagit » — vaut pour bien d'autres cas, donc le greffon ne fixe ni l'en-tête ni la réaction :
.use(createHeaderSignalPlugin({
header: "x-permissions-refreshed",
onSignal: () => store.reloadCurrentUser(),
}))Les appels concurrents sont dédoublonnés : dix requêtes portant le même signal ne déclenchent qu'une réaction.
createIdempotencyPlugin mérite aussi un mot. Sans clé, un POST rejoué après une coupure réseau crée deux ressources. Avec elle, le serveur reconnaît la seconde tentative — à condition qu'il la gère, ce que le greffon ne peut pas vérifier.
Filtres composés
await service.search({
$or: [{ statut: "payé" }, { montant: { gte: 1_000_000 } }],
});Une recherche à quatre critères sérialisée en paramètres produit une URL illisible et dépasse vite la limite de longueur des serveurs. Le filtre voyage donc dans un en-tête X-Filters, au prix de ne pas apparaître dans un lien partageable — acceptable pour un filtre de tableau.
Un objet plat est accepté et normalisé en { $and: [ … ] } : le serveur n'a qu'une forme à lire, l'appelant écrit la plus simple.
Méthodes ajoutées
head(path)— tester l'existence, lire des en-têtes, sans corps;getList(path)— normalise un tableau nu et une réponse paginée en{ items, meta }, pour que l'appelant n'ait pas à deviner;BaseService.removeMany(ids)— unPOSTplutôt que NDELETE: le serveur décide en une transaction, et l'interface n'a pas à gérer une suppression à moitié réussie.
Couverture
32 assertions sur le module, contre 9. Les cas couverts sont ceux qui cassent en vrai : un stockage qui lève, une clé d'idempotence déjà fournie, des signaux concurrents, un tableau nu là où une pagination était attendue.