DynamoDBにアクセスするAppSyncをCloudFormationで作る

DynamoDBにアクセスするAppSyncをCloudFormationで作る
この記事をシェアする

はじめに

クラウドビルダーズのhijikuroです。
この記事はクラウドビルダーズ Advent Calendar 2024の15日目の記事です。
クラウドビルダーズのメンバーでチャレンジしているカレンダーとなっております。
他の記事も是非チェックしてみてください!

プロジェクトでAppSyncを使っていたのですが、ずっとマネジメントコンソールで構築していて、結局IaC化できずでした。ということで、反省を兼ねて記事にしました。
マネジメントコンソールで構築するパターンとCloudFormationで構築するパターンでやってみました。

やってみる

事前準備

DynamoDBを下記の設定で作成します。(AppSyncコンソールで一緒に作ることもできます。)

マネジメントコンソールでAppSyncを作る

AppSyncのコンソールから「APIを作成」をクリックし、「GraphQL API」を選択します。

「GraphQL API データソース」の「DynamoDBテーブルからはじめる」をチェックし次へ。

API名を入力し、作成済みのDynamoDBのリージョン、テーブル名を選択し次へ進みます。

「モデル名」を入力します。ここで入力した名前はAPIの名称に含まれるので、「ToDo」としておきます。
フィールドで「id」のタイプを「ID」にしておきます。
その他の項目はすべて「String」で追加します。
内容に問題なければ、確認画面へ進み、そのまま作成します。

マネジメントコンソールで構築するとよくわからないのですが、この時にスキーマやリゾルバーも作成してくれてます。

APIが作成されたら、コンソールのクエリ画面からテスト実行してみます。
「実行する」ボタンを押して、「createToDo」を選択すると、クエリが実行されます。
(DynamoDB側にもデータが入っているはずです。)

余談

複数回AppSyncを構築する場合、項目を手動で追加する手順がしんどかったです。入力を間違えるリスクもあります…
また、データソースを追加する場合は、下記画像のように直接入力できるので少し楽になるのですが、それでもデータソースが多いとマネジメントコンソールでは設定に時間がかかります。

CloudFormationでAppSyncを作る

次にCloudFormationでAppSyncを作ってみます。
使用したテンプレートは下記です。長いです。
スキーマと各リゾルバーはマネジメントコンソールで構築されたものから抜き出しました。

AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  TableName:
    Description: dynamoDB table name
    Type: String
    Default: ToDoTable

