Accueil / Articles / Équilibrer la génération CRUD de Prisma et un contrôle délibéré des routes

Équilibrer la génération CRUD de Prisma et un contrôle délibéré des routes

Découvrez comment la génération de routeurs CRUD Prisma pilotée par des schémas peut éliminer le code générique répétitif tout en conservant les décisions relatives à la confiance, au cadrage et à l’exposition dans le code de l’application.

2767 mots

Les points d’entrée CRUD reprennent principalement des informations déjà présentes dans le schéma Prisma. Le nom d’un modèle devient un segment de route, les champs scalaires se transforment en mécanismes de validation des entrées, et les appels Prisma deviennent des méthodes de contrôleur. Les relations ajoutent une couche supplémentaire d’analyse des requêtes entrantes et de transformation du contenu à renvoyer.

Cette présentation du sujet s’appuie sur un outil open source développé par des mainteneurs, évalué à partir de sa documentation actuelle et de tests que l’on peut reproduire soi-même, et non sur des affirmations concernant son utilisation à grande échelle.

Cette répétition est coûteuse justement parce qu’elle semble inoffensive. Chaque gestionnaire manuel que l’on copie représente un point de plus où les paramètres par défaut de pagination, les champs autorisés, le ciblage par utilisateur et la gestion des erreurs peuvent différer discrètement des autres.

prisma-generator-express se charge de ce travail manuel et l’intègre à l’étape prisma generate. Il peut générer des routeurs pour Express, Fastify ou Hono. Un package complémentaire, prisma-guard, génère en même temps des méta-données de validation et de périmètre conscientes de Prisma, tandis que les schémas d’opérations précisent exactement quels arguments chaque type d’appelant est autorisé à envoyer.

Le résultat n’est pas une application sans code. C’est plutôt une application avec beaucoup moins de composants liés aux couches frontalières, et un contrôle bien plus clair sur les décisions qui restent à prendre.

Cette distinction est essentielle. La génération doit s’occuper de tout ce que le schéma lui-même peut décrire complètement. L’authentification, les opérations qui sont mises à disposition, l’identité des appelants, ainsi que toute politique nécessitant des informations au-delà du schéma doivent encore être gérées dans le code de l’application.

Transférer le travail répétitif dans une seule étape de génération

generator client {
  provider = "prisma-client-js"
}
generator guard {
  provider          = "prisma-guard"
  output            = "../generated/guard"
  enforceProjection = "true"
}generator express {
  provider = "prisma-generator-express"
  target   = "express"
}

En exécutant npx prisma generate une fois, tous ces trois éléments sont régénérés chaque fois que le schéma ou la configuration du générateur change.

Préserver cette séparation entre les préoccupations est bien plus utile que de considérer le code généré comme un substitut à l’architecture. Deux entrées distinctes alimentent délibérément tout le processus : la génération pilotée par un schéma gère les mécanismes répétitifs, tandis que la politique au niveau de l’application s’occupe des décisions relatives à la confiance et de l’exposition des chemins.

Si certaines règles ne peuvent pas être exprimées fidèlement à l’aide du générateur ou d’une structure de protection, ne forcez pas leur intégration dans la configuration. Un gestionnaire dédié ou une politique appliquée au niveau de la base de données constituent une frontière plus claire qu’une configuration déclarative qui cache discrètement ses fonctionnalités réelles.

La génération modifie également ce qui fait l’objet de l’examen du code. Les CRUD manuels incitent les reviewers à vérifier ligne par ligne la logique de parsing et de délégation répétitive. Les CRUD générés orientent ce contrôle vers une surface bien plus restreinte : le schéma Prisma, les options du générateur, les descripteurs de route, les shapes, ainsi que tout élément qui établit un contexte fiable.

Cela ne rend pas le résultat généré moins important — cela signifie simplement que l’éditer directement n’est pas la bonne approche. Si une route doit être corrigée, il suffit de modifier la configuration qui la génère et de la régénérer. Un correctif manuel inséré dans un fichier de route généré peut disparaître lors du prochain changement de schéma, sans laisser de trace de ce qui était réellement prévu.

