Salta ai contenuti

Gioco di Dungeon con IA

Modulo 2: Implementazione dell’API di gioco

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.

Schema API

Per definire input e output della nostra API, creiamo lo schema utilizzando Zod nel progetto packages/game-api/schema/src 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 ./procedures/echo.ts dato che non verrà utilizzato in questo progetto.

Modellazione delle entità

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 electrodb eseguendo:

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

Creiamo ora i seguenti file nella cartella packages/game-api/backend/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 certi campi come i timestamp sopra. Inoltre, ElectroDB segue il single-table design, considerata best practice con DynamoDB.

Aggiunta del client DynamoDB al contesto tRPC

Dato che abbiamo bisogno del client DynamoDB in ogni nostra procedura, vogliamo creare un’unica istanza del client da passare tramite contesto. Apporta queste modifiche in 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;
});
};

Questo è un plugin che crea il DynamoDBClient e lo inietta nel contesto.

Definizione delle procedure

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

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

Configurazione del router

Ora colleghiamo le procedure all’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 { APIGatewayProxyEventV2WithIAMAuthorizer } 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<APIGatewayProxyEventV2WithIAMAuthorizer>,
) => ctx,
});
export type AppRouter = typeof appRouter;

Infrastruttura

Aggiorniamo l’infrastruttura per creare la tabella DynamoDB e concedere i permessi. Modifica 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 });
}
}

Deployment e test

Prima compiliamo il codice:

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

Deploya 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 circa 2 minuti.

Puoi deployare tutti gli stack insieme. Clicca per dettagli.

Al termine del deployment, vedrai output simili:

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.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/
dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.execute-api.region.amazonaws.com/
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Testiamo l’API con:

  • Un’istanza locale del backend tRPC e chiamate curl
  • Chiamare l'API deployata con curl abilitato per 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-backend:serve

Esegui:

Terminal window
curl -X GET http://localhost:2022/games.query\?input="\\{\\}"

Se tutto funziona, vedrai:

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

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