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 specifica partita.
  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 inoltre 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 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 per 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. Apportiamo le seguenti 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 utilizziamo per creare il DynamoDBClient e iniettarlo nel contesto.

Ora implementiamo i metodi API. Apportiamo le seguenti 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) poiché non verrà utilizzato.

Ora che abbiamo definito le procedure, colleghiamole alla nostra API. Aggiorniamo 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. Aggiorniamo 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.

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

Terminal window
dungeon-adventure-sandbox-Application
dungeon-adventure-sandbox-Application: deploying... [2/2]
dungeon-adventure-sandbox-Application
Deployment time: 354s
Outputs:
dungeon-adventure-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-sandbox-Application-ElectroDbTableXXX-YYY
dungeon-adventure-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-sandbox-Application.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Possiamo testare l’API in due modi:

  • Avviando un’istanza locale del backend tRPC e invocando le API con curl.
  • Chiamare l'API deployata usando curl abilitato per Sigv4

Avvia il server locale game-api con:

Terminal window
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-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, dovresti vedere una risposta come:

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

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