Les mises à jour de version exigent la même discipline. Fixez Prisma, le package de sécurité et le générateur de routeurs à des versions spécifiques ensemble, régénérez depuis un dépôt propre, et exécutez des tests de contrat sur le résultat. Le code généré fait toujours partie de votre surface de dépendances, même si votre répertoire ne traite pas chaque ligne générée comme du code écrit manuellement.

Le véritable avantage en termes de vitesse provient de la reproductibilité. Une seule modification du schéma peut mettre à jour en même temps les métadonnées de validation, les types clients et les mécanismes des routeurs. Cela permet de concentrer l’examen sur la couche de politiques, qui est relativement restreinte — la partie qui ne peut vraiment pas être déduite uniquement à partir du modèle.

Permettre à un seul modèle de servir plusieurs contrats délibérés

Un modèle Prisma peut prendre en charge plusieurs interfaces destinées aux produits en même temps.

Un enregistrement de chambre d’hôtel, par exemple, peut apparaître sur une page de recherche publique, dans un flux de données destiné aux partenaires et dans une console interne pour le personnel. Ces trois utilisateurs ne devraient pas être contraints de partager un ensemble surchargé contenant tous les champs et opérations dont chacun pourrait avoir besoin.

Les formes nommées permettent à une seule opération générée d’appliquer plusieurs contrats distincts en même temps :

const roomRoutes = {
  findMany: {
    shape: {
      storefront: {
        where: {
          isPublished: { equals: force(true) },
          name: { contains: true },
        },
        select: { id: true, name: true, nightlyRate: true },
        take: { max: 40, default: 20 },
      },
      backoffice: {
        where: {
          name: { contains: true },
          floor: { equals: true },
        },
        select: {
          id: true,
          name: true,
          nightlyRate: true,
          floor: true,
          internalNote: true,
        },
        take: { max: 200, default: 50 },
      },
    },
  },
}

Chaque clé nommée définit un contrat API complet et autonome. Un utilisateur public ne peut pas élargir sa projection pour inclure internalNote, car ce champ n’existe tout simplement pas dans la forme publique. Le personnel peut obtenir une projection bien plus riche sans obliger tous les autres clients à utiliser leur propre routeur manuel.

Utilisez shape lorsque seul le contrat au niveau Prisma doit différer entre les utilisateurs. Utilisez variants lorsque l’utilisateur concerné a également besoin de ses propres hooks dédiés.

La manière dont vous identifiez l’appelant fait elle-même partie des mécanismes de sécurité. Une en-tête de requête est considéré comme une entrée fournie par le client — ce qui convient pour des distinctions intentionnellement publiques, comme une vue compacte par rapport à une version détaillée, mais ce n’est pas un moyen approprié pour sélectionner un contrat réservé au personnel autorisé.

Pour tout ce qui nécessite des droits privilégiés, utilisez plutôt resolveVariant en prenant en compte l’état authentifié du serveur. Les clés d’appelant exactes sont vérifiées en premier avant les clés paramétrées. Une clé default permet de gérer les appelsants manquants, vides ou non correspondants ; définitez-en donc une uniquement si vous êtes sûr que ces trois cas seront tous gérés par cette solution de secours.

Parfois, omettre complètement un contrat vaut mieux que d’ajouter une autre vérification d’autorisation. Si les partenaires ne doivent en aucun cas pouvoir supprimer des salles, il suffit de ne pas fournir de clé de partenariat à l’opération de suppression générée.

Il est utile d’examiner le routage des appels sous forme de grille : les opérations le long d’un axe, les publics le long de l’autre. Chaque case doit soit contenir une forme avec des éléments justifiés, soit être délibérément laissée vide.

Gardez ces contrats nommés séparément, même s’ils se chevauchent fortement dans certains champs. Partager un objet est relativement sûr au sein d’un même niveau de confiance, mais réutiliser un objet partagé entre des publics publics et privilégiés risque d’élargir silencieusement les deux extrémités dès que quelqu’un ajoute un champ. Un peu de duplication à la frontière entre les niveaux de confiance en vaut souvent la peine, car cela facilite grandement la compréhension de savoir qui obtient réellement quels données.

