Saltearse al contenido

Juego de Mazmorra con IA

Módulo 2: Implementación de la API del juego

Comenzaremos 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 anteriormente.
  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, crearemos 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 de nuestra aplicación es el siguiente:

dungeon-adventure-er.png

Implementaremos nuestra base de datos en DynamoDB usando la biblioteca cliente ElectroDB. 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 anteriores. Además, ElectroDB sigue el diseño de tabla única, considerada mejor práctica con DynamoDB.

Añadiendo el cliente DynamoDB al contexto de tRPC

Como necesitamos acceso al cliente DynamoDB en cada procedimiento, crearemos una instancia única del cliente que pasaremos mediante el contexto. Para esto, realiza los siguientes 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 instrumentamos para crear el DynamoDBClient e inyectarlo en el contexto.

Definiendo nuestros procedimientos

Ahora implementaremos los métodos de la API. Para esto, realiza los siguientes 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) ya que no lo usaremos.

Configuración del router

Con los procedimientos definidos, conectémoslos a nuestra API. Actualiza el siguiente archivo:

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 { 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

El paso final es actualizar nuestra infraestructura para crear la tabla DynamoDB y otorgar permisos a la Game API. Actualiza packages/infra/src como sigue:

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, construyamos el código:

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

Ahora puedes desplegar la aplicación con:

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

El primer despliegue tomará ~8 minutos. Los siguientes tomarán ~2 minutos.

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

Tras el despliegue, verás salidas similares a:

Ventana de terminal
dungeon-adventure-infra-sandbox
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Deployment time: 354s
Outputs:
dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY
dungeon-adventure-infra-sandbox.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Podemos probar la API mediante:

  • Iniciar una instancia local del backend tRPC y usar curl.
  • Llamar a la API desplegada usando curl con Sigv4

Inicia el servidor local game-api con:

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

Una vez iniciado, prueba con:

Ventana de terminal
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'

Si todo funciona, verás:

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

¡Felicidades, has construido y desplegado tu primera API usando tRPC! 🎉🎉🎉