Saltearse al contenido

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:

  1. createGame - creará una nueva instancia de 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.

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

También puedes eliminar el archivo packages/game-api/src/schema/echo.ts ya que no lo usaremos en este proyecto.

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 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/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 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:

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.

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

También puedes eliminar el archivo echo.ts (de packages/game-api/src/procedures) ya que no lo usaremos en este proyecto.

Ahora que hemos definido nuestros procedimientos, conectémoslos a nuestra API. Para esto, 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;

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:

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

Primero, construyamos el código base:

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

Ahora puedes desplegar tu aplicación ejecutando:

Terminal window
pnpm 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):

Ventana de terminal
dungeon-adventure-sandbox-Application
dungeon-adventure-sandbox-Application: deploying... [2/2]
dungeon-adventure-sandbox-Application
Deployment time: 354s
Outputs:
dungeon-adventure-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-sandbox-Application-ElectroDbTableXXX-YYY
dungeon-adventure-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-sandbox-Application.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-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

Inicia tu servidor local game-api ejecutando:

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

Una vez en funcionamiento, puedes llamarlo con:

Ventana de terminal
curl -X GET 'http://localhost:2022/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! 🎉🎉🎉