tRPC
tRPC es un framework para construir APIs en TypeScript con seguridad de tipos de extremo a extremo. Usando tRPC, las actualizaciones en las entradas y salidas de las operaciones del API se reflejan inmediatamente en el código cliente y son visibles en tu IDE sin necesidad de reconstruir tu proyecto.
El generador de API tRPC crea una nueva API tRPC con configuración de infraestructura AWS CDK. El backend generado utiliza AWS Lambda para despliegue serverless e incluye validación de esquemas usando Zod. Configura AWS Lambda Powertools para observabilidad, incluyendo logging, trazado con AWS X-Ray y métricas de CloudWatch.
Uso
Generar una API tRPC
Puedes generar una nueva API tRPC de dos formas:
- Instale el Nx Console VSCode Plugin si aún no lo ha hecho
- Abra la consola Nx en VSCode
- Haga clic en
Generate (UI)
en la sección "Common Nx Commands" - Busque
@aws/nx-plugin - ts#trpc-api
- Complete los parámetros requeridos
- Haga clic en
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
También puede realizar una ejecución en seco para ver qué archivos se cambiarían
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
Opciones
Parámetro | Tipo | Predeterminado | Descripción |
---|---|---|---|
name Requerido | 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. |
Salida del Generador
El generador creará la siguiente estructura de proyecto en el directorio <directory>/<api-name>
:
Directoryschema
Directorysrc
- index.ts Punto de entrada del esquema
Directoryprocedures
- echo.ts Definiciones de esquema compartidas para el procedimiento “echo”, usando Zod
- tsconfig.json Configuración de TypeScript
- project.json Configuración del proyecto y objetivos de build
Directorybackend
Directorysrc
- init.ts Inicialización del backend tRPC
- router.ts Definición del router tRPC (punto de entrada del manejador Lambda)
Directoryprocedures Procedimientos (u operaciones) expuestos por tu API
- echo.ts Procedimiento de ejemplo
Directorymiddleware
- error.ts Middleware para manejo de errores
- logger.ts middleware para configurar AWS Powertools para logging en Lambda
- tracer.ts middleware para configurar AWS Powertools para trazado en Lambda
- metrics.ts middleware para configurar AWS Powertools para métricas en Lambda
- local-server.ts Punto de entrada del adaptador standalone de tRPC para servidor de desarrollo local
Directoryclient
- index.ts Cliente tipado para llamadas máquina-a-máquina al API
- tsconfig.json Configuración de TypeScript
- project.json Configuración del proyecto y objetivos de build
El generador también creará constructs CDK que pueden usarse para desplegar tu API, ubicados en el directorio packages/common/constructs
.
Implementando tu API tRPC
Como se ve arriba, hay dos componentes principales en una API tRPC: schema
y backend
, definidos como paquetes individuales en tu workspace.
schema
y backend
son ambos proyectos TypeScript, por lo que puedes consultar la documentación de proyectos TypeScript para más detalles sobre su uso general.
Schema
El paquete schema define los tipos compartidos entre tu código cliente y servidor. En este paquete, estos tipos se definen usando Zod, una librería TypeScript-first para declaración y validación de esquemas.
Un esquema de ejemplo podría verse así:
import { z } from 'zod';
// Definición del esquemaexport const UserSchema = z.object({ name: z.string(), height: z.number(), dateOfBirth: z.string().datetime(),});
// Tipo TypeScript correspondienteexport type User = z.TypeOf<typeof UserSchema>;
Dado el esquema anterior, el tipo User
es equivalente al siguiente TypeScript:
interface User { name: string; height: number; dateOfBirth: string;}
Los esquemas son compartidos por el código del servidor y cliente, proporcionando un único lugar para actualizar cuando se modifican las estructuras usadas en tu API.
Los esquemas son validados automáticamente por tu API tRPC en tiempo de ejecución, lo que evita tener que crear lógica de validación manual en el backend.
Zod proporciona utilidades poderosas para combinar o derivar esquemas como .merge
, .pick
, .omit
y más. Puedes encontrar más información en el sitio de documentación de Zod.
Backend
La carpeta anidada backend
contiene la implementación de tu API, donde defines las operaciones de tu API y sus entradas, salidas e implementación.
El punto de entrada de tu API está en src/router.ts
. Este archivo contiene el manejador Lambda que enruta las solicitudes a los “procedimientos” según la operación invocada. Cada procedimiento define la entrada esperada, salida e implementación.
El router de ejemplo generado tiene una sola operación llamada echo
:
import { echo } from './procedures/echo.js';
export const appRouter = router({ echo,});
El procedimiento echo
de ejemplo se genera en src/procedures/echo.ts
:
export const echo = publicProcedure .input(EchoInputSchema) .output(EchoOutputSchema) .query((opts) => ({ result: opts.input.message }));
Desglosando lo anterior:
publicProcedure
define un método público en la API, incluyendo el middleware configurado ensrc/middleware
. Este middleware incluye integración con AWS Lambda Powertools para logging, trazado y métricas.input
acepta un esquema Zod que define la entrada esperada para la operación. Las solicitudes para esta operación se validan automáticamente contra este esquema.output
acepta un esquema Zod que define la salida esperada. Verás errores de tipo en tu implementación si no devuelves una salida que cumpla con el esquema.query
acepta una función que define la implementación de tu API. Esta implementación recibeopts
, que contiene elinput
pasado a la operación, así como otro contexto configurado por el middleware, disponible enopts.ctx
. La función pasada aquery
debe devolver una salida que cumpla con el esquema deoutput
.
El uso de query
para definir la implementación indica que la operación no es mutativa. Úsalo para definir métodos de obtención de datos. Para operaciones mutativas, usa el método mutation
en su lugar.
Si añades una nueva operación, asegúrate de registrarla añadiéndola al router en src/router.ts
.
Personalizando tu API tRPC
Errores
En tu implementación, puedes devolver respuestas de error a los clientes lanzando un TRPCError
. Estos aceptan un code
que indica el tipo de error, por ejemplo:
throw new TRPCError({ code: 'NOT_FOUND', message: 'No se pudo encontrar el recurso solicitado',});
Organizando tus Operaciones
A medida que crece tu API, quizás quieras agrupar operaciones relacionadas.
Puedes agrupar operaciones usando routers anidados, por ejemplo:
import { getUser } from './procedures/users/get.js';import { listUsers } from './procedures/users/list.js';
const appRouter = router({ users: router({ get: getUser, list: listUsers, }), ...})
Los clientes verán esta agrupación de operaciones, por ejemplo invocar la operación listUsers
se vería así:
client.users.list.query();
Logging
El logger de AWS Lambda Powertools se configura en src/middleware/logger.ts
, y puede accederse en una implementación de API via opts.ctx.logger
. Puedes usarlo para registrar en CloudWatch Logs, y/o controlar valores adicionales a incluir en cada mensaje de log estructurado. Por ejemplo:
export const echo = publicProcedure .input(...) .output(...) .query(async (opts) => { opts.ctx.logger.info('Operación llamada con input', opts.input);
return ...; });
Para más información sobre el logger, consulta la documentación de AWS Lambda Powertools Logger.
Registro de Métricas
Las métricas de AWS Lambda Powertools se configuran en src/middleware/metrics.ts
, y pueden accederse en una implementación de API via opts.ctx.metrics
. Puedes usarlas para registrar métricas en CloudWatch sin necesidad de importar y usar el AWS SDK, por ejemplo:
export const echo = publicProcedure .input(...) .output(...) .query(async (opts) => { opts.ctx.metrics.addMetric('Invocations', 'Count', 1);
return ...; });
Para más información, consulta la documentación de AWS Lambda Powertools Metrics.
Ajuste Fino del Trazado con X-Ray
El tracer de AWS Lambda Powertools se configura en src/middleware/tracer.ts
, y puede accederse en una implementación de API via opts.ctx.tracer
. Puedes usarlo para añadir trazas con AWS X-Ray y obtener insights detallados sobre el rendimiento y flujo de las solicitudes API. Por ejemplo:
export const echo = publicProcedure .input(...) .output(...) .query(async (opts) => { const subSegment = opts.ctx.tracer.getSegment()!.addNewSubsegment('MyAlgorithm'); // ... lógica de mi algoritmo para capturar subSegment.close();
return ...; });
Para más información, consulta la documentación de AWS Lambda Powertools Tracer.
Implementando Middleware Personalizado
Puedes añadir valores adicionales al contexto proporcionado a los procedimientos implementando middleware.
Como ejemplo, implementemos middleware para extraer detalles del usuario que llama a nuestra API en src/middleware/identity.ts
.
Este ejemplo asume que auth
se configuró como IAM
. Para autenticación Cognito, el middleware de identidad es más directo, extrayendo los claims relevantes del event
.
Primero, definimos lo que añadiremos al contexto:
export interface IIdentityContext { identity?: { sub: string; username: string; };}
Nota que definimos una propiedad adicional opcional al contexto. tRPC se encarga de asegurar que esto esté definido en procedimientos que hayan configurado correctamente este middleware.
Luego, implementamos el middleware mismo. Tiene la siguiente estructura:
export const createIdentityPlugin = () => { const t = initTRPC.context<...>().create(); return t.procedure.use(async (opts) => { // Añade lógica aquí para ejecutar antes del procedimiento
const response = await opts.next(...);
// Añade lógica aquí para ejecutar después del procedimiento
return response; });};
En nuestro caso, queremos extraer detalles del usuario de Cognito que llama. Hacemos esto extrayendo el ID de sujeto (o “sub”) del usuario del evento de API Gateway, y recuperando detalles del usuario de Cognito. La implementación varía ligeramente dependiendo de si el evento fue proporcionado por una 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: `No se pudo determinar el usuario llamante`, }); }
const { Users } = await cognito.listUsers({ // Asume que el ID del user pool está configurado en el entorno lambda UserPoolId: process.env.USER_POOL_ID!, Limit: 1, Filter: `sub="${sub}"`, });
if (!Users || Users.length !== 1) { throw new TRPCError({ code: 'FORBIDDEN', message: `No se encontró usuario con subjectId ${sub}`, }); }
// Proporciona la identidad a otros procedimientos en el contexto 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: `No se pudo determinar el usuario llamante`, }); }
const { Users } = await cognito.listUsers({ // Asume que el ID del user pool está configurado en el entorno lambda UserPoolId: process.env.USER_POOL_ID!, Limit: 1, Filter: `sub="${sub}"`, });
if (!Users || Users.length !== 1) { throw new TRPCError({ code: 'FORBIDDEN', message: `No se encontró usuario con subjectId ${sub}`, }); }
// Proporciona la identidad a otros procedimientos en el contexto return await opts.next({ ctx: { ...opts.ctx, identity: { sub, username: Users[0].Username!, }, }, }); });};
Desplegando tu API tRPC
El generador de backend tRPC genera un construct CDK para desplegar tu API en la carpeta common/constructs
. Puedes consumirlo en una aplicación CDK, por ejemplo:
import { MyApi } from ':my-scope/common-constructs`;
export class ExampleStack extends Stack { constructor(scope: Construct, id: string) { // Añade la api a tu stack const api = new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this).build(), }); }}
Esto configura la infraestructura de tu API, incluyendo una API REST o HTTP de AWS API Gateway, funciones AWS Lambda para la lógica de negocio, y autenticación basada en tu método auth
elegido.
Si seleccionaste usar autenticación Cognito
, necesitarás proporcionar la propiedad identity
al construct de la API:
import { MyApi, UserIdentity } from ':my-scope/common-constructs';
export class ExampleStack extends Stack { constructor(scope: Construct, id: string) { const identity = new UserIdentity(this, 'Identity');
const api = new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this).build(), identity, }); }}
El construct UserIdentity
puede generarse usando el generador ts#cloudscape-website-auth
Integraciones Tipadas
Los constructos CDK de la API REST/HTTP están configurados para proporcionar una interfaz tipada que permite definir integraciones para cada una de tus operaciones.
Integraciones predeterminadas
Puedes usar el método estático defaultIntegrations
para utilizar el patrón predeterminado, que define una función AWS Lambda individual para cada operación:
new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this).build(),});
Accediendo a las integraciones
Puedes acceder a las funciones AWS Lambda subyacentes a través de la propiedad integrations
del constructo de la API, de manera tipada. Por ejemplo, si tu API define una operación llamada sayHello
y necesitas agregar permisos a esta función, puedes hacerlo de la siguiente manera:
const api = new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this).build(),});
// sayHello está tipado según las operaciones definidas en tu APIapi.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: [...], resources: [...],}));
Personalizando opciones predeterminadas
Si deseas personalizar las opciones utilizadas al crear la función Lambda para cada integración predeterminada, puedes usar el método withDefaultOptions
. Por ejemplo, si quieres que todas tus funciones Lambda residan en una VPC:
const vpc = new Vpc(this, 'Vpc', ...);
new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this) .withDefaultOptions({ vpc, }) .build(),});
Sobrescribiendo integraciones
También puedes sobrescribir integraciones para operaciones específicas usando el método withOverrides
. Cada sobrescritura debe especificar una propiedad integration
que esté tipada al constructo de integración CDK apropiado para la API HTTP o REST. El método withOverrides
también está tipado. Por ejemplo, si deseas sobrescribir una API getDocumentation
para apuntar a documentación alojada en un sitio web externo:
new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this) .withOverrides({ getDocumentation: { integration: new HttpIntegration('https://example.com/documentation'), }, }) .build(),});
Notarás que la integración sobrescrita ya no tiene una propiedad handler
cuando se accede a través de api.integrations.getDocumentation
.
Puedes agregar propiedades adicionales a una integración que también estarán tipadas, permitiendo abstraer otros tipos de integraciones manteniendo la seguridad de tipos. Por ejemplo, si has creado una integración S3 para una API REST y luego deseas referenciar el bucket para una operación particular:
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(),});
// Posteriormente, quizás en otro archivo, puedes acceder a la propiedad bucket que definimos// de manera tipadaapi.integrations.getFile.bucket.grantRead(...);
Sobrescribiendo autorizadores
También puedes proporcionar options
en tu integración para sobrescribir opciones de método específicas como autorizadores. Por ejemplo, si deseas usar autenticación de Cognito para tu operación getDocumentation
:
new MyApi(this, 'MyApi', { integrations: MyApi.defaultIntegrations(this) .withOverrides({ getDocumentation: { integration: new HttpIntegration('https://example.com/documentation'), options: { authorizer: new CognitoUserPoolsAuthorizer(...) // para REST, o HttpUserPoolAuthorizer para HTTP API } }, }) .build(),});
Integraciones explícitas
Si lo prefieres, puedes optar por no usar las integraciones predeterminadas y en su lugar proporcionar una directamente para cada operación. Esto es útil si, por ejemplo, cada operación necesita usar un tipo diferente de integración o deseas recibir un error de tipo al agregar nuevas operaciones:
new MyApi(this, 'MyApi', { integrations: { sayHello: { integration: new LambdaIntegration(...), }, getDocumentation: { integration: new HttpIntegration(...), }, },});
Patrón de enrutador
Si prefieres desplegar una sola función Lambda para manejar todas las solicitudes de la API, puedes modificar libremente el método defaultIntegrations
de tu API para crear una única función en lugar de una por integración:
export class MyApi<...> extends ... {
public static defaultIntegrations = (scope: Construct) => { const router = new Function(scope, 'RouterHandler', { ... }); return IntegrationBuilder.rest({ ... defaultIntegrationOptions: {}, buildDefaultIntegration: (op) => { return { // Referencia el mismo manejador Lambda de enrutador en cada integración integration: new LambdaIntegration(router), }; }, }); };}
Puedes modificar el código de otras formas si lo prefieres, por ejemplo, podrías definir la función router
como parámetro de defaultIntegrations
en lugar de construirla dentro del método.
Cuando añades o eliminas un procedimiento en tu API tRPC, estos cambios se reflejarán inmediatamente en el construct CDK sin necesidad de reconstruir.
Concesión de Acceso (Solo IAM)
Si seleccionaste usar autenticación IAM
, puedes usar el método grantInvokeAccess
para conceder acceso a tu API, por ejemplo podrías querer dar acceso a usuarios autenticados de Cognito:
api.grantInvokeAccess(myIdentityPool.authenticatedRole);
Servidor tRPC Local
Puedes usar el target serve
para ejecutar un servidor local de tu api, por ejemplo:
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
El punto de entrada para el servidor local está en src/local-server.ts
.
Invocando tu API tRPC
Puedes crear un cliente tRPC para invocar tu API de forma tipada. Si estás llamando a tu API tRPC desde otro backend, puedes usar el cliente en src/client/index.ts
, por ejemplo:
import { createMyApiClient } from ':my-scope/my-api';
const client = createMyApiClient({ url: 'https://my-api-url.example.com/' });
await client.echo.query({ message: '¡Hola mundo!' });
Si estás llamando a tu API desde un sitio web React, considera usar el generador API Connection para configurar el cliente.
Más Información
Para más información sobre tRPC, consulta la documentación de tRPC.