Saltearse al contenido

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:

  1. createGame - esto creará una nueva instancia del juego.
  2. queryGames - devolverá una lista paginada de juegos guardados previamente.
  3. saveAction - guardará una acción para un juego específico.
  4. 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>;

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:

dungeon-adventure-er.png

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:

Terminal window
pnpm add -w 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 },
);

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:

middleware/dynamodb.ts
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.

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

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:

packages/game-api/backend/src/router.ts
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:

constructs/electrodb-table.ts
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 });
}
}

Despliegue y pruebas

Primero, compila el código:

Terminal window
pnpm nx run-many --target build --all

Despliega la aplicación con:

Terminal window
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox

La primera implementación tomará ~8 minutos. Posteriores despliegues ~2 minutos.

También puedes desplegar todos los stacks. Haz clic para detalles.

Tras el despliegue, verás salidas similares a:

Ventana de terminal
dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY
dungeon-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

Inicia el servidor local:

Terminal window
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api-backend:serve

Ejecuta:

Ventana de terminal
curl -X GET http://localhost:2022/games.query\?input="\\{\\}"

Respuesta exitosa:

{"result":{"data":{"items":[],"cursor":null}}}

¡Felicidades! Has implementado tu primera API con tRPC. 🎉🎉🎉