AIダンジョンゲーム
モジュール2: ゲームAPIの実装
ゲームAPIの実装を開始します。合計4つのAPIを作成する必要があります:
createGame
- 新しいゲームインスタンスを作成queryGames
- 保存済みゲームのページネーション付きリストを返すsaveAction
- 指定したゲームのアクションを保存queryActions
- ゲームに関連する全アクションのページネーション付きリストを返す
APIスキーマ
APIの入力と出力を定義するため、packages/game-api/schema/src
プロジェクト内でZodを使用してスキーマを作成します:
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';
このプロジェクトで使用しないため、./procedures/echo.ts
ファイルは削除できます。
エンティティモデリング
アプリケーションのER図は以下の通りです:

DynamoDBでデータベースを実装し、ElectroDBクライアントライブラリを使用して簡素化します。まず次のコマンドでelectrodb
をインストールします:
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
packages/game-api/backend/src/entities
フォルダ内に以下のファイルを作成し、ER図に従ってElectroDBエンティティを定義します:
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は非常に強力で、タイプ定義に加え、タイムスタンプのデフォルト値設定などが可能です。またElectroDBはシングルテーブル設計に従っており、DynamoDB使用時のベストプラクティスです。
tRPCコンテキストへのDynamoDBクライアント追加
各プロシージャでDynamoDBクライアントにアクセスするため、コンテキスト経由で単一インスタンスを渡せるようにします。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; });};
DynamoDBClientを作成しコンテキストに注入するプラグイン
import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import type { APIGatewayProxyEventV2WithIAMAuthorizer } 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<APIGatewayProxyEventV2WithIAMAuthorizer> & IDynamoDBContext & ILoggerContext & IMetricsContext & ITracerContext;
IMiddlewareContextに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 .unstable_concat(createDynamoDBPlugin()) .unstable_concat(createLoggerPlugin()) .unstable_concat(createTracerPlugin()) .unstable_concat(createMetricsPlugin()) .unstable_concat(createErrorPlugin());
DynamoDBプラグインを組み込み
プロシージャの定義
APIメソッドを実装します。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; });
このプロジェクトで使用しないため、echo.ts
ファイル(packages/game-api/backend/src/procedures
内)は削除できます。
ルーター設定
プロシージャをAPIに接続します。以下のファイルを更新:
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;
インフラストラクチャ
DynamoDBテーブルの作成とGameAPIへの操作権限付与のため、packages/infra/src
を更新:
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'); const storyApi = new StoryApi(this, 'StoryApi');
gameApi.routerFunction.addEnvironment( 'TABLE_NAME', electroDbTable.tableName, ); electroDbTable.grantReadWriteData(gameApi.routerFunction);
[storyApi, gameApi].forEach((api) => api.grantInvokeAccess(userIdentity.identityPool.authenticatedRole), );
new GameUI(this, 'GameUI'); }}
デプロイとテスト
まずコードベースをビルド:
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
アプリケーションをデプロイ:
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
初回デプロイは約8分かかります。以降は約2分に短縮されます。
デプロイコマンド
全スタックをデプロイ:
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
推奨されません。ステージごとにスタックを分離する場合、意図しないデプロイが発生する可能性があります。
デプロイ完了後、以下のような出力が表示されます(一部値は編集済み):
dungeon-adventure-infra-sandboxdungeon-adventure-infra-sandbox: deploying... [2/2]
✅ dungeon-adventure-infra-sandbox
✨ Deployment time: 354s
Outputs:dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYYdungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.execute-api.region.amazonaws.com/dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx
APIテスト方法:
- tRPCバックエンドをローカル起動し
curl
でAPI呼び出し - デプロイ済みAPIの呼び出し
Sigv4対応curl
.bashrc
に以下を追加(または直接貼り付け):Terminal window 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)" "$@"}API Gateway呼び出し例
Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxLambda関数URL呼び出し例
Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx
ローカルサーバー起動:
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY yarn nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY npx nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY bunx nx run @dungeon-adventure/game-api-backend:serve
サーバー起動後、以下を実行:
curl -X GET http://localhost:2022/games.query\?input="\\{\\}"
acurl ap-southeast-2 execute-api -X GET \ https://xxx.execute-api.region.amazonaws.com/games.query\?input\="\{\}"
成功時、以下のレスポンスが返ります:
{"result":{"data":{"items":[],"cursor":null}}}
おめでとうございます!tRPCを使用した最初のAPIの構築とデプロイに成功しました! 🎉🎉🎉