Aller au contenu

Jeu de Donjon IA

Module 2 : Implémentation de l’API de jeu

Nous allons commencer par implémenter notre API de jeu. Pour ce faire, nous devons créer 4 API au total :

  1. createGame - créera une nouvelle instance de jeu.
  2. queryGames - retournera une liste paginée des parties précédemment sauvegardées.
  3. saveAction - sauvegardera une action pour une partie donnée.
  4. queryActions - retournera une liste paginée de toutes les actions liées à une partie.

Schéma d’API

Pour définir les entrées et sorties de notre API, créons notre schéma avec Zod dans le projet packages/game-api/schema/src comme suit :

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>;

Vous pouvez aussi supprimer le fichier ./procedures/echo.ts car nous ne l’utiliserons pas dans ce projet.

Modélisation d’entités

Le diagramme entité-relation de notre application est le suivant :

dungeon-adventure-er.png

Nous allons implémenter notre base de données dans DynamoDB en utilisant la librairie cliente ElectroDB pour simplifier le développement. Pour commencer, installons d’abord electrodb avec la commande suivante :

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

Créons maintenant les fichiers suivants dans notre dossier packages/game-api/backend/src/entities pour définir nos entités ElectroDB selon le diagramme ER ci-dessus :

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 est très puissant et nous permet non seulement de définir nos types, mais aussi de fournir des valeurs par défaut comme pour les horodatages ci-dessus. De plus, ElectroDB suit le single-table design, une meilleure pratique avec DynamoDB.

Intégration du client DynamoDB dans le contexte tRPC

Comme nous avons besoin d’accéder au client DynamoDB dans chacune de nos procédures, nous voulons créer une instance unique du client que nous pouvons passer via le contexte. Pour ce faire, effectuez les modifications suivantes dans 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;
});
};

Il s’agit d’un plugin que nous instrumentons pour créer le DynamoDBClient et l’injecter dans le contexte.

Définition des procédures

Maintenant, implémentons les méthodes de l’API. Effectuez les modifications suivantes dans 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,
};
});

Vous pouvez aussi supprimer le fichier echo.ts (dans packages/game-api/backend/src/procedures) car nous ne l’utiliserons pas.

Configuration du routeur

Maintenant que nos procédures sont définies, branchons-les dans notre API. Mettez à jour le fichier comme suit :

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;

Infrastructure

La dernière étape consiste à mettre à jour notre infrastructure pour créer la table DynamoDB et accorder les permissions d’accès depuis l’API de jeu. Mettez à jour packages/infra/src comme suit :

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 });
}
}

Déploiement et tests

D’abord, compilons le codebase :

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

Déployez maintenant votre application avec :

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

Le premier déploiement prendra environ 8 minutes. Les suivants prendront ~2 minutes.

Vous pouvez aussi déployer toutes les stacks en une fois. Cliquez pour plus de détails.

Une fois le déploiement terminé, vous verrez des sorties similaires à ceci (valeurs masquées) :

Fenêtre de terminal
dungeon-adventure-infra-sandbox
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Temps de déploiement : 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

Testez l’API avec :

  • Démarrez une instance locale du backend tRPC et appelez les API via curl
  • Appeler l'API déployée avec curl activé pour Sigv4

Démarrez le serveur game-api localement :

Terminal window
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api-backend:serve

Une fois le serveur démarré, appelez-le avec :

Fenêtre de terminal
curl -X GET http://localhost:2022/games.query\?input="\\{\\}"

Si la commande réussit, vous verrez :

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

Félicitations. Vous avez déployé votre première API avec tRPC ! 🎉🎉🎉