Pular para o conteúdo

Jogo de Dungeons com IA

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.

Para definir as entradas e saídas da nossa API, vamos criar nosso schema usando Zod no diretório packages/game-api/src/schema 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 packages/game-api/src/schema/echo.ts pois não o usaremos neste projeto.

O diagrama ER para nossa aplicação é o seguinte:

dungeon-adventure-er.png

Vamos implementar nosso banco de dados no DynamoDB usando a biblioteca cliente ElectroDB para simplificar o processo. Para começar, primeiro precisamos instalar o electrodb executando o comando:

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

Agora vamos criar os seguintes arquivos na pasta packages/game-api/src/entities para definir nossas entidades ElectroDB de acordo com 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 nossos tipos, mas também fornecer valores padrão para certos campos como os timestamps acima. Além disso, o ElectroDB segue o design de tabela única, que é a melhor prática ao usar DynamoDB.

Adicionando o cliente DynamoDB ao contexto do tRPC

Seção intitulada “Adicionando o cliente DynamoDB ao contexto do tRPC”

Como precisamos de acesso ao cliente DynamoDB em cada um de nossos procedimentos, queremos criar uma única instância do cliente que possamos passar via contexto. Para isso, faça as seguintes alterações em 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 é um plugin que instrumentamos para criar o DynamoDBClient e injetá-lo no contexto.

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

Você também pode excluir o arquivo echo.ts (de packages/game-api/src/procedures) pois não o usaremos neste projeto.

Agora que definimos nossos procedimentos, vamos conectá-los à nossa API. Para isso, atualize o seguinte 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;

O passo final é atualizar nossa infraestrutura para criar a tabela DynamoDB e conceder permissões para operações da Game API. Para isso, atualize o packages/infra/src conforme abaixo:

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

Primeiro, vamos construir a base de código:

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

Sua aplicação agora pode ser implantada executando:

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

A primeira implantação levará cerca de 8 minutos. Implantações subsequentes levarão cerca de 2 minutos.

Você também pode implantar todas as stacks de uma vez. Clique aqui para mais detalhes.

Após a conclusão da implantação, você verá saídas similares a estas (alguns valores foram omitidos):

Terminal window
dungeon-adventure-infra-sandbox
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Tempo de implantação: 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 testar nossa API de duas formas:

  • Iniciando uma instância local do backend tRPC e invocando as APIs com curl.
  • Chamando a API implantada usando curl com Sigv4

Inicie o servidor local do game-api com:

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

Com o servidor rodando, teste com:

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

Se o comando for bem-sucedido, você verá:

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

Parabéns, você construiu e implantou sua primeira API usando tRPC! 🎉🎉🎉