Gioco di Dungeon con IA
Modulo 2: Implementazione dell’API del gioco
Sezione intitolata “Modulo 2: Implementazione dell’API del gioco”Inizieremo implementando la nostra Game API. Per farlo, dobbiamo creare 4 API in totale:
createGame
- creerà una nuova istanza di gioco.queryGames
- restituirà una lista paginata di partite salvate precedentemente.saveAction
- salverà un’azione per una partita specifica.queryActions
- restituirà una lista paginata di tutte le azioni relative a una partita.
Schema delle API
Sezione intitolata “Schema delle API”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>;
import { z } from 'zod';
export const QueryInputSchema = z.object({ cursor: z.string().optional(), limit: z.number().optional().default(100),});
export const createPaginatedQueryOutput = <ItemType extends z.ZodTypeAny>( itemSchema: ItemType,) => { return z.object({ items: z.array(itemSchema), cursor: z.string().nullable(), });};
export type IQueryInput = z.TypeOf<typeof QueryInputSchema>;
import { z } from 'zod';
export const GameSchema = z.object({ playerName: z.string(), genre: z.enum(['zombie', 'superhero', 'medieval']), lastUpdated: z.string().datetime(),});
export type IGame = z.TypeOf<typeof GameSchema>;
export * from './echo.js'export * from './action.js';export * from './common.js';export * from './game.js';
Puoi anche eliminare il file packages/game-api/src/schema/echo.ts
dato che non verrà utilizzato in questo progetto.
Modellazione delle entità
Sezione intitolata “Modellazione delle entità”Il diagramma ER della nostra applicazione è il seguente:

