Skip to content

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:

ts
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;
  • getPaginated pour les pages page / limit;
  • getCursorPage pour les flux à curseur;
  • query, swrFetcher et rtkBaseQuery pour 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.

SituationRejouée ?
GET, HEADoui, defaultRetry fois
POST, PUT, PATCH, DELETEnon
n'importe quelle méthode avec retry: n sur l'appeloui, n fois
méthodes listées dans retryMethodsoui, 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:

ts
const api = createApiClient({ baseURL: "/api" });

Un projet qui utilise Axios peut brancher un transport compatible sans que @sia-ui/api dépende d'Axios:

ts
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:

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

ts
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;
  • message extrait depuis message, errors[].message ou 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 nextCursor et prevCursor.

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:

ts
const api = createApiClient({
  baseURL: "/api",
  responseHandler: {
    extractData: (raw) => raw,
  },
});

Services CRUD ​

BaseService et createResourceService exposent les opérations standard:

ts
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:

ts
api.addPlugin(
  createUnauthorizedPlugin({
    onUnauthorized: () => authStore.logout(),
  }),
);

Librairies de data fetching ​

TanStack Query peut utiliser directement les promesses du client:

ts
useQuery({
  queryKey: ["users"],
  queryFn: api.query(() => users.list()),
});

SWR peut utiliser le fetcher intégré:

ts
useSWR<User[]>("/users", api.swrFetcher<User[]>());

RTK Query peut utiliser rtkBaseQuery sans import direct depuis Redux Toolkit:

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

tsx
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 :

ts
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 : socketIoSource et eventSourceSource prennent la forme d'un socket ou d'un EventSource, pas le paquet. Un autre transport fournit subscribe, status et onStatusChange.
  • Le cache est tout objet qui a invalidateQueries et setQueryData — le QueryClient de TanStack Query tel quel. Les clés de createQueryResource s'y emploient directement.
  • Une coupure se rattrape : au retour de la connexion, les clés de refetchOnReconnect sont 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, ofetch et 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 ​

ts
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 ​

ts
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 :

ts
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 serveurRemplacé ?
absent, ou la phrase HTTP du statut (Conflict)oui
le champ error de NestJS, Forbidden resourceoui
Cannot GET /x, ThrottlerException: …oui
un message métier — « Ce client existe déjà »non
une erreur qui porte des erreurs de champnon

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 :

ts
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 ​

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

GreffonRôle
createLoggerPluginméthode, URL, statut et durée; la sortie est injectable
createIdempotencyPluginIdempotency-Key sur POST, PUT et PATCH
createReadOnlyPlugincoupe toute écriture, avant l'envoi
createHeaderSignalPluginré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 :

ts
.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 ​

ts
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) — un POST plutôt que N DELETE : 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.

Publié sous licence MIT.