Salta ai contenuti

Gioco di Dungeon con IA

Modulo 2: Implementazione dell’API del 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 giochi salvati precedentemente.
  3. saveAction - salverà un’azione per un determinato gioco.
  4. queryActions - restituirà una lista paginata di tutte le azioni relative a un gioco.

Schema dell’API

Per definire gli input e output della nostra API, creiamo lo schema utilizzando Zod all’interno del 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 lo utilizzeremo in questo progetto.

Modellazione delle entità

Il diagramma ER per la nostra applicazione è il seguente:

dungeon-adventure-er.png

Implementeremo il nostro database in DynamoDB e utilizzeremo la libreria client ElectroDB per semplificare il tutto. Per iniziare, dobbiamo prima installare electrodb eseguendo il seguente comando:

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

Ora creiamo i seguenti file all’interno della 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 nostri tipi, ma può anche fornire valori predefiniti per certi campi come i timestamp sopra. Inoltre, ElectroDB segue il single-table design, considerata la best practice con DynamoDB.

Aggiunta del client DynamoDB al contesto tRPC

Dato che abbiamo bisogno di accedere al client DynamoDB in ciascuna delle nostre procedure, vogliamo creare un’unica istanza del client da passare tramite contesto. Per farlo, apporta le seguenti 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 strumentiamo per creare il DynamoDBClient e iniettarlo nel contesto.

Definizione delle procedure

Ora è il momento di implementare i metodi dell’API. Apporta le seguenti 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 lo utilizzeremo in questo progetto.

Configurazione del router

Ora che abbiamo definito le nostre procedure, colleghiamole alla nostra API. Aggiorna il seguente 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;

Infrastruttura

Il passo finale è aggiornare la nostra infrastruttura per creare la tabella DynamoDB e concedere i permessi per eseguire operazioni dalla Game API. 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 testing

Prima di tutto, compiliamo il codice:

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

Ora puoi distribuire l’applicazione eseguendo:

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

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

Puoi anche distribuire tutti gli stack contemporaneamente. Clicca qui per maggiori dettagli.

Una volta completato il deployment, dovresti vedere output simili ai seguenti (alcuni valori sono stati 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 la nostra API in due modi:

  • Avviando un’istanza locale del backend tRPC e invocando le API con curl.
  • Chiamare l'API distribuita utilizzando curl con autenticazione 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 viene eseguito correttamente, dovresti vedere una risposta come:

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

Complimenti, hai creato e distribuito la tua prima API utilizzando tRPC! 🎉🎉🎉