2 septembre 2026
•
Ouvrir une API publique sur notre backend est un chantier que nous avons abordé en sachant qu'il serait long. Il l'a été, et sur deux fronts à la fois : celui que nous avions anticipé - refondre l'architecture d'un backend de plusieurs années pour que deux surfaces partagent le même code - et celui que nous avions largement sous-estimé, qui consiste à rendre nos modèles compréhensibles par quelqu'un qui n'a pas accès à notre code.
Voici comment ça s'est passé.
Depuis le premier jour, le backend de Streamfizz est un serveur GraphQL. Ce n'était pas une décision prise en vue d'une API publique : c'était simplement notre façon de faire parler notre back-office avec nos données.
Mais quand la question d'ouvrir une API à nos clients s'est posée, ce choix historique s'est révélé être un cadeau. Parce qu'une bonne partie du travail était déjà faite, et pas par nous.
GraphQL est self-describing. Le schéma n'est pas de la documentation à côté du code : c'est le code. Un client interroge l'endpoint, récupère l'introspection, et sait exactement quels types existent, quels champs ils portent, quels arguments chaque opération accepte, lesquels sont obligatoires, ce que chaque opération renvoie. Pas de fichier OpenAPI à maintenir en parallèle et à laisser dériver. Pas de versionnement d'URL. Pas de « v2 » parce qu'on a ajouté un champ.
Concrètement, notre page de documentation publique n'est pas écrite à la main. Elle fait un buildClientSchema sur une introspection de l'endpoint API, et se reconstruit à chaque déploiement. Une opération ajoutée dans le SDL, une description modifiée, un argument devenu optionnel : tout arrive dans la doc automatiquement, sans qu'un humain n'y touche.
Nous serons honnêtes sur les limites de ce bel automatisme : il y a exactement une chose que l'introspection ne sait pas dire, et c'est ce qui peut mal se passer. Les codes d'erreur métier - « ce média n'est pas prêt », « cette URL est injoignable », « ce quota est dépassé » - ne vivent nulle part dans le schéma GraphQL. Nous les tenons à la main, opération par opération. C'est notre unique dette documentaire, et elle est d'autant plus piégeuse que le reste de la page est parfait : rien ne signale qu'il manque quelque chose.
Ajoutez à ça la pagination, le typage fort, la sélection de champs par le client - qui évite le classique « endpoint qui renvoie 40 champs quand on en veut 2 » - et le calcul était vite fait. Nous n'avions pas à choisir une technologie d'API. Nous en avions déjà une. Il fallait juste décider ce qu'on en montrait.
C'est le premier arbitrage, et c'est celui qui a structuré tout le reste.
La tentation naturelle, quand on ouvre une API, c'est de construire une façade : un endpoint public bien propre, bien nommé, qui appelle par en dessous le vrai code, celui du back-office. Ça paraît raisonnable. C'est rapide. Et c'est le meilleur moyen de se retrouver, dix-huit mois plus tard, avec deux systèmes qui divergent silencieusement - un bug corrigé d'un côté et pas de l'autre, un comportement subtilement différent selon l'endpoint utilisé.
Nous avons posé l'invariant inverse, et nous l'avons écrit noir sur blanc :
L'API remplacera le privé à terme. Tout ce qui peut être partagé DOIT l'être, défini une seule fois et étalé dans les deux surfaces. Une divergence privé/api est une dette, pas une liberté.
En pratique, ça donne une architecture en couches par modèle. La logique métier vit dans un core - une fonction qui ne sait rien de GraphQL, qui prend des arguments et un contexte, et qui fait le travail. Au-dessus, les resolvers ne sont que des adaptateurs, aussi minces que possible. Et quand un resolver est identique entre les deux surfaces, il est écrit une fois :
Le schéma privé et le schéma API étalent tous les deux cet objet. Créer une playlist par le back-office ou par une clé API, c'est littéralement la même ligne de code qui s'exécute. Il n'y a rien à synchroniser parce qu'il n'y a rien en double.
Le typage suit la même logique - et c'est un morceau du chantier à part entière, parce qu'il n'existait pas avant.
Jusque-là, nos resolvers étaient typés à la main, quand ils l'étaient. Un parent: any par-ci, un args: any par-là. Ça tenait parce que nous étions les seuls à écrire les requêtes : si un champ changeait de forme, nous étions les deux bouts de la chaîne, on le savait.
Un schéma publié ne permet plus ça. Nous avons donc mis en place GraphQL Codegen dans le même mouvement que la refonte du premier modèle - le commit qui introduit codegen.ts est celui qui refond Media, le tout premier modèle passé à la nouvelle architecture. Ce n'était pas un chantier d'outillage à côté : c'était une condition pour que le reste tienne.
La configuration génère un fichier de types par surface - privé, API, public - à partir des trois SDL, avec les modèles Prisma déclarés comme mappers. Le parent d'un field resolver n'est plus un any : c'est la ligne Prisma réelle. Un resolver se déclare alors sans une seule annotation manuelle :
satisfies fait le reste : arguments, contexte, type de retour, tout est inféré du SDL. Si le schéma bouge et que le resolver ne suit pas, le compilateur le dit - au lieu que ce soit un client qui le découvre en production.
L'entrée d'un core suit la même règle, et elle est stricte : ce n'est jamais un type maison inventé pour l'occasion, c'est le type d'arguments généré depuis le SDL. Pas de Record<string, any>, pas d'id: string nu. C'est volontairement rigide, mais ça garantit une chose précieuse : le SDL est la seule source de vérité, et le code ne peut pas s'en éloigner sans que ça se voie.
Nous avons repassé les 54 modèles du backend sur ce motif. Le nombre de any explicites dans le repo est tombé de 1 128 à zéro.
Et comme souvent quand on resserre un typage sur du code ancien, ça a trouvé des choses. Le cas le plus parlant : un resolver qui exposait la date de mise à jour d'une notification lisait node.updatedAt, alors que la colonne s'appelle updatedAT - une faute de frappe dans le schéma, des années plus tôt. Le champ renvoyait undefined en silence depuis toujours. Personne ne l'avait vu, parce que rien ne cassait : sur un champ nullable, undefined est une réponse parfaitement valide.
C'est exactement le genre de bug qu'une API publique transforme en ticket client. Autant les trouver avant.
Aujourd'hui, nous avons encore deux endpoints. Mais pour tous les modèles déjà migrés, ils partagent le même code. L'objectif à terme est de n'en garder qu'un.
C'est le versant du chantier que nous avions le plus sous-estimé.
Pendant des années, notre API GraphQL n'a eu qu'un seul consommateur : notre propre back-office. Écrit par les mêmes personnes, dans le même bureau. Ça change tout, et pas dans le bon sens.
Quand vous êtes le seul client de votre API, vous pouvez vous permettre des noms de champs qui ne veulent rien dire pour personne d'autre. Des modèles qui portent le nom d'une notion interne oubliée depuis. Des arguments dont la signification tient dans la tête de celui qui les a écrits. Rien de tout ça ne pose problème : quand un doute surgit, on ouvre le code.
Un client API n'ouvre pas le code. Il a le schéma, et rien d'autre.
Donc il a fallu tout repasser. Modèle par modèle, champ par champ. Est-ce que ce nom est compréhensible pour quelqu'un d'extérieur ? Est-ce que ce champ sert encore ? Est-ce que ces deux arguments ne devraient pas n'en faire qu'un ? Est-ce que cette notion interne a une raison d'être visible depuis l'extérieur ?
Ce travail a produit une règle qui gouverne aujourd'hui toutes nos migrations : on ne code rien avant d'avoir fait valider une projection de surface.
Avant la moindre ligne, on écrit - en texte, pas en code - ce que le schéma va devenir : le SDL avant et après pour les deux surfaces, ce qui devient partagé, ce qui reste réservé au back-office et pourquoi, les renommages, les champs supprimés, les codes d'erreur envisagés, et surtout ce qu'on choisit délibérément de ne pas exposer.
Cette projection se discute. Elle se conteste. Elle repart en correction. C'est exactement le but : une projection acceptée du premier coup signifie généralement qu'elle n'était pas assez précise pour être contestée.
Le calcul est simple. Une erreur de nommage repérée sur une projection coûte trente secondes. La même erreur repérée après publication coûte une release cassante à tous les clients qui ont écrit du code contre notre schéma. Le découpage des fichiers, la forme des cores, l'organisation interne : tout ça se refactore tranquillement. Le nom d'un champ, non.
Une découverte au passage, qui vaut pour tout le monde : on ne sait pas ce qui est utilisé tant qu'on n'a pas cherché. Plusieurs fois, un champ qu'on croyait structurant s'est révélé n'être sélectionné strictement nulle part dans le front. Sa suppression était gratuite. Et à l'inverse, un champ qu'on pensait décoratif portait un comportement. La seule méthode fiable est de chercher les sélections réelles dans les documents GraphQL du front - pas de raisonner de mémoire.
Nous en avons profité pour poser des conventions que nous n'avions jamais eu besoin de formaliser : tous les points d'entrée en lecture sont préfixés get, sans exception. Les valeurs d'énumération reprennent verbatim celles de la base, sans mapping inventé au passage. Et deux entités distinctes ne partagent jamais un nom d'argument - c'est la meilleure façon de produire un « not found » parfaitement incompréhensible pour le client.
Enfin, un détail qui n'en est pas un : les descriptions du schéma sont publiées telles quelles. Puisque la doc se génère par introspection, chaque """commentaire""" posé au-dessus d'un champ finit sur une page publique. Ce ne sont plus des notes internes. C'est de la documentation produit, et elle s'écrit comme telle.
Un consommateur unique et interne, c'est aussi un consommateur qui envoie des données propres. Nous savions ce que le back-office émettait, parce que nous l'écrivions.
Avec une API, ce confort disparaît d'un coup. Il faut se préparer à tout.
Nous n'avions jamais validé nos entrées en détail. Ce n'était pas de la négligence, c'était un arbitrage rationnel : le seul émetteur, c'était nous.
La question du « où » s'est posée immédiatement. La réponse la plus simple - un safeParse en tête de chaque resolver - a été écartée : elle mélange deux responsabilités, elle se duplique entre les deux surfaces, et surtout on finit toujours par en oublier un.
Nous avons donc branché la validation au-dessus des resolvers, comme une transformation appliquée au schéma une fois assemblé - c'est exactement ce que permet mapSchema de graphql-tools :
Chaque modèle déclare ses schémas zod dans un fichier dédié, et les enregistre dans un registre par surface. attachValidation parcourt le schéma exécutable et enveloppe uniquement les champs qui ont une entrée. Un champ sans schéma passe inchangé.
Les bénéfices sont immédiats : le resolver ne contient plus une ligne de validation, il reçoit des données déjà parsées et correctement typées, les schémas identiques entre privé et API sont définis une fois et étalés dans les deux registres, et une erreur de validation remonte toujours sous la même forme - un code INPUT_VALIDATION_ERROR avec le détail champ par champ dans les extensions.
Tant que le back-office était seul client, un message d'erreur était un message pour nous. Il pouvait être vague, il pouvait changer, personne ne construisait de logique dessus.
Un code d'erreur exposé à une API, c'est une promesse. Un client va écrire if (code === "QUOTA_EXCEEDED") dans son intégration. Le renommer, c'est casser son code.
Nous avons donc une classe d'erreur par domaine métier, avec des codes en majuscules, stables, exposés dans les extensions de la réponse GraphQL. Et un principe qui vaut d'être dit : le message ne doit jamais révéler qu'une ressource existe ailleurs. « Cette ressource n'est pas attachée à ce direct » convient. « Elle appartient au live X » est une fuite d'information.
Un piège nous a coûté du temps, et il est assez générique pour être partagé. Notre code privé disposait d'un wrapper qui enveloppait chaque mutation et rattrapait les erreurs connues pour les traduire proprement. Ce filet n'existait pas côté API. Conséquence : toute erreur que nous n'avions pas levée délibérément arrivait brute au client. Un accès en base qui ne trouve rien remontait une erreur technique de l'ORM, que le client recevait sous la forme d'un INTERNAL_SERVER_ERROR - il croyait à une panne de notre côté alors que sa requête était simplement mauvaise.
La correction n'était pas de rajouter un filet, mais de rendre chaque core explicite : une ressource absente ou hors périmètre lève une erreur typée, avec son code. C'est plus verbeux. C'est infiniment plus honnête vis-à-vis du client.
Il y a un endroit où privé et API doivent diverger, et il revient sans arrêt : les entrées fichier - vignettes, sous-titres, sources vidéo.
Notre back-office uploade d'abord le fichier vers S3 par son propre flux, puis passe une clé S3 à la mutation. Un client API n'a pas de clé S3 et n'en aura jamais. Il dispose d'une URL publique, ou il passe par notre endpoint REST d'upload dédié - protégé par clé API et limité en débit - et réutilise l'URL temporaire obtenue.
La divergence est donc réelle. Mais nous la refermons immédiatement : l'API n'a pas un core différent, elle reconstitue l'argument attendu par le core partagé.
Six lignes. Un seul core, un seul comportement métier ; seule l'acquisition du fichier diffère. C'est le motif exact que nous rejouons sur chaque modèle concerné.
En contrepartie, cette entrée par URL introduit des codes d'erreur que le privé n'a pas - une URL peut être injoignable, ou renvoyer un 403 - et qui doivent figurer au catalogue public.
Nous avions des tests. Mais ils étaient organisés comme l'était le reste : autour d'un consommateur unique, dont nous connaissions les requêtes. Couvrir le chemin nominal suffisait, parce que le chemin nominal était le seul que quelqu'un empruntait.
Une API ouverte fait exploser l'espace des appels possibles. La refonte a donc été l'occasion de reprendre entièrement la question, et d'en faire une règle du skill : un modèle n'est pas exposé tant qu'il n'a pas ses trois fichiers de test.
Un dossier par modèle, trois fichiers de test, leurs helpers à côté. Ce qui sert à plusieurs modèles - au premier rang duquel le compte étranger dont chaque security.test.ts a besoin - remonte dans shared/ : monter un second compte est le genre de fixture qu'on n'a pas envie d'écrire deux fois, et encore moins d'écrire deux fois différemment.
**flow.test.ts** - la logique métier. Tous les chemins de l'opération, pas seulement le nominal : chaque code d'erreur métier atteignable, chaque garde de périmètre, les états intermédiaires. Ce sont de vrais tests de flux, exécutés contre un serveur lancé, avec de vraies mutations GraphQL - pas des tests unitaires sur des fonctions isolées. Ce qui compte, c'est ce qu'un client reçoit, pas ce qu'une fonction retourne. La suite tourne sous bun test depuis notre passage à Bun.
**validation.test.ts** - les entrées. Une assertion par règle du schéma zod. Un titre vide, un titre à 201 caractères, un identifiant qui n'est pas un cuid, une position négative, une limite de pagination au-delà du plafond, une URL qui n'est pas en http. C'est fastidieux à écrire et c'est exactement ce qui fait la différence entre une API qui renvoie un message clair et une API qui renvoie une erreur 500. Ce fichier vérifie aussi la surface publiée : par introspection, que les mutations restées réservées au back-office sont bien absentes du schéma API.
**security.test.ts** - le cloisonnement. C'est le fichier que nous n'avions pas avant, et c'est le plus important. Il ne teste qu'une chose, sous tous les angles : qu'une clé API ne peut rien atteindre en dehors de son compte.
Le motif qui revient partout est celui de l'objet confus - ce que l'OWASP place en tête de son top 10 des risques d'API sous le nom de Broken Object Level Authorization : on prend un identifiant qui appartient à un autre compte et on le fait passer par une ressource du sien. Sur les chapitres, ça donne un test qui se lit tout seul - « ne se modifie pas en passant par un média du compte ».
Ce n'est pas un cas théorique. Nos directives d'autorisation résolvent leur cible à partir des arguments de la mutation. Si on retire l'argument parent d'une mutation de sous-ressource sous prétexte qu'il paraît redondant avec l'identifiant de la ressource, la directive prend l'identifiant de la sous-ressource pour celui du parent, évalue une cible qui n'existe pas, et la protection devient décorative - sans lever la moindre erreur. D'où deux règles inséparables : on garde toujours l'argument parent, et chaque core filtre sur le couple { id, parentId } plutôt que sur l'identifiant seul.
Cette rigueur a un coût réel - sur une playlist, les trois fichiers font 83 tests - et une contrepartie qui l'emporte largement : une brèche de cloisonnement en production sur une API multi-tenant, c'est un incident dont on ne se remet pas en publiant un correctif.
Deux détails d'organisation, hérités de nos conventions et repris dans le skill : un fichier *.test.ts ne contient que des describe / it / expect, toute la mécanique vivant dans un *.helpers.ts frère - un test doit se lire comme une phrase. Et les fixtures sont suffixées par un jeton de run, parce que le compte de test est partagé : sans ça, la suite passe une fois et échoue au second lancement.
Nous utilisons Ultracite, un préréglage de linting sans configuration bâti sur Biome, sur l'ensemble de nos projets. Tous sont à zéro erreur.
Tous sauf un : le backend. De loin le plus gros, le plus ancien, le plus dense.
Nous aurions pu en faire un chantier séparé - « la semaine du lint », celle qu'on repousse indéfiniment. Nous l'avons adossé à la migration. La règle est devenue : un modèle migré vers l'API est un modèle à zéro erreur Ultracite. Pas de compteur, pas de sprint dédié, pas de gros commit de 400 fichiers impossible à relire.
C'est le même ressort que pour le codegen : un cliquet qu'on ne redescend pas. Un modèle qui passe ne repasse plus jamais en dessous.
C'est un des effets de bord les plus satisfaisants du chantier. Ouvrir l'API nous obligeait de toute façon à relire chaque modèle en entier, ligne par ligne, pour juger de ses champs et de ses noms. Le passage au niveau zéro erreur ne coûtait quasiment rien de plus, une fois qu'on était déjà dedans.
Il y a une leçon générale là-dedans, et elle vaut au-delà de notre cas : le meilleur moment pour nettoyer du code, c'est quand une raison extérieure vous force à le relire. Le nettoyage pour lui-même ne se fait jamais.
Les premiers modèles sont passés à la main. C'était le seul moyen de découvrir les pièges - et il y en a eu.
Mais au bout de quelques modèles, un constat s'impose : le travail est répétitif sans être mécanique. Chaque migration suit exactement les mêmes étapes - projection de surface, extraction du core, SDL, validation, branchement dans les deux registres, tests de flux, documentation publique - et chaque migration comporte une part de jugement irréductible sur le nommage et le périmètre.
C'est exactement le profil de tâche où un agent est utile, à condition de lui donner le contexte que nous avions dans la tête.
Nous avons donc écrit ce contexte. Un skill Claude, dédié à cette migration, qui contient tout : l'invariant fondateur, l'architecture cible fichier par fichier, les contrats de typage non négociables, la façon dont la validation se branche, les règles de nommage du SDL, le motif des entrées fichier, la procédure de test, les étapes de documentation publique.
Le workflow qui en découle est simple. L'agent lit le modèle, et sa première livraison n'est pas du code : c'est une projection de schéma GraphQL. C'est là que se joue le va-et-vient. On discute des noms, on conteste un champ exposé, on fusionne deux arguments, on retire une notion interne. Plusieurs allers-retours, parfois une dizaine.
Une fois le SDL validé, le reste est largement mécanique. La logique métier existe déjà - nous ne réécrivons rien, nous déplaçons et nous partageons. Ce qui reste, c'est la plomberie du workflow, et elle est entièrement décrite dans le skill.
La partie la plus intéressante, c'est que le skill s'améliore à chaque passage. Chaque piège rencontré y retourne sous forme de règle. Il s'est constitué au fil des migrations une table « erreurs fréquentes » qui fait aujourd'hui une quarantaine de lignes, et dont chacune a été payée par un vrai bug :
| Erreur | Réalité |
| --- | --- |
| Se lancer dans le code sans projection validée | La surface est publique : le nommage ne se rattrape pas |
| Valider dans le resolver | La validation est une couche, branchée au-dessus du schéma |
| Compter sur le wrapper d'erreurs côté API | Il n'y est pas - les erreurs arrivent brutes au client |
| Type visible côté API sans resolver enregistré | Les champs remontent null, en silence, sans erreur |
| Exiger une clé S3 côté API | Un client API n'en a pas - URL ou endpoint REST |
| Croire le compilateur et le codegen verts | Ils ne voient pas les resolvers orphelins : seul le démarrage assemble le schéma |
| Oublier le catalogue d'erreurs de la doc | Seule partie qui ne s'auto-génère pas - et rien ne signale le manque |
Ce tableau est probablement l'artefact le plus précieux produit par ce chantier. Ce n'est pas de la documentation d'architecture : c'est de la mémoire d'équipe rendue exécutable. Ces pièges, nous les connaissions - pour les avoir vécus. Ils vivaient dans nos têtes et se transmettaient en relecture de merge request, avec la déperdition qu'on imagine. Ils sont maintenant écrits, appliqués systématiquement, et ils survivent à la mémoire de celui qui les a rencontrés.
Le résultat très concret : les derniers modèles migrés le sont nettement plus vite que les premiers, avec moins d'allers-retours, et sans que nous ayons rien assoupli sur les exigences.
Pour nos clients, c'est un nouveau produit et un vrai nouveau cas d'usage. Automatiser des uploads depuis leur propre système, piloter leurs directs depuis leur outillage interne, brancher notre plateforme dans un workflow existant sans passer par une interface. Des choses qui n'étaient tout simplement pas possibles avant.
Pour nous, c'est autre chose, et honnêtement pas moins précieux : un backend en bien meilleur état qu'avant. Une logique métier isolée dans des cores testables plutôt que noyée dans des resolvers. Une validation systématique. Des erreurs typées. Un nommage qu'on peut expliquer à quelqu'un d'extérieur - ce qui est, en pratique, le meilleur test de qualité d'un modèle de données qu'on connaisse.
La migration se poursuit, modèle par modèle. Ce n'est pas un chantier qui se termine par un grand déploiement : chaque modèle validé rejoint la surface publique, et le suivant commence.
La documentation de l'API est disponible sur streamfizz.fr/api, et la présentation de l'API détaille ce qu'elle permet de piloter.
GGeoffrey Signorato
Lead développeur
Publié le 2 septembre 2026
Mis à jour le 2 septembre 2026
Demandez une démonstration personnalisée dès aujourd'hui.

Bien choisir son encodeur pour la diffusion en live streaming : Un match fou entre Logiciels et Matériels ( Spoiler : ça finit en match nul)

L'Éclairage Studio analyse les couleurs vidéo en temps réel pour créer un halo d'ambiance cinématographique subtil mais immersif autour de l'écran.