Implementeremo il nostro database in DynamoDB utilizzando la libreria client ElectroDB per semplificare il lavoro. Per iniziare, installiamo prima electrodb
eseguendo il comando:
pnpm add -w electrodb @aws-sdk/client-dynamodb
yarn add electrodb @aws-sdk/client-dynamodb
npm install --legacy-peer-deps electrodb @aws-sdk/client-dynamodb
bun install 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 }, );
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createGameEntity = (client?: DynamoDBClient) => new Entity( { model: { entity: 'Game', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, genre: { type: 'string', required: true, readOnly: true }, lastUpdated: { type: 'string', required: true, default: () => new Date().toISOString(), }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: [], }, }, }, }, { 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.
Aggiunta del client DynamoDB al contesto tRPC
Sezione intitolata “Aggiunta del client DynamoDB al contesto tRPC”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
:
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.
import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import type { APIGatewayProxyEvent } from 'aws-lambda';import { ILoggerContext } from './logger.js';import { IMetricsContext } from './metrics.js';import { ITracerContext } from './tracer.js';import { IDynamoDBContext } from './dynamodb.js';
export * from './dynamodb.js';export * from './logger.js';export * from './metrics.js';export * from './tracer.js';export * from './error.js';
export type IMiddlewareContext = CreateAWSLambdaContextOptions<APIGatewayProxyEvent> & IDynamoDBContext & ILoggerContext & IMetricsContext & ITracerContext;
Estendiamo il nostro IMiddlewareContext
per aggiungere IDynamoDBContext
.
import { initTRPC } from '@trpc/server';import { createDynamoDBPlugin, createErrorPlugin, createLoggerPlugin, createMetricsPlugin, createTracerPlugin, IMiddlewareContext,} from './middleware/index.js';
process.env.POWERTOOLS_SERVICE_NAME = 'GameApi';process.env.POWERTOOLS_METRICS_NAMESPACE = 'GameApi';
export type Context = IMiddlewareContext;
export const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure .concat(createDynamoDBPlugin()) .concat(createLoggerPlugin()) .concat(createTracerPlugin()) .concat(createMetricsPlugin()) .concat(createErrorPlugin());
Il plugin DynamoDB viene strumentato.
Definizione delle procedure
Sezione intitolata “Definizione delle procedure”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, }; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';
export const queryGames = publicProcedure .input(QueryInputSchema) .output(createPaginatedQueryOutput(GameSchema)) .query(async ({ input, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb); const result = await gameEntity.scan.go({ cursor: input.cursor, count: input.limit, });
return { items: result.data as IGame[], cursor: result.cursor, }; });
import { ActionSchema, IAction } from '../schema/index.js';import { publicProcedure } from '../init.js';import { createActionEntity } from '../entities/action.js';import { createGameEntity } from '../entities/game.js';
export const saveAction = publicProcedure .input(ActionSchema.omit({ timestamp: true })) .output(ActionSchema) .mutation(async ({ input, ctx }) => { const actionEntity = createActionEntity(ctx.dynamoDb); const gameEntity = createGameEntity(ctx.dynamoDb);
const action = await actionEntity.put(input).go(); await gameEntity .update({ playerName: input.playerName }) .set({ lastUpdated: action.data.timestamp }) .go(); return action.data as IAction; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame } from '../schema/index.js';import { publicProcedure } from '../init.js';
export const saveGame = publicProcedure .input(GameSchema.omit({ lastUpdated: true })) .output(GameSchema) .mutation(async ({ input, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb);
const result = await gameEntity.put(input).go(); return result.data as IGame; });
Puoi anche eliminare il file echo.ts
(da packages/game-api/src/procedures
) dato che non verrà utilizzato.
Configurazione del router
Sezione intitolata “Configurazione del router”Ora che abbiamo definito le procedure, colleghiamole alla nostra API. Aggiorna il file come segue:
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
Sezione intitolata “Infrastruttura”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:
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 }); }}
import { GameApi, GameUI, StoryApi, UserIdentity,} from ':dungeon-adventure/common-constructs';import * as cdk from 'aws-cdk-lib';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props);
const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this).build(), integrations: GameApi.defaultIntegrations(this) .withDefaultOptions({ environment: { TABLE_NAME: electroDbTable.tableName, }, }) .build(), });
// Grant read/write access to each procedure's lambda handler according to the permissions it requires electroDbTable.grantReadData(gameApi.integrations['actions.query'].handler); electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadWriteData( gameApi.integrations['actions.save'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, );
const storyApi = new StoryApi(this, 'StoryApi', { integrations: StoryApi.defaultIntegrations(this).build(), });
// grant our authenticated role access to invoke our APIs [storyApi, gameApi].forEach((api) => api.grantInvokeAccess(userIdentity.identityPool.authenticatedRole), );
// Ensure this is instantiated last so our runtime-config.json can be automatically configured new GameUI(this, 'GameUI'); }}
Deployment e testing
Sezione intitolata “Deployment e testing”Prima di tutto, compiliamo il codice:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
Ora puoi deployare l’applicazione con:
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
yarn nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
npx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
bunx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
Il primo deployment richiederà circa 8 minuti. I successivi richiederanno circa 2 minuti.
Comando di deployment
Puoi deployare tutti gli stack dell’applicazione CDK eseguendo:
pnpm nx run @dungeon-adventure/infra:deploy --all
yarn nx run @dungeon-adventure/infra:deploy --all
npx nx run @dungeon-adventure/infra:deploy --all
bunx nx run @dungeon-adventure/infra:deploy --all
Non è raccomandato poiché potresti voler separare gli stage di deployment in stack diversi (es. infra-prod
). In questo caso il flag --all
tenterà di deployare tutto, con possibili deployment indesiderati!
Al completamento del deployment, vedrai output simili a questi (alcuni valori sono oscurati):
dungeon-adventure-infra-sandboxdungeon-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-YYYdungeon-adventure-infra-sandbox.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-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
Curl abilitato Sigv4
Puoi aggiungere questo script al tuo file
.bashrc
(e eseguiresource
) oppure incollarlo direttamente nel terminale.~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}Esempi di utilizzo:
API Gateway
Sezione intitolata “API Gateway”Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxLambda function url
Sezione intitolata “Lambda function url”Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxxAggiungi questa funzione al tuo profilo PowerShell o incollala nella sessione corrente.
Terminal window function acurl {param([Parameter(Mandatory=$true)][string]$Region,[Parameter(Mandatory=$true)][string]$Service,[Parameter(ValueFromRemainingArguments=$true)][string[]]$CurlArgs)$AccessKey = aws configure get aws_access_key_id$SecretKey = aws configure get aws_secret_access_key$SessionToken = aws configure get aws_session_token& curl --aws-sigv4 "aws:amz:$Region`:$Service" --user "$AccessKey`:$SecretKey" -H "X-Amz-Security-Token: $SessionToken" @CurlArgs}Esempi di utilizzo:
API Gateway
Sezione intitolata “API Gateway”Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxLambda function url
Sezione intitolata “Lambda function url”Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx
Avvia il server locale game-api
con:
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY yarn nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY npx nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY bunx nx run @dungeon-adventure/game-api:serve
Una volta avviato il server, puoi chiamarlo con:
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'
acurl ap-southeast-2 execute-api -X GET 'https://xxx.execute-api.ap-southeast-2.amazonaws.com/prod/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! 🎉🎉🎉