Resources:
  GraphQLApi:
    Type: AWS::AppSync::GraphQLApi
    Properties:
      AuthenticationType: API_KEY
      Name: ToDo API v2
  
  ApiKey:
    Type: AWS::AppSync::ApiKey
    Properties:
      ApiId: !GetAtt GraphQLApi.ApiId
      Expires: 1734447600 # UNIX時間で有効期限を設定

  AppSyncRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: ToDoAPIv2Role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Action:
          - sts:AssumeRole
          Principal:
            Service:
            - appsync.amazonaws.com
      Path: '/'
      Policies:
      - PolicyName: ToDoAPIv2Policy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - dynamodb:PutItem
            - dynamodb:DeleteItem
            - dynamodb:GetItem
            - dynamodb:Scan
            - dynamodb:Query
            - dynamodb:UpdateItem
            Resource: 
            - !Sub 'arn:aws:dynamodb:ap-northeast-1:${AWS::AccountId}:table/${TableName}'
            - !Sub 'arn:aws:dynamodb:ap-northeast-1:${AWS::AccountId}:table/${TableName}/*'

  DataSource:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt GraphQLApi.ApiId
      Name: !Sub ${TableName}
      Type: AMAZON_DYNAMODB
      ServiceRoleArn: !GetAtt AppSyncRole.Arn
      DynamoDBConfig:
        AwsRegion: ap-northeast-1
        TableName: !Sub ${TableName}

  GraphQLSchema:
    Type: AWS::AppSync::GraphQLSchema
    Properties:
      ApiId: !GetAtt GraphQLApi.ApiId
      Definition: |
        input CreateToDoInput {
          name: String
          when: String
          where: String
          description: String
        }

        input DeleteToDoInput {
          id: ID!
        }

        input ModelSizeInput {
          ne: Int
          eq: Int
          le: Int
          lt: Int
          ge: Int
          gt: Int
          between: [Int]
        }

        input TableBooleanFilterInput {
          ne: Boolean
          eq: Boolean
          attributeExists: Boolean
        }

        input TableFloatFilterInput {
          ne: Float
          eq: Float
          le: Float
          lt: Float
          ge: Float
          gt: Float
          between: [Float]
          attributeExists: Boolean
        }

        input TableIDFilterInput {
          ne: ID
          eq: ID
          le: ID
          lt: ID
          ge: ID
          gt: ID
          contains: ID
          notContains: ID
          between: [ID]
          beginsWith: ID
          attributeExists: Boolean
          size: ModelSizeInput
        }

        input TableIntFilterInput {
          ne: Int
          eq: Int
          le: Int
          lt: Int
          ge: Int
          gt: Int
          between: [Int]
          attributeExists: Boolean
        }

        input TableStringFilterInput {
          ne: String
          eq: String
          le: String
          lt: String
          ge: String
          gt: String
          contains: String
          notContains: String
          between: [String]
          beginsWith: String
          attributeExists: Boolean
          size: ModelSizeInput
        }

        input TableToDoFilterInput {
          id: TableIDFilterInput
          name: TableStringFilterInput
          when: TableStringFilterInput
          where: TableStringFilterInput
          description: TableStringFilterInput
        }

        type ToDo {
          id: ID!
          name: String
          when: String
          where: String
          description: String
        }

        type ToDoConnection {
          items: [ToDo]
          nextToken: String
        }

        input UpdateToDoInput {
          id: ID!
          name: String
          when: String
          where: String
          description: String
        }

        type Mutation {
          createToDo(input: CreateToDoInput!): ToDo
          updateToDo(input: UpdateToDoInput!): ToDo
          deleteToDo(input: DeleteToDoInput!): ToDo
        }

        type Query {
          getToDo(id: ID!): ToDo
          listToDos(filter: TableToDoFilterInput, limit: Int, nextToken: String): ToDoConnection
        }

        type Subscription {
          onCreateToDo(
            id: ID,
            name: String,
            when: String,
            where: String,
            description: String
          ): ToDo
            @aws_subscribe(mutations: ["createToDo"])
          onUpdateToDo(
            id: ID,
            name: String,
            when: String,
            where: String,
            description: String
          ): ToDo
            @aws_subscribe(mutations: ["updateToDo"])
          onDeleteToDo(
            id: ID,
            name: String,
            when: String,
            where: String,
            description: String
          ): ToDo
            @aws_subscribe(mutations: ["deleteToDo"])
        }

  ResolverGetToDo:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt GraphQLApi.ApiId
      DataSourceName: !GetAtt DataSource.Name
      TypeName: 'Query'
      FieldName: 'getToDo'
      Kind: UNIT
      Runtime:
        Name: APPSYNC_JS
        RuntimeVersion: 1.0.0
      Code: |
        import { util } from '@aws-appsync/utils';
        import { get } from '@aws-appsync/utils/dynamodb';

        export function request(ctx) {
          const { id } = ctx.args;
          const key = { id };
          return get({
            key,
          })
        }

        export function response(ctx) {
          const { error, result } = ctx;
          if (error) {
              return util.appendError(error.message, error.type, result);
          }
          return result;
        }

  ResolverListToDos:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt GraphQLApi.ApiId
      DataSourceName: !GetAtt DataSource.Name
      TypeName: 'Query'
      FieldName: 'listToDos'
      Kind: UNIT
      Runtime:
        Name: APPSYNC_JS
        RuntimeVersion: 1.0.0
      Code: |
        import { util } from '@aws-appsync/utils';
        import { scan } from '@aws-appsync/utils/dynamodb';

        export function request(ctx) {
          const { filter, limit, nextToken } = ctx.args;
          
          return scan({
              limit,
              nextToken,
              filter,
          })
        }

        export function response(ctx) {
          const { error, result } = ctx;
          if (error) {
              return util.appendError(error.message, error.type, result);
          }
          const { items = [], nextToken } = result;
          return { items, nextToken };
        }

  ResolverCreateToDo:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt GraphQLApi.ApiId
      DataSourceName: !GetAtt DataSource.Name
      TypeName: 'Mutation'
      FieldName: 'createToDo'
      Kind: UNIT
      Runtime:
        Name: APPSYNC_JS
        RuntimeVersion: 1.0.0
      Code: |
        import { util } from '@aws-appsync/utils';
        import { put } from '@aws-appsync/utils/dynamodb';

        export function request(ctx) {
          const { input: values } = ctx.args;
          const key = { id: util.autoId() };
          const condition = { and: [{ id: { attributeExists: false } }] };
          
          return put({
              key,
              item: ctx.args.input,
              condition,
          })
        }

        export function response(ctx) {
          const { error, result } = ctx;
          if (error) {
              return util.appendError(error.message, error.type, result);
          }
          return result;
        }

  ResolverUpdateToDo:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt GraphQLApi.ApiId
      DataSourceName: !GetAtt DataSource.Name
      TypeName: 'Mutation'
      FieldName: 'updateToDo'
      Kind: UNIT
      Runtime:
        Name: APPSYNC_JS
        RuntimeVersion: 1.0.0
      Code: |
        import { util } from '@aws-appsync/utils';
        import { update } from '@aws-appsync/utils/dynamodb';

        export function request(ctx) {
          const { id, ...values } = ctx.args.input;
          const key = { id };
          const condition = {};
          for (const k in key) {
              condition[k] = { attributeExists: true };
          }
          
          return update({
              key,
              update: values,
              condition,
          })
        }

        export function response(ctx) {
          const { error, result } = ctx;
          if (error) {
              return util.appendError(error.message, error.type, result);
          }
          return result;
        }

  ResolverDeleteToDo:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt GraphQLApi.ApiId
      DataSourceName: !GetAtt DataSource.Name
      TypeName: 'Mutation'
      FieldName: 'deleteToDo'
      Kind: UNIT
      Runtime:
        Name: APPSYNC_JS
        RuntimeVersion: 1.0.0
      Code: |
        import { util } from '@aws-appsync/utils';
        import { remove } from '@aws-appsync/utils/dynamodb';

        export function request(ctx) {
          const { id } = ctx.args.input;
          const key = { id };
          return remove({
              key,
          })
        }

        export function response(ctx) {
          const { error, result } = ctx;
          if (error) {
              return util.appendError(error.message, error.type, result);
          }
          return result;
        }

上記でAppSyncを構築後、AppSyncコンソールからlistクエリを実行できました。

おわりに

実際にCloudFormationでAppSyncを構築してみての感想ですが、どうしてもスキーマやリゾルバーの記述でコードが長くなってしまいます。「CodeS3Location」というパラメータでS3に置いたコードのファイルを指定することもできますが、S3にアップロードしてテンプレートと別管理というのも微妙かなと思います。ですので、結局CDKを使うのが一番楽な気がします。参考ドキュメント

AppSyncをはじめて触るような場合はスキーマもリゾルバーも書き方がよく分からないと思うので、まずはマネジメントコンソールで構築するのはおすすめです。ただし、設定内容が決まってきたら、IaC化なりCLIで手順を用意するなりしたほうが良いと思います。(でないと、移行する時に構築が大変…)

AppSync自体はDynamoDBだけでなく、RDSだったりLambdaだったり色々なものと接続でき、使いこなせればとても便利だと思うので、引き続きキャッチアップしていきたいです。(最近出たAppSync Event APIも試してみたい。)

この記事をシェアする
著者:hijikuro
AWS歴3年目になりました!Amazon Connect勉強中です!