Salta ai contenuti

Gioco di Dungeon con IA

Inizieremo implementando la nostra Game API. Per farlo, dobbiamo creare 4 API in totale:

  1. createGame - creerà una nuova istanza di gioco.
  2. queryGames - restituirà una lista paginata di partite salvate precedentemente.
  3. saveAction - salverà un’azione per una partita specifica.
  4. queryActions - restituirà una lista paginata di tutte le azioni relative a una partita.

Per definire gli input e output delle nostre API, creiamo lo schema utilizzando Zod nella directory packages/game-api/src/schema come segue:

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

Puoi anche eliminare il file packages/game-api/src/schema/echo.ts dato che non verrà utilizzato in questo progetto.

Il diagramma ER della nostra applicazione è il seguente:

dungeon-adventure-er.png

Implementeremo il nostro database in DynamoDB utilizzando la libreria client ElectroDB per semplificare il lavoro. Per iniziare, installiamo prima electrodb eseguendo il comando:

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

Ora creiamo i seguenti file nella cartella packages/game-api/src/entities per definire le nostre entità ElectroDB secondo il diagramma ER sopra:

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 è molto potente e ci permette non solo di definire i tipi, ma anche di fornire valori predefiniti per alcuni campi come i timestamp sopra. Inoltre, ElectroDB segue il single-table design, considerata la best practice con DynamoDB.

Dato che abbiamo bisogno di accedere al client DynamoDB in ciascuna delle nostre procedure, vogliamo creare un’istanza unica del client da passare tramite il contesto. Per farlo, apporta queste modifiche in 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;
});
};

Questo è un plugin che strumentiamo per creare il DynamoDBClient e iniettarlo nel contesto.

Ora implementiamo i metodi API. Apporta queste modifiche in 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,
};
});

Puoi anche eliminare il file echo.ts (da packages/game-api/src/procedures) dato che non verrà utilizzato.

Ora che abbiamo definito le procedure, colleghiamole alla nostra API. Aggiorna il file come segue:

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;

Il passo finale è aggiornare l’infrastruttura per creare la tabella DynamoDB e concedere i permessi alle operazioni dalla Game API. Aggiorna packages/infra/src come segue:

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

Prima di tutto, compiliamo il codice:

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

Ora puoi deployare l’applicazione con:

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

Il primo deployment richiederà circa 8 minuti. I successivi richiederanno circa 2 minuti.

Puoi anche deployare tutti gli stack insieme. Clicca qui per dettagli.

Al completamento del deployment, vedrai output simili a questi (alcuni valori sono oscurati):

Terminal window
dungeon-adventure-infra-sandbox
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Tempo di deployment: 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

Possiamo testare l’API in due modi:

  • Avviare un’istanza locale del backend tRPC e invocare le API con curl.
  • Chiamare l'API deployata usando curl con Sigv4

Avvia il server locale game-api con:

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

Una volta avviato il server, puoi chiamarlo con:

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

Se il comando ha successo, vedrai una risposta come:

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

Complimenti, hai costruito e deployato la tua prima API con tRPC! 🎉🎉🎉