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:
createGame
- creerà una nuova istanza di gioco.queryGames
- restituirà una lista paginata di giochi salvati precedentemente.saveAction
- salverà un’azione per un determinato gioco.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>;
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 './procedures/echo.js';export * from './types/action.js';export * from './types/common.js';export * from './types/game.js';
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:

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:
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 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 }, );
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 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
:
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
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, }; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame, QueryInputSchema, createPaginatedQueryOutput,} from ':dungeon-adventure/game-api-schema';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 ':dungeon-adventure/game-api-schema';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 ':dungeon-adventure/game-api-schema';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/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:
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:
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
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 distribuire l’applicazione eseguendo:
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 deployment successivi richiederanno circa 2 minuti.
Comando di deployment
Puoi distribuire tutti gli stack contenuti nell’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 le fasi di deployment in stack diversi es. infra-prod
. In questo caso il flag --all
tenterà di distribuire tutti gli stack, con il rischio di deployment indesiderati!
Una volta completato il deployment, dovresti vedere output simili ai seguenti (alcuni valori sono stati 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 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
Curl abilitato Sigv4
Puoi aggiungere questo script al tuo file
.bashrc
(e eseguiresource
) o 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
Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxLambda 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
Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxLambda 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 viene eseguito correttamente, dovresti vedere una risposta come:
{"result":{"data":{"items":[],"cursor":null}}}
Complimenti, hai creato e distribuito la tua prima API utilizzando tRPC! 🎉🎉🎉