tRPC
tRPC est un framework pour construire des APIs en TypeScript avec une sécurité de typage de bout en bout. En utilisant tRPC, les mises à jour des entrées et sorties des opérations de l’API sont immédiatement reflétées dans le code client et visibles dans votre IDE sans avoir à reconstruire votre projet.
Le générateur d’API tRPC crée une nouvelle API tRPC avec une infrastructure AWS CDK configurée. Le backend généré utilise AWS Lambda pour un déploiement serverless et inclut une validation de schéma via Zod. Il configure AWS Lambda Powertools pour l’observabilité, incluant les logs, le tracing AWS X-Ray et les métriques CloudWatch.
Utilisation
Section intitulée « Utilisation »Générer une API tRPC
Section intitulée « Générer une API tRPC »Vous pouvez générer une nouvelle API tRPC de deux manières :
- Installez le Nx Console VSCode Plugin si ce n'est pas déjà fait
- Ouvrez la console Nx dans VSCode
- Cliquez sur
Generate (UI)
dans la section "Common Nx Commands" - Recherchez
@aws/nx-plugin - ts#trpc-api
- Remplissez les paramètres requis
- Cliquez sur
Generate
pnpm nx g @aws/nx-plugin:ts#trpc-api
yarn nx g @aws/nx-plugin:ts#trpc-api
npx nx g @aws/nx-plugin:ts#trpc-api
bunx nx g @aws/nx-plugin:ts#trpc-api
Vous pouvez également effectuer une simulation pour voir quels fichiers seraient modifiés
pnpm nx g @aws/nx-plugin:ts#trpc-api --dry-run
yarn nx g @aws/nx-plugin:ts#trpc-api --dry-run
npx nx g @aws/nx-plugin:ts#trpc-api --dry-run
bunx nx g @aws/nx-plugin:ts#trpc-api --dry-run
Paramètre | Type | Par défaut | Description |
---|---|---|---|
name Requis | string | - | The name of the API (required). Used to generate class names and file paths. |
computeType | string | ServerlessApiGatewayRestApi | The type of compute to use to deploy this API. Choose between ServerlessApiGatewayRestApi (default) or ServerlessApiGatewayHttpApi. |
auth | string | IAM | The method used to authenticate with your API. Choose between IAM (default), Cognito or None. |
directory | string | packages | The directory to store the application in. |
Sortie du générateur
Section intitulée « Sortie du générateur »Le générateur créera la structure de projet suivante dans le répertoire <directory>/<api-name>
:
Répertoiresrc
- init.ts Initialisation tRPC du backend
- router.ts Définition du routeur tRPC (point d’entrée de l’API du handler Lambda)
Répertoireschema Définitions de schémas avec Zod
- echo.ts Exemple de définitions pour l’entrée et la sortie de la procédure “echo”
Répertoireprocedures Procédures (ou opérations) exposées par votre API
- echo.ts Exemple de procédure
Répertoiremiddleware
- error.ts Middleware de gestion d’erreurs
- logger.ts Middleware de configuration d’AWS Powertools pour les logs Lambda
- tracer.ts Middleware de configuration d’AWS Powertools pour le tracing Lambda
- metrics.ts Middleware de configuration d’AWS Powertools pour les métriques Lambda
- local-server.ts Point d’entrée de l’adaptateur standalone tRPC pour le serveur de développement local
Répertoireclient
- index.ts Client type-safe pour les appels API machine-à-machine
- tsconfig.json Configuration TypeScript
- project.json Configuration du projet et cibles de build
Le générateur créera également des constructs CDK utilisables pour déployer votre API, situés dans le répertoire packages/common/constructs
.
Implémentation de votre API tRPC
Section intitulée « Implémentation de votre API tRPC »Globalement, les APIs tRPC consistent en un routeur qui délègue les requêtes à des procédures spécifiques. Chaque procédure possède une entrée et une sortie définies comme schémas Zod.
Le répertoire src/schema
contient les types partagés entre votre code client et serveur. Dans ce package, ces types sont définis avec Zod, une bibliothèque de déclaration et validation de schémas orientée TypeScript.
Un exemple de schéma pourrait ressembler à ceci :
import { z } from 'zod';
// Définition du schémaexport const UserSchema = z.object({ name: z.string(), height: z.number(), dateOfBirth: z.string().datetime(),});
// Type TypeScript correspondantexport type User = z.TypeOf<typeof UserSchema>;
Avec le schéma ci-dessus, le type User
est équivalent au TypeScript suivant :
interface User { name: string; height: number; dateOfBirth: string;}
Les schémas sont partagés entre le code serveur et client, offrant un seul endroit à modifier pour changer les structures utilisées dans votre API.
Les schémas sont automatiquement validés par votre API tRPC à l’exécution, évitant d’écrire manuellement une logique de validation dans le backend.
Zod fournit des utilitaires puissants pour combiner ou dériver des schémas comme .merge
, .pick
, .omit
et plus encore. Plus d’informations sur le site de documentation Zod.
Routeur et procédures
Section intitulée « Routeur et procédures »Le point d’entrée de votre API se trouve dans src/router.ts
. Ce fichier contient le handler Lambda qui route les requêtes vers des “procédures” basées sur l’opération invoquée. Chaque procédure définit l’entrée attendue, la sortie et l’implémentation.
Le routeur généré en exemple possède une seule opération appelée echo
:
import { echo } from './procedures/echo.js';
export const appRouter = router({ echo,});
L’exemple de procédure echo
est généré dans src/procedures/echo.ts
:
export const echo = publicProcedure .input(EchoInputSchema) .output(EchoOutputSchema) .query((opts) => ({ result: opts.input.message }));
Décomposons ce code :
publicProcedure
définit une méthode publique de l’API, incluant le middleware configuré danssrc/middleware
. Ce middleware inclut l’intégration d’AWS Lambda Powertools pour les logs, tracing et métriques.input
accepte un schéma Zod définissant l’entrée attendue de l’opération. Les requêtes pour cette opération sont automatiquement validées contre ce schéma.output
accepte un schéma Zod définissant la sortie attendue. Des erreurs de type apparaîtront dans votre implémentation si vous ne retournez pas une sortie conforme.query
accepte une fonction définissant l’implémentation de votre API. Cette implémentation reçoitopts
, qui contient l’input
passé à l’opération, ainsi que le contexte configuré par le middleware, disponible dansopts.ctx
. La fonction passée àquery
doit retourner une sortie conforme au schémaoutput
.
L’utilisation de query
pour définir l’implémentation indique que l’opération n’est pas mutative. Utilisez-la pour des méthodes de récupération de données. Pour une opération mutative, utilisez plutôt la méthode mutation
.
Si vous ajoutez une nouvelle procédure, assurez-vous de l’enregistrer en l’ajoutant au routeur dans src/router.ts
.
Personnalisation de votre API tRPC
Section intitulée « Personnalisation de votre API tRPC »Dans votre implémentation, vous pouvez retourner des erreurs aux clients en lançant un TRPCError
. Ces erreurs acceptent un code
indiquant le type d’erreur, par exemple :
throw new TRPCError({ code: 'NOT_FOUND', message: 'La ressource demandée n\'a pas été trouvée',});
Organisation des opérations
Section intitulée « Organisation des opérations »Au fur et à mesure que votre API grandit, vous pouvez regrouper les opérations liées.
Vous pouvez grouper les opérations avec des routeurs imbriqués, par exemple :
import { getUser } from './procedures/users/get.js';import { listUsers } from './procedures/users/list.js';
const appRouter = router({ users: router({ get: getUser, list: listUsers, }), ...})
Les clients verront alors ce regroupement, par exemple l’invocation de listUsers
ressemblera à :
client.users.list.query();
Journalisation
Section intitulée « Journalisation »Le logger AWS Lambda Powertools est configuré dans src/middleware/logger.ts
, et est accessible dans une implémentation via opts.ctx.logger
. Vous pouvez l’utiliser pour journaliser dans CloudWatch Logs, et/ou contrôler les valeurs supplémentaires à inclure dans chaque message de log structuré. Par exemple :
export const echo = publicProcedure .input(...) .output(...) .query(async (opts) => { opts.ctx.logger.info('Opération appelée avec l\'entrée', opts.input);
return ...; });
Pour plus d’informations, consultez la documentation AWS Lambda Powertools Logger.
Enregistrement de métriques
Section intitulée « Enregistrement de métriques »Les métriques AWS Lambda Powertools sont configurées dans src/middleware/metrics.ts
, et sont accessibles via opts.ctx.metrics
. Vous pouvez les utiliser pour enregistrer des métriques dans CloudWatch sans utiliser le SDK AWS, par exemple :
export const echo = publicProcedure .input(...) .output(...) .query(async (opts) => { opts.ctx.metrics.addMetric('Invocations', 'Count', 1);
return ...; });
Pour plus d’informations, consultez la documentation AWS Lambda Powertools Metrics.
Ajustement fin du tracing X-Ray
Section intitulée « Ajustement fin du tracing X-Ray »Le tracer AWS Lambda Powertools est configuré dans src/middleware/tracer.ts
, et est accessible via opts.ctx.tracer
. Vous pouvez l’utiliser pour ajouter des traces AWS X-Ray afin d’obtenir des insights détaillés sur les performances et le flux des requêtes API. Par exemple :
export const echo = publicProcedure .input(...) .output(...) .query(async (opts) => { const subSegment = opts.ctx.tracer.getSegment()!.addNewSubsegment('MyAlgorithm'); // ... logique de l'algorithme à capturer subSegment.close();
return ...; });
Pour plus d’informations, consultez la documentation AWS Lambda Powertools Tracer.
Implémentation de middleware personnalisé
Section intitulée « Implémentation de middleware personnalisé »Vous pouvez ajouter des valeurs supplémentaires au contexte fourni aux procédures en implémentant des middlewares.
Par exemple, implémentons un middleware pour extraire des détails sur l’utilisateur appelant notre API dans src/middleware/identity.ts
.
Cet exemple suppose que auth
est configuré sur IAM
. Pour l’authentification Cognito, le middleware d’identité est plus simple, extrayant les claims pertinents depuis l’event
.
D’abord, définissons ce que nous ajouterons au contexte :
export interface IIdentityContext { identity?: { sub: string; username: string; };}
Notez que nous définissons une propriété optionnelle supplémentaire dans le contexte. tRPC s’assure que cette propriété est définie dans les procédures ayant correctement configuré ce middleware.
Ensuite, implémentons le middleware lui-même. Il a la structure suivante :
export const createIdentityPlugin = () => { const t = initTRPC.context<...>().create(); return t.procedure.use(async (opts) => { // Ajouter une logique à exécuter avant la procédure
const response = await opts.next(...);
// Ajouter une logique à exécuter après la procédure
return response; });};
Dans notre cas, nous voulons extraire les détails de l’utilisateur Cognito. Nous le ferons en extrayant l’ID de sujet (“sub”) de l’événement API Gateway, et en récupérant les détails de l’utilisateur depuis Cognito. L’implémentation varie selon que l’événement provient d’une API REST ou HTTP :
import { CognitoIdentityProvider } from '@aws-sdk/client-cognito-identity-provider';import { initTRPC, TRPCError } from '@trpc/server';import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import { APIGatewayProxyEvent } from 'aws-lambda';
export interface IIdentityContext { identity?: { sub: string; username: string; };}
export const createIdentityPlugin = () => { const t = initTRPC.context<IIdentityContext & CreateAWSLambdaContextOptions<APIGatewayProxyEvent>>().create();
const cognito = new CognitoIdentityProvider();
return t.procedure.use(async (opts) => { const cognitoAuthenticationProvider = opts.ctx.event.requestContext?.identity?.cognitoAuthenticationProvider;
let sub: string | undefined = undefined; if (cognitoAuthenticationProvider) { const providerParts = cognitoAuthenticationProvider.split(':'); sub = providerParts[providerParts.length - 1]; }
if (!sub) { throw new TRPCError({ code: 'FORBIDDEN', message: `Impossible de déterminer l'utilisateur appelant`, }); }
const { Users } = await cognito.listUsers({ // Suppose que l'ID du pool utilisateur est configuré dans l'environnement Lambda UserPoolId: process.env.USER_POOL_ID!, Limit: 1, Filter: `sub="${sub}"`, });
if (!Users || Users.length !== 1) { throw new TRPCError({ code: 'FORBIDDEN', message: `Aucun utilisateur trouvé avec l'ID de sujet ${sub}`, }); }
// Fournit l'identité aux autres procédures via le contexte return await opts.next({ ctx: { ...opts.ctx, identity: { sub, username: Users[0].Username!, }, }, }); });};
import { CognitoIdentityProvider } from '@aws-sdk/client-cognito-identity-provider';import { initTRPC, TRPCError } from '@trpc/server';import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import { APIGatewayProxyEventV2WithIAMAuthorizer } from 'aws-lambda';
export interface IIdentityContext { identity?: { sub: string; username: string; };}
export const createIdentityPlugin = () => { const t = initTRPC.context<IIdentityContext & CreateAWSLambdaContextOptions<APIGatewayProxyEventV2WithIAMAuthorizer>>().create();
const cognito = new CognitoIdentityProvider();
return t.procedure.use(async (opts) => { const cognitoIdentity = opts.ctx.event.requestContext?.authorizer?.iam ?.cognitoIdentity as unknown as | { amr: string[]; } | undefined;
const sub = (cognitoIdentity?.amr ?? []) .flatMap((s) => (s.includes(':CognitoSignIn:') ? [s] : [])) .map((s) => { const parts = s.split(':'); return parts[parts.length - 1]; })?.[0];
if (!sub) { throw new TRPCError({ code: 'FORBIDDEN', message: `Impossible de déterminer l'utilisateur appelant`, }); }
const { Users } = await cognito.listUsers({ // Suppose que l'ID du pool utilisateur est configuré dans l'environnement Lambda UserPoolId: process.env.USER_POOL_ID!, Limit: 1, Filter: `sub="${sub}"`, });
if (!Users || Users.length !== 1) { throw new TRPCError({ code: 'FORBIDDEN', message: `Aucun utilisateur trouvé avec l'ID de sujet ${sub}`, }); }
// Fournit l'identité aux autres procédures via le contexte return await opts.next({ ctx: { ...opts.ctx, identity: { sub, username: Users[0].Username!, }, }, }); });};
Déploiement de votre API tRPC
Section intitulée « Déploiement de votre API tRPC »Le générateur d’API tRPC crée un construct CDK pour déployer votre API dans le dossier common/constructs
. Vous pouvez l’utiliser dans une application CDK, par exemple :
import { MyApi } from ':my-scope/common-constructs`;
export class ExampleStack extends Stack { constructor(scope: Construct, id: string) { // Ajoute l'API à votre stack const api = new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this).build(), }); }}
Ceci configure l’infrastructure de votre API, incluant une API Gateway REST ou HTTP AWS, des fonctions Lambda pour la logique métier, et une authentification basée sur la méthode auth
choisie.
Intégrations type-safe
Section intitulée « Intégrations type-safe »Les constructeurs CDK d’API REST/HTTP sont configurés pour fournir une interface typée permettant de définir des intégrations pour chacune de vos opérations.
Intégrations par défaut
Section intitulée « Intégrations par défaut »Vous pouvez utiliser la méthode statique defaultIntegrations
pour exploiter le modèle par défaut, qui définit une fonction AWS Lambda distincte pour chaque opération :
new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this).build(),});
Accès aux intégrations
Section intitulée « Accès aux intégrations »Vous pouvez accéder aux fonctions AWS Lambda sous-jacentes via la propriété integrations
du construct d’API, de manière typée. Par exemple, si votre API définit une opération nommée sayHello
et que vous devez ajouter des permissions à cette fonction, vous pouvez le faire comme suit :
const api = new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this).build(),});
// sayHello est typé en fonction des opérations définies dans votre APIapi.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: [...], resources: [...],}));
Personnalisation des options par défaut
Section intitulée « Personnalisation des options par défaut »Si vous souhaitez personnaliser les options utilisées lors de la création de la fonction Lambda pour chaque intégration par défaut, vous pouvez utiliser la méthode withDefaultOptions
. Par exemple, si vous voulez que toutes vos fonctions Lambda résident dans un VPC :
const vpc = new Vpc(this, 'Vpc', ...);
new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this) .withDefaultOptions({ vpc, }) .build(),});
Surcharge des intégrations
Section intitulée « Surcharge des intégrations »Vous pouvez également surcharger les intégrations pour des opérations spécifiques en utilisant la méthode withOverrides
. Chaque surcharge doit spécifier une propriété integration
typée selon le construct d’intégration CDK approprié pour l’API HTTP ou REST. La méthode withOverrides
est également typée. Par exemple, pour rediriger une API getDocumentation
vers une documentation hébergée sur un site externe :
new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this) .withOverrides({ getDocumentation: { integration: new HttpIntegration('https://example.com/documentation'), }, }) .build(),});
Vous remarquerez que l’intégration surchargée n’a plus de propriété handler
lors de son accès via api.integrations.getDocumentation
.
Vous pouvez ajouter des propriétés supplémentaires à une intégration qui seront également typées, permettant d’abstraire d’autres types d’intégration tout en conservant le typage. Par exemple, si vous avez créé une intégration S3 pour une API REST et que vous souhaitez référencer le bucket pour une opération particulière :
const storageBucket = new Bucket(this, 'Bucket', { ... });
const apiGatewayRole = new Role(this, 'ApiGatewayS3Role', { assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),});
storageBucket.grantRead(apiGatewayRole);
const api = new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this) .withOverrides({ getFile: { bucket: storageBucket, integration: new AwsIntegration({ service: 's3', integrationHttpMethod: 'GET', path: `${storageBucket.bucketName}/{fileName}`, options: { credentialsRole: apiGatewayRole, requestParameters: { 'integration.request.path.fileName': 'method.request.querystring.fileName', }, integrationResponses: [{ statusCode: '200' }], }, }), options: { requestParameters: { 'method.request.querystring.fileName': true, }, methodResponses: [{ statusCode: '200', }], } }, }) .build(),});
// Plus tard, peut-être dans un autre fichier, vous pouvez accéder à la propriété bucket// de manière typéeapi.integrations.getFile.bucket.grantRead(...);
Surcharge des autorisations
Section intitulée « Surcharge des autorisations »Vous pouvez également fournir des options
dans votre intégration pour surcharger des options de méthode spécifiques comme les autorisations. Par exemple, pour utiliser l’authentification Cognito pour l’opération getDocumentation
:
new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this) .withOverrides({ getDocumentation: { integration: new HttpIntegration('https://example.com/documentation'), options: { authorizer: new CognitoUserPoolsAuthorizer(...) // pour REST, ou HttpUserPoolAuthorizer pour une API HTTP } }, }) .build(),});
Intégrations explicites
Section intitulée « Intégrations explicites »Si vous préférez, vous pouvez choisir de ne pas utiliser les intégrations par défaut et fournir directement une intégration pour chaque opération. Ceci est utile si chaque opération nécessite un type d’intégration différent, ou si vous souhaitez obtenir une erreur de type lors de l’ajout de nouvelles opérations :
new MyApi(this, 'MyApi', { integrations: { sayHello: { integration: new LambdaIntegration(...), }, getDocumentation: { integration: new HttpIntegration(...), }, },});
Modèle Router
Section intitulée « Modèle Router »Si vous préférez déployer une seule fonction Lambda pour traiter toutes les requêtes de l’API, vous pouvez modifier librement la méthode defaultIntegrations
de votre API pour créer une seule fonction au lieu d’une par intégration :
export class MyApi<...> extends ... {
public static defaultIntegrations = (scope: Construct) => { const router = new Function(scope, 'RouterHandler', { ... }); return IntegrationBuilder.rest({ ... defaultIntegrationOptions: {}, buildDefaultIntegration: (op) => { return { // Référencer le même gestionnaire Lambda de routeur dans chaque intégration integration: new LambdaIntegration(router), }; }, }); };}
Vous pouvez modifier le code selon vos préférences, par exemple définir la fonction router
comme paramètre de defaultIntegrations
au lieu de la construire dans la méthode.
Octroi d’accès (IAM uniquement)
Section intitulée « Octroi d’accès (IAM uniquement) »Si vous avez choisi l’authentification IAM
, vous pouvez utiliser la méthode grantInvokeAccess
pour octroyer l’accès à votre API. Par exemple, vous pourriez vouloir autoriser les utilisateurs Cognito authentifiés à accéder à votre API :
api.grantInvokeAccess(myIdentityPool.authenticatedRole);
Serveur tRPC local
Section intitulée « Serveur tRPC local »Vous pouvez utiliser la cible serve
pour exécuter un serveur local pour votre API, par exemple :
pnpm nx run @my-scope/my-api:serve
yarn nx run @my-scope/my-api:serve
npx nx run @my-scope/my-api:serve
bunx nx run @my-scope/my-api:serve
Le point d’entrée du serveur local est src/local-server.ts
.
Celui-ci se rechargera automatiquement lorsque vous modifierez votre API.
Invocation de votre API tRPC
Section intitulée « Invocation de votre API tRPC »Vous pouvez créer un client tRPC pour appeler votre API de manière type-safe. Si vous appelez votre API tRPC depuis un autre backend, vous pouvez utiliser le client dans src/client/index.ts
, par exemple :
import { createMyApiClient } from ':my-scope/my-api';
const client = createMyApiClient({ url: 'https://my-api-url.example.com/' });
await client.echo.query({ message: 'Hello world!' });
Si vous appelez votre API depuis un site React, envisagez d’utiliser le générateur Connexion API pour configurer le client.
Plus d’informations
Section intitulée « Plus d’informations »Pour plus d’informations sur tRPC, consultez la documentation tRPC.