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

Para definir las entradas y salidas de nuestra API, creemos 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 comenzar, primero instalamos electrodb ejecutando:

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 tipos, sino también proveer valores por defecto como los timestamps. 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

Sección titulada «Añadiendo el cliente DynamoDB al contexto de tRPC»

Necesitamos acceso al cliente DynamoDB en cada procedimiento. Para esto, hacemos 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 plugin crea el DynamoDBClient y lo inyecta en el contexto.

Implementemos los métodos de la API haciendo estos 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.

Actualizamos el siguiente archivo para conectar nuestros 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 { 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;

Actualizamos packages/infra/src para crear la tabla DynamoDB y asignar 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 });
}
}

Primero, construimos el código:

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

Despliega tu aplicación con:

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

El primer despliegue tomará ~8 minutos. Los siguientes ~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
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Tiempo de despliegue: 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

Prueba la API mediante:

  • Iniciando una instancia local del backend tRPC y usando curl.
  • 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:serve

Ejemplo de llamada:

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

Si la ejecución es exitosa, verás:

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

¡Felicidades, has implementado tu primera API con tRPC! 🎉🎉🎉