Les clés d’appel paramétrées vous donnent une raison de plus de faire confiance au résolveur intégré plutôt que de réinventer le mécanisme de sélection des appels à l’intérieur d’un hook. Le routeur maintient la valeur brute de l’appel distincte de la clé déclarée à laquelle elle correspond, et il rejette catégoriquement les schémas de paramètres ambigus. Une comparaison de chaînes réalisée manuellement devrait reproduire une correspondance exacte, la priorité des paramètres, le traitement par défaut ainsi que le comportement en cas d’échec pour pouvoir prétendre offrir les mêmes garanties.

Utilisez les hooks pour les décisions liées au cycle de vie, pas pour la construction cachée des requêtes

Les routes générées n’éliminent pas le besoin de jugements au niveau de l’application. Elles donnent simplement à ces décisions un cadre prévisible.

Pour une demande correspondant à une variante spécifique, l’exécution se déroule via les avant-crochets au niveau de l’opération, puis ceux au niveau de la variante, ensuite le gestionnaire généré lui-même, suivis des après-crochets au niveau de la variante, et enfin ceux au niveau de l’opération.

Les avant-crochets d’opération sont l’endroit idéal pour les politiques applicables à tous les appels de cette opération, quel que soit le contrat auquel ils correspondent. Les avant-crochets de variante concernent plutôt la logique spécifique à une forme d’appel déclarée unique.

const transferRoutes = {
  update: {
    before: [authenticateOperator],
    variants: {
      warehouse: {
        before: [authorizeTransferLocation],
        shape: warehouseTransferShape,
      },
      supervisor: {
        before: [requireSupervisorApproval],
        shape: supervisorTransferShape,
      },
    },
  },
}

Un avant-crochet peut examiner l’identifiant exact que le gestionnaire généré s’apprête à utiliser, et il peut rejeter directement la demande si cet identifiant échoue à une vérification. Ce qu’il ne doit jamais faire, c’est autoriser un identifiant tout en substituant discrètement un autre dans la requête réelle. Ce genre de modification silencieuse annule complètement l’intérêt d’avoir un gestionnaire pouvant être inspecté.

Les restrictions qui ne changent jamais doivent être intégrées dans les formes. Le filtrage au niveau du tenant, appliqué en amont d’une requête, doit figurer dans des mappages de portée générés en conjonction avec un contexte fiable. Les différences entre les types d’appelants doivent être gérées via des variantes. Chacun de ces éléments dispose d’une place dédiée, et en les mélangeant, la logique finit par se retrouver cachée là où personne ne la cherche.

Il existe des cas où le serveur a réellement besoin de construire une requête que aucune forme ne peut exprimer. C’est alors qu’un gestionnaire spécialement conçu s’avère utile — en particulier lorsque l’on a besoin d’une véritable disjonction gérée par le serveur. Il convient de se rappeler que les conditions forcées imbriquées à l’intérieur des combinateurs booléens deviennent des contraintes obligatoires pour la requête, et non un mécanisme flexible permettant d’exprimer des règles d’autorisation arbitraires. Les considérer comme un moteur de logique polyvalent est une façon courante d’aboutir à des règles qui ne mettent pas réellement en œuvre ce que l’on pense qu’elles font.

Les after-hooks s’exécutent après le gestionnaire, mais ils ne constituent pas une phase de nettoyage sur laquelle on peut compter inconditionnellement. Une réponse qui se termine prématurément, ou une erreur générée au cours de la demande, peut empêcher les phases ultérieures — y compris les after-hooks — d’être exécutées. Si un ressource doit absolument être libérée quel que soit le scénario, elle a besoin de son propre cycle de vie avec un bloc finally explicite placé en dehors de la chaîne d’hooks générée, et non à l’intérieur.

Les détails dépendent également du framework cible. Express, Fastify et Hono implémentent respectivement les signatures d’hooks et le mécanisme de court-circuitage de manière différente. Le principe général — savoir où se situe une décision donnée — reste identique pour les trois, mais le code d’application doit respecter le contrat du framework cible choisi.

Garder le contexte fiable en dehors des arguments Prisma

