Juego de Mazmorra con IA
Módulo 2: Implementación de la API del juego
Sección titulada «Módulo 2: Implementación de la API del juego»Comenzaremos implementando nuestra Game API. Para esto necesitamos crear 4 APIs en total:
createGame
- creará una nueva instancia de juego.queryGames
- devolverá una lista paginada de juegos guardados anteriormente.saveAction
- guardará una acción para un juego específico.queryActions
- devolverá una lista paginada de todas las acciones relacionadas con un juego.
Esquema de la API
Sección titulada «Esquema de la API»Para definir las entradas y salidas de nuestra API, crearemos nuestro esquema usando Zod en el directorio packages/game-api/src/schema
de la siguiente manera:
import { z } from 'zod';
export const ActionSchema = z.object({ playerName: z.string(), timestamp: z.string().datetime(), role: z.enum(['assistant', 'user']), content: z.string(),});
export type IAction = z.TypeOf<typeof ActionSchema>;
import { z } from 'zod';
export const QueryInputSchema = z.object({ cursor: z.string().optional(), limit: z.number().optional().default(100),});
export const createPaginatedQueryOutput = <ItemType extends z.ZodTypeAny>( itemSchema: ItemType,) => { return z.object({ items: z.array(itemSchema), cursor: z.string().nullable(), });};
export type IQueryInput = z.TypeOf<typeof QueryInputSchema>;
import { z } from 'zod';
export const GameSchema = z.object({ playerName: z.string(), genre: z.enum(['zombie', 'superhero', 'medieval']), lastUpdated: z.string().datetime(),});
export type IGame = z.TypeOf<typeof GameSchema>;
export * from './echo.js'export * from './action.js';export * from './common.js';export * from './game.js';
También puedes eliminar el archivo packages/game-api/src/schema/echo.ts
ya que no lo usaremos en este proyecto.
Modelado de entidades
Sección titulada «Modelado de entidades»El diagrama ER de nuestra aplicación es el siguiente:

Implementaremos nuestra base de datos en DynamoDB usando la biblioteca cliente ElectroDB para simplificar el proceso. Para comenzar, primero necesitamos instalar electrodb
ejecutando el siguiente comando:
pnpm add -w electrodb @aws-sdk/client-dynamodb
yarn add electrodb @aws-sdk/client-dynamodb
npm install --legacy-peer-deps electrodb @aws-sdk/client-dynamodb
bun install electrodb @aws-sdk/client-dynamodb
Ahora creemos los siguientes archivos en nuestra carpeta packages/game-api/src/entities
para definir nuestras entidades ElectroDB según el diagrama ER anterior:
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (client?: DynamoDBClient) => new Entity( { model: { entity: 'Action', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, timestamp: { type: 'string', required: true, readOnly: true, set: () => new Date().toISOString(), default: () => new Date().toISOString(), }, role: { type: 'string', required: true, readOnly: true }, content: { type: 'string', required: true, readOnly: true }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: ['timestamp'] }, }, }, }, { client, table: process.env.TABLE_NAME }, );
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createGameEntity = (client?: DynamoDBClient) => new Entity( { model: { entity: 'Game', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, genre: { type: 'string', required: true, readOnly: true }, lastUpdated: { type: 'string', required: true, default: () => new Date().toISOString(), }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: [], }, }, }, }, { client, table: process.env.TABLE_NAME }, );
ElectroDB es muy potente y nos permite no solo definir nuestros tipos, sino también proporcionar valores predeterminados para ciertos campos como las marcas de tiempo anteriores. Además, ElectroDB sigue el diseño de tabla única, que es la mejor práctica al usar DynamoDB.
Añadiendo el cliente DynamoDB a nuestro contexto tRPC
Sección titulada «Añadiendo el cliente DynamoDB a nuestro contexto tRPC»Dado que necesitamos acceso al cliente DynamoDB en cada uno de nuestros procedimientos, queremos crear una única instancia del cliente que podamos pasar a través del contexto. Para esto, realiza los siguientes cambios en packages/game-api/src
:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';import { initTRPC } from '@trpc/server';
export interface IDynamoDBContext { dynamoDb?: DynamoDBClient;}
export const createDynamoDBPlugin = () => { const t = initTRPC.context<IDynamoDBContext>().create(); return t.procedure.use(async (opts) => { const dynamoDb = new DynamoDBClient();
const response = await opts.next({ ctx: { ...opts.ctx, dynamoDb, }, });
return response; });};
Este es un plugin que instrumentamos para crear el DynamoDBClient
e inyectarlo en el contexto.
import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import type { APIGatewayProxyEvent } from 'aws-lambda';import { ILoggerContext } from './logger.js';import { IMetricsContext } from './metrics.js';import { ITracerContext } from './tracer.js';import { IDynamoDBContext } from './dynamodb.js';
export * from './dynamodb.js';export * from './logger.js';export * from './metrics.js';export * from './tracer.js';export * from './error.js';
export type IMiddlewareContext = CreateAWSLambdaContextOptions<APIGatewayProxyEvent> & IDynamoDBContext & ILoggerContext & IMetricsContext & ITracerContext;
Ampliamos nuestro IMiddlewareContext
para añadir el IDynamoDBContext
.
import { initTRPC } from '@trpc/server';import { createDynamoDBPlugin, createErrorPlugin, createLoggerPlugin, createMetricsPlugin, createTracerPlugin, IMiddlewareContext,} from './middleware/index.js';
process.env.POWERTOOLS_SERVICE_NAME = 'GameApi';process.env.POWERTOOLS_METRICS_NAMESPACE = 'GameApi';
export type Context = IMiddlewareContext;
export const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure .concat(createDynamoDBPlugin()) .concat(createLoggerPlugin()) .concat(createTracerPlugin()) .concat(createMetricsPlugin()) .concat(createErrorPlugin());
El plugin de DynamoDB queda instrumentado.
Definiendo nuestros procedimientos
Sección titulada «Definiendo nuestros procedimientos»Ahora es momento de implementar los métodos de la API. Para esto, realiza los siguientes cambios en packages/game-api/src/procedures
:
import { createActionEntity } from '../entities/action.js';import { ActionSchema, IAction, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';import { z } from 'zod';
export const queryActions = publicProcedure .input(QueryInputSchema.extend({ playerName: z.string() })) .output(createPaginatedQueryOutput(ActionSchema)) .query(async ({ input, ctx }) => { const actionEntity = createActionEntity(ctx.dynamoDb); const result = await actionEntity.query .primary({ playerName: input.playerName }) .go({ cursor: input.cursor, count: input.limit });
return { items: result.data as IAction[], cursor: result.cursor, }; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';
export const queryGames = publicProcedure .input(QueryInputSchema) .output(createPaginatedQueryOutput(GameSchema)) .query(async ({ input, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb); const result = await gameEntity.scan.go({ cursor: input.cursor, count: input.limit, });
return { items: result.data as IGame[], cursor: result.cursor, }; });
import { ActionSchema, IAction } from '../schema/index.js';import { publicProcedure } from '../init.js';import { createActionEntity } from '../entities/action.js';import { createGameEntity } from '../entities/game.js';
export const saveAction = publicProcedure .input(ActionSchema.omit({ timestamp: true })) .output(ActionSchema) .mutation(async ({ input, ctx }) => { const actionEntity = createActionEntity(ctx.dynamoDb); const gameEntity = createGameEntity(ctx.dynamoDb);
const action = await actionEntity.put(input).go(); await gameEntity .update({ playerName: input.playerName }) .set({ lastUpdated: action.data.timestamp }) .go(); return action.data as IAction; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame } from '../schema/index.js';import { publicProcedure } from '../init.js';
export const saveGame = publicProcedure .input(GameSchema.omit({ lastUpdated: true })) .output(GameSchema) .mutation(async ({ input, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb);
const result = await gameEntity.put(input).go(); return result.data as IGame; });
También puedes eliminar el archivo echo.ts
(de packages/game-api/src/procedures
) ya que no lo usaremos en este proyecto.
Configuración del router
Sección titulada «Configuración del router»Ahora que hemos definido nuestros procedimientos, conectémoslos a nuestra API. Para esto, actualiza el siguiente archivo:
import { awsLambdaRequestHandler, CreateAWSLambdaContextOptions,} from '@trpc/server/adapters/aws-lambda';import { echo } from './procedures/echo.js';import { t } from './init.js';import { APIGatewayProxyEvent } from 'aws-lambda';import { queryActions } from './procedures/query-actions.js';import { saveAction } from './procedures/save-action.js';import { queryGames } from './procedures/query-games.js';import { saveGame } from './procedures/save-game.js';
export const router = t.router;
export const appRouter = router({ echo, actions: router({ query: queryActions, save: saveAction, }), games: router({ query: queryGames, save: saveGame, }),});
export const handler = awsLambdaRequestHandler({ router: appRouter, createContext: ( ctx: CreateAWSLambdaContextOptions<APIGatewayProxyEvent>, ) => ctx, responseMeta: () => ({ headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': '*', }, }),});
export type AppRouter = typeof appRouter;
Infraestructura
Sección titulada «Infraestructura»El paso final es actualizar nuestra infraestructura para crear la tabla DynamoDB y otorgar permisos para realizar operaciones desde la Game API. Para hacerlo, actualiza packages/infra/src
como sigue:
import { CfnOutput } from 'aws-cdk-lib';import { AttributeType, BillingMode, ProjectionType, Table, TableProps,} from 'aws-cdk-lib/aws-dynamodb';import { Construct } from 'constructs';
export type ElectrodbDynamoTableProps = Omit< TableProps, 'partitionKey' | 'sortKey' | 'billingMode'>;
export class ElectrodbDynamoTable extends Table { constructor(scope: Construct, id: string, props?: ElectrodbDynamoTableProps) { super(scope, id, { partitionKey: { name: 'pk', type: AttributeType.STRING, }, sortKey: { name: 'sk', type: AttributeType.STRING, }, billingMode: BillingMode.PAY_PER_REQUEST, ...props, });
this.addGlobalSecondaryIndex({ indexName: 'gsi1pk-gsi1sk-index', partitionKey: { name: 'gsi1pk', type: AttributeType.STRING, }, sortKey: { name: 'gsi1sk', type: AttributeType.STRING, }, projectionType: ProjectionType.ALL, });
new CfnOutput(this, 'TableName', { value: this.tableName }); }}
import { GameApi, GameUI, StoryApi, UserIdentity,} from ':dungeon-adventure/common-constructs';import { Stack, StackProps } from 'aws-cdk-lib';import * as cdk from 'aws-cdk-lib';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) {export class ApplicationStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props);
const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this).build(), integrations: GameApi.defaultIntegrations(this) .withDefaultOptions({ environment: { TABLE_NAME: electroDbTable.tableName, }, }) .build(), });
// Grant read/write access to each procedure's lambda handler according to the permissions it requires electroDbTable.grantReadData(gameApi.integrations['actions.query'].handler); electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadWriteData( gameApi.integrations['actions.save'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, );
const storyApi = new StoryApi(this, 'StoryApi', { integrations: StoryApi.defaultIntegrations(this).build(), });
// grant our authenticated role access to invoke our APIs [storyApi, gameApi].forEach((api) => api.grantInvokeAccess(userIdentity.identityPool.authenticatedRole), );
// Ensure this is instantiated last so our runtime-config.json can be automatically configured new GameUI(this, 'GameUI'); }}
Despliegue y pruebas
Sección titulada «Despliegue y pruebas»Primero, construyamos el código base:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
Ahora puedes desplegar tu aplicación ejecutando:
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*
yarn nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*
npx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*
bunx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*
El primer despliegue tomará aproximadamente 8 minutos. Los despliegues posteriores tomarán alrededor de 2 minutos.
Una vez completado el despliegue, deberías ver salidas similares a las siguientes (algunos valores han sido omitidos):
dungeon-adventure-sandbox-Applicationdungeon-adventure-sandbox-Application: deploying... [2/2]
✅ dungeon-adventure-sandbox-Application
✨ Deployment time: 354s
Outputs:dungeon-adventure-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-sandbox-Application-ElectroDbTableXXX-YYYdungeon-adventure-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-sandbox-Application.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx
Podemos probar nuestra API de dos formas:
- Iniciando una instancia local del backend tRPC e invocando las APIs con
curl
. - Llamar a la API desplegada usando curl con Sigv4
curl con Sigv4 habilitado
Puedes agregar el siguiente script a tu archivo
.bashrc
(y hacersource
) o simplemente pegarlo en la misma terminal donde ejecutarás el comando.~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}Luego para hacer una petición curl autenticada con sigv4, puedes invocar
acurl
como en los siguientes ejemplos:API Gateway
Sección titulada «API Gateway»Ventana de terminal acurl ap-southeast-2 execute-api -X GET https://xxxURL de función Lambda con streaming
Sección titulada «URL de función Lambda con streaming»Ventana de terminal acurl ap-southeast-2 lambda -N -X POST https://xxxPuedes agregar esta función a tu perfil de PowerShell o pegarla directamente en la sesión actual.
Ventana de terminal function acurl {param([Parameter(Mandatory=$true)][string]$Region,[Parameter(Mandatory=$true)][string]$Service,[Parameter(ValueFromRemainingArguments=$true)][string[]]$CurlArgs)$AccessKey = aws configure get aws_access_key_id$SecretKey = aws configure get aws_secret_access_key$SessionToken = aws configure get aws_session_token& curl --aws-sigv4 "aws:amz:$Region`:$Service" --user "$AccessKey`:$SecretKey" -H "X-Amz-Security-Token: $SessionToken" @CurlArgs}Ejemplos de uso:
API Gateway
Sección titulada «API Gateway»Ventana de terminal acurl ap-southeast-2 execute-api -X GET https://xxxURL de función Lambda con streaming
Sección titulada «URL de función Lambda con streaming»Ventana de terminal acurl ap-southeast-2 lambda -N -X POST https://xxx
Inicia tu servidor local game-api
ejecutando:
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY yarn nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY npx nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY bunx nx run @dungeon-adventure/game-api:serve
Una vez en funcionamiento, puedes llamarlo con:
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'
acurl ap-southeast-2 execute-api -X GET 'https://xxx.execute-api.ap-southeast-2.amazonaws.com/prod/games.query?input=%7B%7D'
Si el comando se ejecuta correctamente, deberías ver esta respuesta:
{"result":{"data":{"items":[],"cursor":null}}}
¡Felicidades, has construido y desplegado tu primera API usando tRPC! 🎉🎉🎉