🧗‍♂️

API GatewayとDynamoDBで爆速なREST APIを構築してみた

2022/12/24に公開

ローカル環境

  • macOS 13.0.1
  • cdk 2.51.1
  • node 16.15.1

設計

「記事」のリソースが必要と仮定してAPIを構築します。

API仕様

メソッド エンドポイント 概要
GET /posts 記事一覧取得
GET /posts/{id} 記事取得
POST /posts/{id} 記事追加
PUT /posts/{id} 記事更新
DELETE /posts/{id} 記事削除

DB設計

PK 項目名
id String
title String
content String
createAt Number
updatedAt Number

AWS構成図


API Gatewayでは「統合リクエスト」「統合レスポンス」を定義します。

  • 統合リクエスト・・・DynamoDBにどのような操作(CRUD)をするのか定義します。
  • 統合レスポンス・・・クライアントが必要としているデータ形式に整形します。

実装

CDKはAWSリソースをデプロイするためのツールです。
本記事での詳細な説明は割愛します。

スタック

agw-integrate-ddb-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as iam from "aws-cdk-lib/aws-iam";
import reqGetPost from "../src/agw/reqTemplates/reqGetPost";
import resGetPost from "../src/agw/resTemplates/resGetPost";
import { HttpMethod } from "aws-cdk-lib/aws-events";
import reqListPost from "../src/agw/reqTemplates/reqListPost";
import resListPost from "../src/agw/resTemplates/resListPost";
import reqDeletePost from "../src/agw/reqTemplates/reqDeletePost";
import reqPutPost from "../src/agw/reqTemplates/reqPutPost";
import reqUpdatePost from "../src/agw/reqTemplates/reqUpdatePost";
import resDeletePost from "../src/agw/resTemplates/resDeletePost";
import resPutPost from "../src/agw/resTemplates/resPutPost";
import resUpdatePost from "../src/agw/resTemplates/resUpdatePost";

export class AgwIntegrateDdbStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // DynamoDBテーブル
    const ddbTable = new dynamodb.Table(this, "PostTable", {
      tableName: "Post",
      partitionKey: {
        name: "id",
        type: dynamodb.AttributeType.STRING,
      },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // API Gateway
    const restApi = new apigateway.RestApi(this, "RestApi", {
      restApiName: "api",
      cloudWatchRole: true,
      deployOptions: {
        loggingLevel: apigateway.MethodLoggingLevel.ERROR,
        metricsEnabled: true,
        dataTraceEnabled: true,
      },
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
      },
    });

    // API GatewayがDynamoDBにアクセスするためのIAMロール
    const credentialsRole = new iam.Role(this, "AccessibleDdbRole", {
      assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
    });

    // DynamoDBへのCRUD権限を付与
    ddbTable.grantReadWriteData(credentialsRole);

    // /posts/{id}のエンドポイントを作成する
    const posts = restApi.root.addResource("posts");
    const post = posts.addResource("{id}");

    // 【GET】/posts
    this.addMethod(
      posts,
      credentialsRole,
      HttpMethod.GET,
      dynamodb.Operation.SCAN,
      reqListPost,
      resListPost
    );

    // 【GET】/posts/{id}
    this.addMethod(
      post,
      credentialsRole,
      HttpMethod.GET,
      dynamodb.Operation.GET_ITEM,
      reqGetPost,
      resGetPost
    );
    // 【POST】/posts/{id}
    this.addMethod(
      post,
      credentialsRole,
      HttpMethod.POST,
      dynamodb.Operation.PUT_ITEM,
      reqPutPost,
      resPutPost
    );
    // 【PUT】/posts/{id}
    this.addMethod(
      post,
      credentialsRole,
      HttpMethod.PUT,
      dynamodb.Operation.UPDATE_ITEM,
      reqUpdatePost,
      resUpdatePost
    );
    // 【DELETE】/posts/{id}
    this.addMethod(
      post,
      credentialsRole,
      HttpMethod.DELETE,
      dynamodb.Operation.DELETE_ITEM,
      reqDeletePost,
      resDeletePost
    );
  }

  /**
   * API GatewayにHTTP Methodを追加する
   * @param resource メソッド追加対象のリソース
   * @param credentialsRole DynamoDBアクセス用のIAMロール
   * @param httpMethod 追加するHTTPメソッド
   * @param action DynamoDBの操作タイプ
   * @param requestTemplates 統合リクエストのテンプレート
   * @param responseTemplates  統合レスポンスのテンプレート
   */
  private addMethod(
    resource: apigateway.Resource,
    credentialsRole: iam.Role,
    httpMethod: HttpMethod,
    action: dynamodb.Operation,
    requestTemplates: {
      [contentType: string]: string;
    },
    responseTemplates: {
      [contentType: string]: string;
    }
  ) {
    resource.addMethod(
      httpMethod,
      new apigateway.AwsIntegration({
        service: "dynamodb",
        action,
        options: {
          credentialsRole,
          requestTemplates,
          integrationResponses: [
            {
              statusCode: "200",
              responseTemplates,
            },
          ],
        },
      }),
      {
        methodResponses: [
          {
            statusCode: "200",
          },
        ],
      }
    );
  }
}

