Pular para o conteúdo

Jogo de Dungeons com IA

Módulo 2: Implementação da API do Jogo

Vamos começar implementando nossa Game API. Para isso, precisamos criar 4 APIs no total:

  1. createGame - criará uma nova instância de jogo.
  2. queryGames - retornará uma lista paginada de jogos salvos anteriormente.
  3. saveAction - salvará uma ação para um jogo específico.
  4. queryActions - retornará uma lista paginada de todas as ações relacionadas a um jogo.

Esquema da API

Para definir nossas entradas e saídas da API, vamos criar nosso esquema usando Zod dentro do projeto packages/game-api/schema/src da seguinte forma:

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

Você também pode excluir o arquivo ./procedures/echo.ts pois não o usaremos neste projeto.

Modelagem de Entidades

O diagrama ER do nosso aplicativo é o seguinte:

dungeon-adventure-er.png

Vamos implementar nosso banco de dados no DynamoDB usando a biblioteca cliente ElectroDB. Para começar, primeiro instale o electrodb executando:

Terminal window
pnpm add -w electrodb @aws-sdk/client-dynamodb

Agora crie os seguintes arquivos na pasta packages/game-api/backend/src/entities para definir nossas entidades ElectroDB conforme o diagrama ER acima:

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

O ElectroDB é muito poderoso e nos permite não apenas definir tipos, mas também fornecer valores padrão (como os timestamps acima). Além disso, segue o design de tabela única, considerada a melhor prática para DynamoDB.

Adicionando o cliente DynamoDB ao contexto do tRPC

Precisamos acessar o cliente DynamoDB em cada procedimento. Para isso, faça as seguintes alterações em 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 é um plugin que instrumentamos para criar o DynamoDBClient e injetá-lo no contexto.

Definindo nossos procedimentos

Agora vamos implementar os métodos da API. Faça as seguintes alterações em 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,
};
});

Você também pode excluir o arquivo echo.ts (de packages/game-api/backend/src/procedures) pois não será usado.

Configuração do Router

Agora que definimos os procedimentos, vamos conectá-los à nossa API. Atualize o arquivo:

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;

Infraestrutura

A etapa final é atualizar nossa infraestrutura para criar a tabela DynamoDB e conceder permissões à Game API. Atualize packages/infra/src:

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

Implantação e Testes

Primeiro, vamos construir o código:

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

Implante o aplicativo com:

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

A primeira implantação levará cerca de 8 minutos. As subsequentes levarão ~2 minutos.

Clique para detalhes de implantação completa.

Após a implantação, você verá saídas similares a:

Terminal window
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

Teste a API usando:

  • Instância local do backend tRPC com curl
  • Chamar API implantada com curl autenticado

Inicie o servidor local:

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

Com o servidor rodando:

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

Se bem-sucedido, você verá:

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

Parabéns! Você construiu e implantou sua primeira API com tRPC! 🎉🎉🎉