はじめに
Part1 では、KiroのSpec駆動開発の概要と、requirements.md / design.md / tasks.md が自動生成される流れを紹介しました。
この記事では、そのspecをもとに実際にAWSサーバーレスAPIを実装した過程を紹介します。
特に注目したいのはここです:
- specの通りにコードが生成された部分(設計意図が守られた部分)
作ったもの
認証付きメモ管理API(サーバーレス)
技術スタック:
バックエンド: API Gateway + Lambda (TypeScript) + DynamoDB + Cognito
インフラ: AWS CDK (TypeScript)
フロントエンド: React + Vite (S3 + CloudFront)
エンドポイント一覧:
| メソッド | パス | 認証 | 機能 |
|---|---|---|---|
| POST | /auth/signup | 不要 | ユーザー登録 |
| POST | /auth/login | 不要 | ログイン・トークン取得 |
| POST | /memos | 必要 | メモ作成 |
| GET | /memos | 必要 | メモ一覧取得 |
| GET | /memos/{id} | 必要 | メモ1件取得 |
| DELETE | /memos/{id} | 必要 | メモ削除 |
ディレクトリ構成
.
├── bin/
│ └── memo-api.ts # CDKエントリーポイント
├── lib/
│ ├── memo-api-stack.ts # バックエンドCDKスタック
│ └── frontend-stack.ts # フロントエンドCDKスタック
├── lambda/
│ ├── auth/
│ │ ├── signup.ts
│ │ └── login.ts
│ ├── memos/
│ │ ├── create-memo.ts
│ │ ├── get-memos.ts
│ │ ├── get-memo.ts
│ │ └── delete-memo.ts
│ └── shared/
│ ├── types.ts
│ ├── errors.ts
│ └── error-handler.ts
└── frontend/
└── src/ # React SPA
CDKスタックの実装
specの通りに生成された部分:Cognito Authorizerの設定
前編で紹介した design.md には、こう書かれていました。
### Cognito Authorizer設定
- Authorizationヘッダーから Bearer <token> 形式でトークンを取得
- Cognitoでトークンを検証
- 検証成功時、ユーザーIDをLambdaのイベントコンテキストに追加
実際に生成されたCDKコードです:
// Cognito Authorizer
const authorizer = new apigateway.CognitoUserPoolsAuthorizer(
this,
'MemoApiAuthorizer',
{
cognitoUserPools: [this.userPool],
identitySource: 'method.request.header.Authorization',
}
);
// メモ系エンドポイントにAuthorizerを適用
memos.addMethod('POST', new apigateway.LambdaIntegration(createMemoFunction), {
authorizer,
authorizationType: apigateway.AuthorizationType.COGNITO,
});
memos.addMethod('GET', new apigateway.LambdaIntegration(getMemosFunction), {
authorizer,
authorizationType: apigateway.AuthorizationType.COGNITO,
});
specで「Cognito Authorizerで認証する」と明示されていたため、Lambda側でJWT検証するコードは一切生成されませんでした。
DynamoDBテーブルとGSIの設定
// DynamoDB Table
this.memosTable = new dynamodb.Table(this, 'MemosTable', {
tableName: 'Memos',
partitionKey: {
name: 'memoId',
type: dynamodb.AttributeType.STRING,
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// GSI: userId でクエリするためのインデックス
this.memosTable.addGlobalSecondaryIndex({
indexName: 'UserIdIndex',
partitionKey: {
name: 'userId',
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: 'createdAt',
type: dynamodb.AttributeType.STRING,
},
});
design.md で「ユーザーのメモ一覧を取得するためのGSIが必要」と設計されていたため、GSIも正しく定義されました。
Lambda実装
共通エラークラス(shared/errors.ts)
エラーハンドリングも specで設計されており、カスタムエラークラスが整備されました。
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
export class ForbiddenError extends Error { ... }
export class NotFoundError extends Error { ... }
export class UnauthorizedError extends Error { ... }
export class ConflictError extends Error { ... }
共通エラーハンドラ(shared/error-handler.ts)
エラークラスをHTTPステータスコードに変換する関数も生成されています。
export function handleError(error: unknown): APIGatewayProxyResult {
if (error instanceof ValidationError) {
return createErrorResponse(400, 'VALIDATION_ERROR', error.message);
}
if (error instanceof UnauthorizedError) {
return createErrorResponse(401, 'UNAUTHORIZED', error.message);
}
if (error instanceof ForbiddenError) {
return createErrorResponse(403, 'FORBIDDEN', error.message);
}
if (error instanceof NotFoundError) {
return createErrorResponse(404, 'NOT_FOUND', error.message);
}
if (error instanceof ConflictError) {
return createErrorResponse(409, 'CONFLICT', error.message);
}
return createErrorResponse(500, 'INTERNAL_SERVER_ERROR', 'An unexpected error occurred');
}
Signup Lambda(lambda/auth/signup.ts)
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
try {
if (!event.body) {
throw new ValidationError('リクエストボディが必要です');
}
const request: SignupRequest = JSON.parse(event.body);
// バリデーション
validateEmail(request.email);
validatePassword(request.password);
// Cognito SignUp
const command = new SignUpCommand({
ClientId: USER_POOL_CLIENT_ID,
Username: request.email,
Password: request.password,
UserAttributes: [{ Name: 'email', Value: request.email }],
});
const result = await cognitoClient.send(command);
return createSuccessResponse(200, {
userId: result.UserSub!,
email: request.email,
});
} catch (error: any) {
// CognitoエラーをカスタムエラーにマッピングW
if (error.name === 'UsernameExistsException') {
return handleError(new ConflictError('このメールアドレスは既に登録されています'));
}
return handleError(error);
}
}
Create Memo Lambda(lambda/memos/create-memo.ts)
メモ作成時は、Cognito Authorizerが検証済みのトークンから userId を取り出します。
function getUserIdFromEvent(event: APIGatewayProxyEvent): string {
const userId = event.requestContext?.authorizer?.claims?.sub;
if (!userId) {
throw new ValidationError('ユーザーIDが取得できません');
}
return userId;
}
export async function handler(event: APIGatewayProxyEvent) {
const request: CreateMemoRequest = JSON.parse(event.body!);
validateContent(request.content);
const userId = getUserIdFromEvent(event); // ← Authorizerのclaimsから取得
const memoId = uuidv4();
const createdAt = new Date().toISOString();
await docClient.send(new PutCommand({
TableName: MEMOS_TABLE_NAME,
Item: { memoId, userId, content: request.content.trim(), createdAt },
}));
return createSuccessResponse(200, { memoId, userId, content, createdAt });
}
Get Memos Lambda(lambda/memos/get-memos.ts)
メモ一覧の取得は、GSI UserIdIndex を使って自分のメモだけを取得します。
export async function handler(event: APIGatewayProxyEvent) {
const userId = getUserIdFromEvent(event);
const result = await docClient.send(new QueryCommand({
TableName: MEMOS_TABLE_NAME,
IndexName: 'UserIdIndex',
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: { ':userId': userId },
ScanIndexForward: false, // createdAt 降順
}));
return createSuccessResponse(200, { memos: result.Items || [] });
}
Delete Memo Lambda(lambda/memos/delete-memo.ts)
削除前に所有者チェックを行います。他ユーザーのメモは削除できません。
export async function handler(event: APIGatewayProxyEvent) {
const memoId = event.pathParameters?.id;
const userId = getUserIdFromEvent(event);
// メモを取得して所有者確認
const getResult = await docClient.send(new GetCommand({
TableName: MEMOS_TABLE_NAME,
Key: { memoId },
}));
if (!getResult.Item) {
throw new NotFoundError('メモが見つかりません');
}
if (getResult.Item.userId !== userId) {
throw new ForbiddenError('このメモを削除する権限がありません'); // 403
}
await docClient.send(new DeleteCommand({
TableName: MEMOS_TABLE_NAME,
Key: { memoId },
}));
return createSuccessResponse(200, { message: 'メモが正常に削除されました' });
}
デプロイとAPIの動作確認
# デプロイ
npm install
cdk bootstrap # 初回のみ
cdk deploy
# デプロイ完了後に出力される情報
# ApiUrl: https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod
# UserPoolId: ap-northeast-1_xxxxxxxxx
# UserPoolClientId: xxxxxxxxxxxxxxxxxxxxxxxxxx
動作確認:
# ユーザー登録
curl -X POST https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"Test1234!"}'
# ログイン → idTokenを取得
curl -X POST https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"Test1234!"}'
# メモ作成(認証必要)
curl -X POST https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/memos \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <idToken>" \
-d '{"title":"テストメモ","content":"Kiroで作ったAPIのテストです"}'
# メモ一覧取得
curl https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/memos \
-H "Authorization: Bearer <idToken>"
すべて設計通りに動作しました。
中編まとめ
| 項目 | 結果 |
|---|---|
| Cognito Authorizerの設定 | ✅ specの通りに生成 |
| DynamoDB GSIの設計 | ✅ specの通りに生成 |
| Lambda間の共通エラーハンドリング | ✅ specの通りに生成 |
| 所有者チェック(403)ロジック | ✅ specの通りに生成 |
大枠の設計はspecの通りに出てくる。
というのが正直な感想です。
次の 後編 では、vibeコーディングとSpec駆動を改めて比較し、どう使い分けるかを考察します。