Aller au contenu

tRPC

tRPC est un framework pour construire des APIs en TypeScript avec une sécurité typographique de bout en bout. Avec tRPC, les mises à jour des entrées et sorties des opérations d’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 le logging, le tracing AWS X-Ray et les métriques CloudWatch.

Vous pouvez générer une nouvelle API tRPC de deux manières :

  1. Installez le Nx Console VSCode Plugin si ce n'est pas déjà fait
  2. Ouvrez la console Nx dans VSCode
  3. Cliquez sur Generate (UI) dans la section "Common Nx Commands"
  4. Recherchez @aws/nx-plugin - ts#trpc-api
  5. Remplissez les paramètres requis
    • Cliquez sur Generate
    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.

    Le générateur créera la structure de projet suivante dans le répertoire <directory>/<api-name> :

    • Répertoiresrc
      • init.ts Initialisation du backend tRPC
      • router.ts Définition du routeur tRPC (point d’entrée de l’API via le 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 de AWS Powertools pour le logging Lambda
        • tracer.ts Middleware de configuration de AWS Powertools pour le tracing Lambda
        • metrics.ts Middleware de configuration de 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 typé 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 pour déployer votre API, situés dans le répertoire packages/common/constructs.

    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 par un schéma Zod.

    Le répertoire src/schema contient les types partagés entre votre code client et serveur. 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/v4';
    // Définition du schéma
    export const UserSchema = z.object({
    name: z.string(),
    height: z.number(),
    dateOfBirth: z.string().datetime(),
    });
    // Type TypeScript correspondant
    export type User = z.TypeOf<typeof UserSchema>;

    Avec ce schéma, 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 client et serveur, offrant un point unique de modification pour 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.

    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” selon 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,
    });

    La procédure echo exemple est générée 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é dans src/middleware. Ce middleware inclut l’intégration AWS Lambda Powertools pour le logging, tracing et les métriques.
    • input accepte un schéma Zod définissant l’entrée attendue. 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 si l’implémentation ne retourne pas une sortie conforme.
    • query accepte une fonction définissant l’implémentation. Celle-ci reçoit opts contenant l’input passé à l’opération, ainsi que le contexte défini par le middleware disponible dans opts.ctx. La fonction doit retourner une sortie conforme au schéma output.

    L’utilisation de query indique que l’opération est non mutation. Utilisez-le pour définir des méthodes de récupération de données. Pour une opération mutation, utilisez plutôt mutation.

    Si vous ajoutez une nouvelle procédure, assurez-vous de l’enregistrer dans le routeur via src/router.ts.

    Dans votre implémentation, vous pouvez retourner des erreurs aux clients en lançant un TRPCError. Ceux-ci 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',
    });

    Pour grouper des opérations liées, vous pouvez utiliser des routeurs imbriqués :

    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 pour invoquer listUsers :

    client.users.list.query();

    Le logger AWS Lambda Powertools est configuré dans src/middleware/logger.ts et accessible via opts.ctx.logger. Utilisez-le pour journaliser dans CloudWatch Logs :

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    opts.ctx.logger.info('Opération appelée avec l\'entrée', opts.input);
    return ...;
    });

    Plus d’informations dans la documentation AWS Lambda Powertools Logger.

    Les métriques AWS Lambda Powertools sont configurées dans src/middleware/metrics.ts et accessibles via opts.ctx.metrics :

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    opts.ctx.metrics.addMetric('Invocations', 'Count', 1);
    return ...;
    });

    Plus d’informations dans la documentation AWS Lambda Powertools Metrics.

    Le tracer AWS Lambda Powertools est configuré dans src/middleware/tracer.ts et accessible via opts.ctx.tracer :

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    const subSegment = opts.ctx.tracer.getSegment()!.addNewSubsegment('MyAlgorithm');
    // ... logique à tracer
    subSegment.close();
    return ...;
    });

    Plus d’informations dans la documentation AWS Lambda Powertools Tracer.

    Vous pouvez ajouter des valeurs au contexte des procédures via des middlewares.

    Exemple pour extraire l’identité de l’utilisateur dans src/middleware/identity.ts :

    Cet exemple suppose que auth est configuré sur IAM. Pour Cognito, l’extraction est plus directe via les claims de l’event.

    Définition du contexte :

    export interface IIdentityContext {
    identity?: {
    sub: string;
    username: string;
    };
    }

    Implémentation du middleware :

    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: `Unable to determine calling user`,
    });
    }
    const { Users } = await cognito.listUsers({
    UserPoolId: process.env.USER_POOL_ID!,
    Limit: 1,
    Filter: `sub="${sub}"`,
    });
    if (!Users || Users.length !== 1) {
    throw new TRPCError({
    code: 'FORBIDDEN',
    message: `No user found with subjectId ${sub}`,
    });
    }
    return await opts.next({
    ctx: {
    ...opts.ctx,
    identity: {
    sub,
    username: Users[0].Username!,
    },
    },
    });
    });
    };

    Le générateur crée un construct CDK dans common/constructs. Exemple d’utilisation :

    import { MyApi } from ':my-scope/common-constructs`;
    export class ExampleStack extends Stack {
    constructor(scope: Construct, id: string) {
    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    }
    }

    Ceci configure l’infrastructure API Gateway, Lambda et l’authentification selon la méthode auth choisie.

    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.

    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(),
    });

    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 API
    api.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({
    effect: Effect.ALLOW,
    actions: [...],
    resources: [...],
    }));

    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(),
    });

    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ée
    api.integrations.getFile.bucket.grantRead(...);

    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(),
    });

    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(...),
    },
    },
    });

    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 :

    packages/common/constructs/src/app/apis/my-api.ts
    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.

    Pour l’authentification IAM, utilisez grantInvokeAccess :

    api.grantInvokeAccess(myIdentityPool.authenticatedRole);

    Utilisez la cible serve pour exécuter un serveur local :

    Terminal window
    pnpm nx run @my-scope/my-api:serve

    Le point d’entrée est src/local-server.ts. Le serveur se recharge automatiquement lors des modifications.

    Créez un client tRPC typé pour appeler votre API :

    import { createMyApiClient } from ':my-scope/my-api';
    const client = createMyApiClient({ url: 'https://my-api-url.example.com/' });
    await client.echo.query({ message: 'Hello world!' });

    Pour les sites React, utilisez le générateur API Connection.

    Consultez la documentation tRPC.