Juego de Mazmorra con IA
Módulo 2: Implementación de la API del juego
Vamos a comenzar implementando nuestra Game API. Para esto, necesitamos crear 4 APIs en total:
createGame
- esto creará una nueva instancia del juego.queryGames
- devolverá una lista paginada de juegos guardados previamente.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
Para definir las entradas y salidas de nuestra API, creemos nuestro esquema usando Zod dentro del proyecto packages/game-api/schema/src
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 './procedures/echo.js';export * from './types/action.js';export * from './types/common.js';export * from './types/game.js';
También puedes eliminar el archivo ./procedures/echo.ts
ya que no lo usaremos en este proyecto.
Modelado de entidades
El diagrama ER para 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/backend/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 tipos, sino también proveer valores por defecto como los timestamps. Además, ElectroDB sigue el diseño de tabla única, que es la mejor práctica con DynamoDB.
Añadiendo el cliente DynamoDB al contexto de tRPC
Como necesitamos acceso al cliente DynamoDB en cada procedimiento, queremos pasar una única instancia mediante el contexto. Realiza estos cambios en packages/game-api/backend/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 inyecta el DynamoDBClient
en el contexto.
import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import type { APIGatewayProxyEventV2WithIAMAuthorizer } 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<APIGatewayProxyEventV2WithIAMAuthorizer> & IDynamoDBContext & ILoggerContext & IMetricsContext & ITracerContext;
Aumentamos IMiddlewareContext
para añadir 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 .unstable_concat(createDynamoDBPlugin()) .unstable_concat(createLoggerPlugin()) .unstable_concat(createTracerPlugin()) .unstable_concat(createMetricsPlugin()) .unstable_concat(createErrorPlugin());
El plugin DynamoDB queda integrado.
Definiendo nuestros procedimientos
Implementemos los métodos de la API. Realiza estos cambios en packages/game-api/backend/src/procedures
:
import { createActionEntity } from '../entities/action.js';import { ActionSchema, IAction, QueryInputSchema, createPaginatedQueryOutput,} from ':dungeon-adventure/game-api-schema';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 ':dungeon-adventure/game-api-schema';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 ':dungeon-adventure/game-api-schema';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 ':dungeon-adventure/game-api-schema';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/backend/src/procedures
.
Configuración del router
Actualiza el router para integrar los procedimientos:
import { awsLambdaRequestHandler, CreateAWSLambdaContextOptions,} from '@trpc/server/adapters/aws-lambda';import { echo } from './procedures/echo.js';import { t } from './init.js';import { APIGatewayProxyEventV2WithIAMAuthorizer } 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<APIGatewayProxyEventV2WithIAMAuthorizer>, ) => ctx,});
export type AppRouter = typeof appRouter;
Infraestructura
Actualiza la infraestructura para crear la tabla DynamoDB y otorgar permisos:
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 * as cdk from 'aws-cdk-lib';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
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'); const storyApi = new StoryApi(this, 'StoryApi');
gameApi.routerFunction.addEnvironment( 'TABLE_NAME', electroDbTable.tableName, ); electroDbTable.grantReadWriteData(gameApi.routerFunction);
[storyApi, gameApi].forEach((api) => api.grantInvokeAccess(userIdentity.identityPool.authenticatedRole), );
new GameUI(this, 'GameUI'); }}
Despliegue y pruebas
Primero, compila el código:
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
Despliega la aplicación con:
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
La primera implementación tomará ~8 minutos. Posteriores despliegues ~2 minutos.
Comando de despliegue
Despliega todos los stacks con:
pnpm nx run @dungeon-adventure/infra:deploy --all
yarn nx run @dungeon-adventure/infra:deploy --all
npx nx run @dungeon-adventure/infra:deploy --all
bunx nx run @dungeon-adventure/infra:deploy --all
No recomendado si tienes stacks separados para diferentes entornos.
Tras el despliegue, verás salidas similares a:
dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYYdungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/...
Para probar la API:
- Inicia un servidor local o usa curl con autenticación sigv4.
- Llamar a la API desplegada usando curl con sigv4
Curl con sigv4
Añade este script a tu
.bashrc
:Ventana de terminal 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)" "$@"}Ejemplos:
API Gateway
Ventana de terminal acurl ap-southeast-2 execute-api -X GET https://xxxLambda
Ventana de terminal acurl ap-southeast-2 lambda -N -X POST https://xxx
Inicia el servidor local:
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY yarn nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY npx nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY bunx nx run @dungeon-adventure/game-api-backend:serve
Ejecuta:
curl -X GET http://localhost:2022/games.query\?input="\\{\\}"
acurl ap-southeast-2 execute-api -X GET \ https://xxx.execute-api.region.amazonaws.com/games.query\?input\="\{\}"
Respuesta exitosa:
{"result":{"data":{"items":[],"cursor":null}}}
¡Felicidades! Has implementado tu primera API con tRPC. 🎉🎉🎉