DynamoDBへのアクセスの最小権限を一行で設定している部分や、使いまわす冗長なコードはメソッド化しているところがポイントです。

統合テンプレート

テンプレートはVTLエンジンで動作しています。
JavaScriptのような親しみのある言語がサポートされて欲しいですね。
意外と世に出回っているリファレンス少なく、試行錯誤で実装してみたので参考にしてみてください。

【GET】/posts

reqListPost.ts
export default {
  "application/json": `{
    "TableName": "Post"
}`,
};
resListPost.ts
export default {
  "application/json": `{
        #set($body=$input.path('$'))
        "data":[
            #foreach($item in $body.Items)
                {
                    "id": "$item.id.S",
                    "title": "$item.title.S",
                    "content": "$item.content.S",
                    "createdAt": "$item.createdAt.N",
                    "updatedAt": "$item.updatedAt.N"
                }
                #if($foreach.hasNext),#end
            #end
        ]
    }`,
};

【GET】/posts/{id}

reqGetPost.ts
export default {
  "application/json": `{
    "TableName": "Post",
    "Key": {
        "id": {
            "S": "$input.params('id')"
        }
    }
}`,
};
resGetPost.ts
export default {
  "application/json": `{
        #set($body=$input.path('$'))
        #if($body.Item.id.S!="")
            "id": "$body.Item.id.S",
            "title": "$body.Item.title.S",
            "content": "$body.Item.content.S",
            "createdAt": "$item.createdAt.N",
            "updatedAt": "$item.updatedAt.N"
        #end
    }`,
};

【POST】/posts/{id}

reqPutPost.ts
export default {
  "application/json": `{
    "TableName": "Post",
    "Item": {
        "id": {
            "S": "$input.params('id')"
        },
        "title": {
            "S": $input.json('$.title')
        },
        "content": {
            "S": $input.json('$.content')
        },
        "createdAt": {
            "N": "$context.requestTimeEpoch"
        },
        "updatedAt": {
            "N": "$context.requestTimeEpoch"
        }
    }
}`,
};
resPutPost.ts
export default {
    "application/json": `{}`
}

【PUT】/posts/{id}

reqUpdatePost.ts
export default {
  "application/json": `{
    "TableName": "Post",
    "Key": {
        "id": {
            "S": "$input.params('id')"
        }
    },
    "AttributeUpdates": {
        #set($body=$input.path('$'))
        #if($body.title != "")
        "title": {
            "Action":"PUT",
            "Value":{
                "S": "$body.title"
            }
        },
        #end
        #if($body.content != "")
        "content": {
            "Action":"PUT",
            "Value":{
                "S": "$body.content"
            }
        },
        #end
        "updatedAt": {
            "Action":"PUT",
            "Value":{
                "N": "$context.requestTimeEpoch"
            }
        }
    }
}`,
};
resUpdatePost.ts
export default {
    "application/json": `{}`
}

【DELETE】/posts/{id}

reqDeletePost.ts
export default {
  "application/json": `{
    "TableName": "Post",
    "Key": {
        "id": {
            "S": "$input.params('id')"
        }
    }
}`,
};
resDeletePost.ts
export default {
    "application/json": `{}`
}

まとめ

API GatewayとDynamoDBの間のLambdaをなくすことで、管理するリソースが減り、コストが削減され、パフォーマンスも向上します。
API統合が使える場面では積極的に採用していきたいですね。
ではまた👋

Discussion