L’identité du locataire ainsi que l’état de l’appelant authentifié ne doivent jamais être transmis au serveur en tant que champs contrôlés par le client à l’intérieur du corps de la requête.

Au lieu de cela, appliquez un marqueur @scope-root au modèle du locataire, exécutez la génération pour créer la carte de portée correspondante, et attachez un résolveur de contexte au Prisma Client via son mécanisme d’extension :

const prisma = new PrismaClient().$extends(
  guard.extension(() => ({
    Nursery: requestStore.getStore()?.nurseryId,
  }))
)

La valeur provient elle-même de l’état local de la requête, authentifié — et non de quoi que ce soit envoyé par le client. L’extension l’injecte ensuite dans les opérations de niveau supérieur prises en charge sur les modèles qui sont mappés comme enfants de cette racine de portée.

Ce n’est pas une garantie absolue selon laquelle toute relation sera automatiquement verrouillée, mais plutôt une fonctionnalité réelle et bien définie. L’application des contraintes de portée ne s’étend pas aux lectures ou écritures imbriquées. Le modèle de délégation racine lui-même n’est pas filtré par son propre marqueur de portée. De plus, tout modèle ne disposant pas d’une correspondance générée a toujours besoin d’une protection explicite — le contexte de portée ne suffit pas par défaut.

La vitesse obtenue grâce à cette génération reste intéressante précisément parce que ces limites sont visibles et non cachées. Vous pouvez examiner directement la carte de portée. Les projections imbriquées peuvent comporter leurs propres filtres et limites indépendants. Enfin, toute règle de propriété inhabituelle qui ne correspond pas au schéma standard peut être intégrée dans le code de l’application ou gérée au niveau de la base de données.

La mise en mémoire de l’état d’une application personnalisée — tout ce qui dépasse le périmètre du locataire — doit figurer dans le contexte de la requête, et non dans les arguments Prisma eux-mêmes. Introduire secrètement l’identité de l’appelant ou des métadonnées d’autorisation dans le corps d’une requête Prisma rend le contrat de données résultant beaucoup plus difficile à comprendre, et cela peut également provoquer des erreurs de validation stricte qui attendent une structure d’arguments propre.

Considérer l’exposition des routes comme une question de conception produit

Un générateur est capable de produire des gestionnaires pour un grand nombre d’opérations Prisma. Cette capacité ne dit rien sur quels de ces gestionnaires doivent réellement être activés et accessibles.

Les lectures, les mutations de seul enregistrement, les mutations en masse, les écritures de relations ainsi que les opérations qui retournent des données méritent toutes une analyse distincte, et non une décision unique et générale. Le support fourni par les fournisseurs pour certaines opérations de retour en masse varie, ce qui fait que cela relève non seulement d’une question de politique, mais aussi de compatibilité. Tout chemin qui ne dispose ni de shape ni de variants appellera directement Prisma sans aucune vérification de sécurité.

Une configuration bien réfléchie ne résulte pas du simple activation de tout puis de l’ajout ultérieur de contrôles de refus. Elle commence par une surface restreinte et explicite, ne s’élargissant que lorsque le flux de travail réel d’un produit montre le besoin d’une autre opération.

La lecture de projection nécessite le même niveau d’attention que l’accès en écriture. Lors d’une lecture protégée, un select ou un include déclaré au niveau de la forme agit à la fois comme une liste blanche et comme valeur par défaut lorsque la demande du client omet sa propre projection. La projection de mutation suit des règles par défaut différentes, et si l’omission d’une projection ne doit en aucun cas permettre d’élargir la réponse, il est nécessaire d’activer explicitement enforceProjection.

Les routes de traitement en masse exigent une décision distincte à chaque fois. Une méthode de traitement en masse ne doit être considérée comme valide sur la surface générée que si sa forme définit un vocabulaire de filtrage approprié, et que la requête entrante fournit toujours une condition significative en temps de exécution. Activer deleteMany simplement parce que la suppression d’un seul enregistrement est déjà autorisée ignore ce deuxième risque distinct. Le retour de variantes d’opérations en masse entraîne sa propre dépendance par rapport au fournisseur et au support Prisma ; par conséquent, la configuration de vos routes doit refléter ce que la base de données déployée peut réellement exécuter, et non ce qu’un plan de développement du produit souhaiterait qu’elle exécute.

