Salta ai contenuti

tRPC

tRPC è un framework per costruire API in TypeScript con type safety end-to-end. Utilizzando tRPC, gli aggiornamenti agli input e output delle operazioni API si riflettono immediatamente nel codice client e sono visibili nel tuo IDE senza bisogno di ricostruire il progetto.

Il generatore di API tRPC crea una nuova API tRPC con configurazione dell’infrastruttura AWS CDK o Terraform. Il backend generato utilizza AWS Lambda per il deployment serverless, esposto tramite un’API AWS API Gateway, e include la validazione degli schemi con Zod. Configura AWS Lambda Powertools per l’osservabilità, inclusi logging, tracciamento AWS X-Ray e metriche Cloudwatch.

Puoi generare una nuova API tRPC in due modi:

  1. Installa il Nx Console VSCode Plugin se non l'hai già fatto
  2. Apri la console Nx in VSCode
  3. Clicca su Generate (UI) nella sezione "Common Nx Commands"
  4. Cerca @aws/nx-plugin - ts#trpc-api
  5. Compila i parametri richiesti
    • Clicca su Generate
    Parametro Tipo Predefinito Descrizione
    name Obbligatorio 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.
    iacProvider string CDK The preferred IaC provider

    Il generatore creerà la seguente struttura del progetto nella directory <directory>/<api-name>:

    • Directorysrc
      • init.ts Inizializzazione del backend tRPC
      • router.ts Definizione del router tRPC (punto di ingresso API del gestore Lambda)
      • Directoryschema Definizioni degli schemi con Zod
        • echo.ts Definizioni di esempio per input e output della procedura “echo”
      • Directoryprocedures Procedure (o operazioni) esposte dalla tua API
        • echo.ts Procedura di esempio
      • Directorymiddleware
        • error.ts Middleware per la gestione degli errori
        • logger.ts middleware per configurare AWS Powertools per il logging Lambda
        • tracer.ts middleware per configurare AWS Powertools per il tracciamento Lambda
        • metrics.ts middleware per configurare AWS Powertools per le metriche Lambda
      • local-server.ts Punto di ingresso dell’adapter standalone tRPC per server di sviluppo locale
      • Directoryclient
        • index.ts Client type-safe per chiamate API machine-to-machine
    • tsconfig.json Configurazione TypeScript
    • project.json Configurazione del progetto e target di build

    Poiché questo generatore fornisce infrastruttura come codice basata sul tuo iacProvider selezionato, creerà un progetto in packages/common che include i relativi costrutti CDK o moduli Terraform.

    Il progetto comune di infrastruttura come codice è strutturato come segue:

    • Directorypackages/common/constructs
      • Directorysrc
        • Directoryapp/ Construct per l’infrastruttura specifica di un progetto/generatore
        • Directorycore/ Construct generici riutilizzati dai construct in app
        • index.ts Punto di ingresso che esporta i construct da app
      • project.json Target di build e configurazione del progetto

    Per la distribuzione della tua API, vengono generati i seguenti file:

    • Directorypackages/common/constructs/src
      • Directoryapp
        • Directoryapis
          • <project-name>.ts Costrutto CDK per distribuire la tua API
      • Directorycore
        • Directoryapi
          • http-api.ts Costrutto CDK per distribuire un’API HTTP (se hai scelto di distribuire un’API HTTP)
          • rest-api.ts Costrutto CDK per distribuire un’API REST (se hai scelto di distribuire un’API REST)
          • utils.ts Utilities per i costrutti API

    A grandi linee, le API tRPC consistono in un router che delega le richieste a procedure specifiche. Ogni procedura ha un input e un output definiti come schema Zod.

    La directory src/schema contiene i tipi condivisi tra codice client e server. In questo pacchetto, questi tipi sono definiti usando Zod, una libreria TypeScript-first per dichiarazione e validazione di schemi.

    Uno schema di esempio potrebbe essere:

    import { z } from 'zod';
    // Definizione dello schema
    export const UserSchema = z.object({
    name: z.string(),
    height: z.number(),
    dateOfBirth: z.string().datetime(),
    });
    // Tipo TypeScript corrispondente
    export type User = z.TypeOf<typeof UserSchema>;

    Dato lo schema sopra, il tipo User è equivalente al seguente TypeScript:

    interface User {
    name: string;
    height: number;
    dateOfBirth: string;
    }

    Gli schemi sono condivisi sia dal codice server che client, fornendo un unico punto di aggiornamento per modifiche alle strutture usate nella tua API.

    Gli schemi sono automaticamente validati dalla tua API tRPC a runtime, evitando la necessità di scrivere logiche di validazione manuali nel backend.

    Zod fornisce utility potenti per combinare o derivare schemi come .merge, .pick, .omit e altri. Puoi trovare maggiori informazioni sul sito della documentazione Zod.

    Il punto di ingresso della tua API si trova in src/router.ts. Questo file contiene il gestore Lambda che instrada le richieste alle “procedure” in base all’operazione invocata. Ogni procedura definisce l’input atteso, l’output e l’implementazione.

    Il router di esempio generato per te ha una singola operazione chiamata echo:

    import { echo } from './procedures/echo.js';
    export const appRouter = router({
    echo,
    });

    La procedura echo di esempio è generata in src/procedures/echo.ts:

    export const echo = publicProcedure
    .input(EchoInputSchema)
    .output(EchoOutputSchema)
    .query((opts) => ({ result: opts.input.message }));

    Analizzando il codice:

    • publicProcedure definisce un metodo pubblico sull’API, includendo il middleware configurato in src/middleware. Questo middleware include l’integrazione con AWS Lambda Powertools per logging, tracciamento e metriche.
    • input accetta uno schema Zod che definisce l’input atteso per l’operazione. Le richieste per questa operazione sono automaticamente validate rispetto a questo schema.
    • output accetta uno schema Zod che definisce l’output atteso. Vedrai errori di tipo nell’implementazione se non restituisci un output conforme allo schema.
    • query accetta una funzione che definisce l’implementazione della tua API. Questa implementazione riceve opts, che contiene l’input passato all’operazione, oltre ad altro contesto configurato dal middleware, disponibile in opts.ctx. La funzione passata a query deve restituire un output conforme allo schema output.

    L’uso di query per definire l’implementazione indica che l’operazione non è mutativa. Usalo per definire metodi di recupero dati. Per operazioni mutative, usa invece il metodo mutation.

    Se aggiungi una nuova procedura, assicurati di registrarla aggiungendola al router in src/router.ts.

    Nella tua implementazione, puoi restituire errori ai client lanciando un TRPCError. Questi accettano un code che indica il tipo di errore, ad esempio:

    throw new TRPCError({
    code: 'NOT_FOUND',
    message: 'La risorsa richiesta non è stata trovata',
    });

    Man mano che la tua API cresce, potresti voler raggruppare operazioni correlate.

    Puoi raggruppare operazioni usando router annidati, ad esempio:

    import { getUser } from './procedures/users/get.js';
    import { listUsers } from './procedures/users/list.js';
    const appRouter = router({
    users: router({
    get: getUser,
    list: listUsers,
    }),
    ...
    })

    I client riceveranno questo raggruppamento, ad esempio invocare l’operazione listUsers apparirebbe così:

    client.users.list.query();

    Il logger AWS Lambda Powertools è configurato in src/middleware/logger.ts, e può essere accessibile in un’implementazione API via opts.ctx.logger. Puoi usarlo per loggare su CloudWatch Logs, e/o controllare valori aggiuntivi da includere in ogni messaggio di log strutturato. Ad esempio:

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    opts.ctx.logger.info('Operazione chiamata con input', opts.input);
    return ...;
    });

    Per maggiori informazioni sul logger, consulta la documentazione AWS Lambda Powertools Logger.

    Le metriche AWS Lambda Powertools sono configurate in src/middleware/metrics.ts, e possono essere accessibili in un’implementazione API via opts.ctx.metrics. Puoi usarle per registrare metriche in CloudWatch senza bisogno di importare e usare l’AWS SDK, ad esempio:

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

    Per maggiori informazioni, consulta la documentazione AWS Lambda Powertools Metrics.

    Il tracer AWS Lambda Powertools è configurato in src/middleware/tracer.ts, e può essere accessibile in un’implementazione API via opts.ctx.tracer. Puoi usarlo per aggiungere tracce con AWS X-Ray per fornire insight dettagliati sulle prestazioni e il flusso delle richieste API. Ad esempio:

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    const subSegment = opts.ctx.tracer.getSegment()!.addNewSubsegment('MyAlgorithm');
    // ... logica del mio algoritmo da tracciare
    subSegment.close();
    return ...;
    });

    Per maggiori informazioni, consulta la documentazione AWS Lambda Powertools Tracer.

    Puoi aggiungere valori aggiuntivi al contesto fornito alle procedure implementando middleware.

    Come esempio, implementiamo un middleware per estrarre dettagli sull’utente chiamante dalla nostra API in src/middleware/identity.ts.

    Questo esempio assume che auth sia impostato a IAM. Per autenticazione Cognito, il middleware di identità è più diretto, estraendo i claim rilevanti dall’event.

    Prima definiamo cosa aggiungeremo al contesto:

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

    Nota che definiamo una proprietà opzionale aggiuntiva al contesto. tRPC gestisce l’assicurarsi che questa sia definita nelle procedure che hanno configurato correttamente questo middleware.

    Poi implementiamo il middleware stesso. Ha la seguente struttura:

    export const createIdentityPlugin = () => {
    const t = initTRPC.context<...>().create();
    return t.procedure.use(async (opts) => {
    // Aggiungi logica qui da eseguire prima della procedura
    const response = await opts.next(...);
    // Aggiungi logica qui da eseguire dopo la procedura
    return response;
    });
    };

    Nel nostro caso, vogliamo estrarre dettagli sull’utente Cognito chiamante. Lo faremo estraendo l’ID subject (o “sub”) dell’utente dall’evento API Gateway, e recuperando i dettagli utente da Cognito. L’implementazione varia leggermente a seconda che l’evento sia fornito da un’API REST o 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: `Impossibile determinare l'utente chiamante`,
    });
    }
    const { Users } = await cognito.listUsers({
    // Assume che l'ID user pool sia configurato nell'ambiente lambda
    UserPoolId: process.env.USER_POOL_ID!,
    Limit: 1,
    Filter: `sub="${sub}"`,
    });
    if (!Users || Users.length !== 1) {
    throw new TRPCError({
    code: 'FORBIDDEN',
    message: `Nessun utente trovato con subjectId ${sub}`,
    });
    }
    // Fornisci l'identità ad altre procedure nel contesto
    return await opts.next({
    ctx: {
    ...opts.ctx,
    identity: {
    sub,
    username: Users[0].Username!,
    },
    },
    });
    });
    };

    Il generatore di API tRPC crea infrastruttura come codice CDK o Terraform in base al iacProvider selezionato. Puoi usarlo per fare il deploy della tua API tRPC.

    Il costrutto CDK per il deploy della tua API si trova nella cartella common/constructs. Puoi consumarlo in un’applicazione CDK, ad esempio:

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

    Questo configura l’infrastruttura API, inclusa un’API AWS API Gateway REST o HTTP, funzioni AWS Lambda per la business logic, e autenticazione basata sul metodo auth scelto.

    I costrutti CDK per le API REST/HTTP sono configurati per fornire un’interfaccia type-safe per definire le integrazioni per ciascuna delle tue operazioni.

    I costrutti CDK forniscono supporto completo per l’integrazione type-safe come descritto di seguito.

    Puoi utilizzare il metodo statico defaultIntegrations per sfruttare il pattern predefinito, che definisce una singola funzione AWS Lambda per ogni operazione:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });

    Puoi accedere alle funzioni AWS Lambda sottostanti tramite la proprietà integrations del costrutto API in modo type-safe. Ad esempio, se la tua API definisce un’operazione chiamata sayHello e devi aggiungere dei permessi a questa funzione, puoi farlo come segue:

    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    // sayHello è tipizzato in base alle operazioni definite nella tua API
    api.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({
    effect: Effect.ALLOW,
    actions: [...],
    resources: [...],
    }));

    Se desideri personalizzare le opzioni utilizzate durante la creazione delle funzioni Lambda per ogni integrazione predefinita, puoi usare il metodo withDefaultOptions. Ad esempio, per far risiedere tutte le funzioni Lambda in una VPC:

    const vpc = new Vpc(this, 'Vpc', ...);
    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withDefaultOptions({
    vpc,
    })
    .build(),
    });

    Puoi sovrascrivere le integrazioni per operazioni specifiche usando il metodo withOverrides. Ogni override deve specificare una proprietà integration tipizzata correttamente. Esempio per reindirizzare un’API getDocumentation:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withOverrides({
    getDocumentation: {
    integration: new HttpIntegration('https://example.com/documentation'),
    },
    })
    .build(),
    });

    Le integrazioni sovrascritte non avranno più la proprietà handler quando accedute tramite api.integrations.getDocumentation.

    Puoi aggiungere proprietà personalizzate alle integrazioni mantenendo il type-safety. Esempio con integrazione S3:

    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(),
    });
    // Accesso type-safe alla proprietà bucket definita
    api.integrations.getFile.bucket.grantRead(...);

    Puoi sovrascrivere gli authorizer specificando options nelle integrazioni. Esempio con autenticazione Cognito:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withOverrides({
    getDocumentation: {
    integration: new HttpIntegration('https://example.com/documentation'),
    options: {
    authorizer: new CognitoUserPoolsAuthorizer(...) // Per REST API
    }
    },
    })
    .build(),
    });

    Puoi definire manualmente ogni integrazione. Utile per usare tipi diversi per ogni operazione:

    new MyApi(this, 'MyApi', {
    integrations: {
    sayHello: {
    integration: new LambdaIntegration(...),
    },
    getDocumentation: {
    integration: new HttpIntegration(...),
    },
    },
    });

    Per usare una singola Lambda per tutte le richieste, modifica defaultIntegrations:

    export class MyApi<...> extends ... {
    public static defaultIntegrations = (scope: Construct) => {
    const router = new Function(scope, 'RouterHandler', { ... });
    return IntegrationBuilder.rest({
    buildDefaultIntegration: (op) => ({
    integration: new LambdaIntegration(router),
    }),
    });
    };
    }

    Se hai scelto di usare autenticazione IAM, puoi concedere accesso alla tua API:

    api.grantInvokeAccess(myIdentityPool.authenticatedRole);

    Puoi usare il target serve per eseguire un server locale per la tua API, ad esempio:

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

    Il punto di ingresso per il server locale è src/local-server.ts.

    Questo ricaricherà automaticamente le modifiche alla tua API.

    Puoi creare un client tRPC per invocare la tua API in modo type-safe. Se stai chiamando la tua API tRPC da un altro backend, puoi usare il client in src/client/index.ts, ad esempio:

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

    Se stai chiamando la tua API da un sito React, considera di usare il generatore API Connection per configurare il client.

    Per maggiori informazioni su tRPC, consulta la documentazione tRPC.