La sortie OpenAPI générée peut décrire les chemins des routes ainsi que la structure des requêtes dérivées de leurs formes. Elle ne peut pas accéder aux fonctions d’ancrage arbitraires, et donc ne peut pas décrire les politiques cachées à l’intérieur d’elles. Si un ancrage bloque les transferts qui sortent du entrepôt attribué à un opérateur, cette condition doit être documentée à côté de la configuration de la route et vérifiée par des tests ciblant le comportement de l’application — la documentation générée ne doit en aucun cas être considérée comme une preuve de logique qu’elle est incapable d’inspecter.

Les versions GET et POST d’une extrémité de lecture doivent partager un même contrat de requête. GET s’appuie sur des paramètres de requête codés ; POST accepte du JSON natif, ce qui est plus pratique pour des arbres d’arguments plus complexes. Un ancrage qui ne touche que le corps de la requête crée un comportement qui dépend silencieusement du mode de transport, et c’est précisément pour cette raison que des restrictions stables ne devraient pas y être appliquées.

Adopter la génération sans renoncer à l’évaluation

Une méthode pratique pour évaluer ce type de configuration consiste à suivre une courte séquence :

  1. Générer un routeur pour un modèle uniquement en lecture seule.
  2. Exposer uniquement les opérations réellement nécessaires.
  3. Ajouter une forme directe avec une projection explicite et une limite de taille de page.
  4. Vérifier les arguments Prisma que le routeur émet réellement.
  5. Ajouter un contexte de portée fiable si le modèle est associé à une location.
  6. Séparer une opération en contrats distincts pour les appels uniquement lorsque les publics diffèrent réellement.
  7. Ajouter des hooks uniquement pour les décisions que les formes, la portée et les variantes ne peuvent pas prendre seules.
  8. Introduire les écritures uniquement après que la complétude de la création, le filtrage en masse et la propriété des relations disposent tous de tests explicites les couvrant.

Conservez les tests de contrat real-guard même dans des configurations où les tests bout en bout du navigateur s’exécutent sans aucune validation de protection. Les tests de navigateur sont efficaces pour couvrir le routage et le comportement de l’interface, mais ils ne peuvent pas prouver qu’une version en production rejette un champ interdit lorsque la couche de protection n’est pas réellement présente.

La génération trouve son utilité en permettant à l’équipe de se concentrer sur les décisions qui ont vraiment de l’importance. Prisma décrit les données ; les générateurs s’occupent des tâches mécaniques répétitives. Les schémas définissent quels appels sont autorisés. Le code d’application reste chargé de fournir la confiance, les politiques spécifiques au produit ainsi que les exceptions qui ne peuvent pas être déclarées honnêtement par d’autres moyens.

Lectures complémentaires

  • SQL brut, Prisma ou Drizzle : comment choisir véritablement une couche de base de données — Comprenez en quoi SQL brut, Prisma et Drizzle diffèrent en termes de contrôle, de sécurité des types et d’expérience utilisateur, et apprenez une méthode pratique pour sélectionner le bon outil pour un projet.
  • Exemple MovieVault : une API de liste de regardation avec Express 5, Prisma 7 et JWT — Une spécification d’exercice full-stack chronométré ainsi que son backend basé sur Express, Prisma et JWT, accompagnée de notes d’analyse sur les vérifications de propriété, les cascades et la gestion des erreurs.
  • Analyser les shapes prisma-guard : propriété, projection et contrats d’écriture — Apprenez à examiner les shapes prisma-guard en tant que contrats API en vous demandant qui possède chaque valeur, quels données peuvent figurer dans une réponse et quelles écritures un point de terminaison généré peut exécuter.
  • Détecter le drift des contrats API en temp de compilation grâce à un rollout incrémental de tRPC — Comment tRPC transforme un champ backend renommé en erreur de compilation, comment l’intégrer point par point à côté de REST, et dans quels cas il s’agit du mauvais outil.