テーマ
今回はAppSyncからVTL経由でDyamoDBをリクエストして、CRUD操作する実装をCDKで実装しました
簡単にキーワードを確認
AWS AppSync
サーバーレスのGraphQL APIを作成するサービス
AWS CDK
PythonやTypeScriptなどのプログラミング言語を使用して、AWSリソースを定義するツール
CloudFormationのテンプレートをプログラミング言語で出力することが出来る
VTL
VTL(Velocity Template Language)はテンプレート言語で今回はDynamoDBへのリクエストの操作設定などをここに実装していきます
イメージ

ディレクトリ構成
backend/
 ├ cdk_stacks/
 │    ├ appsync
 │    │   ├ vtl
 │    │   │  ├ create_user.vtl
 │    │   │  ├ delete_user.vtl
 │    │   │  ├ get_user.vtl
 │    │   │  ├ query_users_from_email.vtl
 │    │   │  ├ query_users_from_prefecture_prefix
 │    │   │  └ update_user.vtl
 │    │   └ schema.graphql
 │    ├ __init__.py
 │    └ backend_stack.py
 ├ app.py
 └ requirements.txtスキーマ設計
schemaファイルではUserのレコードを作り、emailやaddressで検索する設計にしました
type User {
  id: String!
  meta: String!
  name: String
  birthdate: String
  gender: String
  email: String
  phone_number: String
  postal_code: String
  address: String
  created_at: AWSTimestamp
  updated_at: AWSTimestamp
  ttl: AWSTimestamp
}
input CreateUserInput {
  name: String
  birthdate: String
  gender: String
  email: String
  phone_number: String
  postal_code: String
  address: String
}
input UpdateUserInput {
  name: String
  birthdate: String
  gender: String
  email: String
  phone_number: String
  postal_code: String
  address: String
  created_at: AWSTimestamp
}
input KeyInput {
  id: String!
  meta: String!
}
input EmailQueryInput {
  email: String!
}
input PrefecturePrefixQueryInput {
  prefecture_prefix: String!
}
type Query {
  getUser(key: KeyInput!): User!
  queryUsersFromEmail(query: EmailQueryInput): [User]
  queryUsersFromPrefecturePrefix(query: PrefecturePrefixQueryInput): [User]
}
type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(key: KeyInput!, input: UpdateUserInput!): User!
  deleteUser(key: KeyInput!): User!
}スタックファイル
以下がスタック定義のファイルです
テーマから外れるので説明は割愛していますが、 AppSyncのAuthorizationTypeはAuth0のOIDCに設定しています
from aws_cdk import (
    Stack,
    RemovalPolicy,
    aws_dynamodb as dynamodb,
    aws_appsync as appsync,
)
from constructs import Construct
import os
dirname = os.path.dirname(__file__)
backend_dirname = os.path.abspath(os.path.dirname(
    os.path.abspath(__file__)) + "/../../backend/")
class TechChallengeBackendStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        env = os.getenv('ENVIRONMENT', 'dev')
        if env == "dev":  # dev
            apiAuthorizerIssuer = "https://xxxxxxxxxxx.jp.auth0.com/"
        # DynamoDB
        ddb_table = dynamodb.Table(self, "Table",
                                   partition_key=dynamodb.Attribute(
                                       name="id", type=dynamodb.AttributeType.STRING),
                                   sort_key=dynamodb.Attribute(
                                       name="meta", type=dynamodb.AttributeType.STRING),
                                   billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
                                   time_to_live_attribute="ttl",
                                   removal_policy=RemovalPolicy.DESTROY
                                   )
        ddb_table.add_global_secondary_index(
            partition_key=dynamodb.Attribute(
                name="meta", type=dynamodb.AttributeType.STRING),
            sort_key=dynamodb.Attribute(
                name="email", type=dynamodb.AttributeType.STRING),
            index_name="meta-email-idx"
        )
        ddb_table.add_global_secondary_index(
            partition_key=dynamodb.Attribute(
                name="meta", type=dynamodb.AttributeType.STRING),
            sort_key=dynamodb.Attribute(
                name="address", type=dynamodb.AttributeType.STRING),
            index_name="meta-address-idx"
        )
        gq_api = appsync.GraphqlApi(self, "graphqlapi",
                                        name="AppSync",
                                        schema=appsync.SchemaFile.from_asset(
                                            os.path.join(dirname, "appsync/schema.graphql")),
                                        authorization_config=appsync.AuthorizationConfig(
                                            default_authorization=appsync.AuthorizationMode(
                                                authorization_type=appsync.AuthorizationType.OIDC,
                                                open_id_connect_config=appsync.OpenIdConnectConfig(
                                                    oidc_provider=apiAuthorizerIssuer,
                                                )
                                            )
                                        ),
                                        xray_enabled=True)
        ddb_ds = gq_api.add_dynamo_db_data_source("DynamoDBDataSourse", ddb_table)
        ddb_ds.create_resolver("getUserResolver",
            type_name="Query",
            field_name="getUser",
            request_mapping_template=appsync.MappingTemplate.from_file(os.path.join(dirname, "appsync/vtl/get_user.vtl")),
            response_mapping_template=appsync.MappingTemplate.dynamo_db_result_item()
        )
        ddb_ds.create_resolver("queryUsersFromEmailResolver",
            type_name="Query",
            field_name="queryUsersFromEmail",
            request_mapping_template=appsync.MappingTemplate.from_file(os.path.join(dirname, "appsync/vtl/query_users_from_email.vtl")),
            response_mapping_template=appsync.MappingTemplate.dynamo_db_result_list()
        )
        ddb_ds.create_resolver("queryUsersFromPrefecturePrefixResolver",
            type_name="Query",
            field_name="queryUsersFromPrefecturePrefix",
            request_mapping_template=appsync.MappingTemplate.from_file(os.path.join(dirname, "appsync/vtl/query_users_from_prefecture_prefix.vtl")),
            response_mapping_template=appsync.MappingTemplate.dynamo_db_result_list()
        )
        ddb_ds.create_resolver("createUserResolver",
            type_name="Mutation",
            field_name="createUser",
            request_mapping_template=appsync.MappingTemplate.from_file(os.path.join(dirname, "appsync/vtl/create_user.vtl")),
            response_mapping_template=appsync.MappingTemplate.dynamo_db_result_item()
        )
        ddb_ds.create_resolver("updateUserResolver",
            type_name="Mutation",
            field_name="updateUser",
            request_mapping_template=appsync.MappingTemplate.from_file(os.path.join(dirname, "appsync/vtl/update_user.vtl")),
            response_mapping_template=appsync.MappingTemplate.dynamo_db_result_item()
        )
        ddb_ds.create_resolver("deleteUserResolver",
            type_name="Mutation",
            field_name="deleteUser",
            request_mapping_template=appsync.MappingTemplate.from_file(os.path.join(dirname, "appsync/vtl/delete_user.vtl")),
            response_mapping_template=appsync.MappingTemplate.dynamo_db_result_item()
        )VTLファイル
挙動が確認できたVTLファイルです
PutItem
・新規作成パターン
idに自動でULIDを付与しています
{
  "version": "2017-02-28",
  "operation": "PutItem",
  "key" : {
    "id" : $util.dynamodb.toDynamoDBJson($util.autoUlid()),
    "meta" : $util.dynamodb.toDynamoDBJson("User")
  },
  "attributeValues": $util.dynamodb.toMapValuesJson({
    "name": $ctx.args.input.name,
    "birthdate": $ctx.args.input.birthdate,
    "gender": $ctx.args.input.gender,
    "email": $ctx.args.input.email,
    "phone_number": $ctx.args.input.phone_number,
    "postal_code": $ctx.args.input.postal_code,
    "address": $ctx.args.input.address,
    "created_at": $util.time.nowEpochSeconds()
  })
}・更新パターン
idは既存のレコードのものを指定して更新処理を行なっています
{
  "version": "2017-02-28",
  "operation": "PutItem",
  "key" : {
    "id" : $util.dynamodb.toDynamoDBJson($ctx.args.key.id),
    "meta" : $util.dynamodb.toDynamoDBJson($ctx.args.key.meta)
  },
  "attributeValues": $util.dynamodb.toMapValuesJson({
    "id": $ctx.args.key.id,
    "meta": $ctx.args.key.meta,
    "name": $ctx.args.input.name,
    "birthdate": $ctx.args.input.birthdate,
    "gender": $ctx.args.input.gender,
    "email": $ctx.args.input.email,
    "phone_number": $ctx.args.input.phone_number,
    "postal_code": $ctx.args.input.postal_code,
    "address": $ctx.args.input.address,
    "created_at": $ctx.args.input.created_at,
    "updated_at": $util.time.nowEpochSeconds()
  })
}DeleteItem
・レコードの削除
{
  "version": "2017-02-28",
  "operation": "DeleteItem",
  "key" : {
    "id" : $util.dynamodb.toDynamoDBJson($ctx.args.key.id),
    "meta" : $util.dynamodb.toDynamoDBJson($ctx.args.key.meta)
  }
}GetItem
・レコードの取得
{
  "version": "2017-02-28",
  "operation": "GetItem",
  "key" : {
    "id" : $util.dynamodb.toDynamoDBJson($ctx.args.key.id),
    "meta" : $util.dynamodb.toDynamoDBJson($ctx.args.key.meta)
  }
}Query
・emailが一致するレコードリストを取得
{
  "version": "2017-02-28",
  "operation": "Query",
  "index": "meta-email-idx",
  "query" : {
    "expression" : "meta=:meta AND email=:email",
    "expressionValues" : {
      ":meta": $util.dynamodb.toDynamoDBJson("User"),
      ":email": $util.dynamodb.toDynamoDBJson($ctx.args.query.email)
    }
  }
}・addressの前方一致でレコードリストを取得
{
  "version": "2017-02-28",
  "operation": "Query",
  "index": "meta-address-idx",
  "query" : {
    "expression" : "meta=:meta AND begins_with(address, :prefecture_prefix)",
    "expressionValues" : {
      ":meta": $util.dynamodb.toDynamoDBJson("User"),
      ":prefecture_prefix": $util.dynamodb.toDynamoDBJson($ctx.args.query.prefecture_prefix)
    }
  }
}検証
PostmanやAWSコンソールから以下のクエリで実行します
AWSコンソールでは、
AWS AppSync >> API選択 >> クエリ
と進み、Authorization TokenにAuth0からaccess_tokenを発行して貼り付けます
access_tokenはAuth0コンソール
APIs >> API選択 >> Testタブ
Response項目のaccess_tokenから得ることができます
mutation CreateMutation {
  createUser(
    input: {
      name: "田中 太郎",
      address: "東京都新宿区中落合1-2-3クレオ123",
      birthdate: "1973-04-12",
      email: "tanaka123@example.co.jp",
      gender: "男",
      phone_number: "03-1234-5678",
      postal_code: "123-7934"
    }
  ) {
    address
    birthdate
    created_at
    email
    gender
    id
    meta
    phone_number
    postal_code
  }
}
mutation updateMutation {
  updateUser(
    key: {
      id: "01GR0A6322T20RKAWGXNT3QMBT",
      meta: "User",
    },
    input: {
      name: "田中 太郎",
      address: "東京都新宿区中落合1-2-3クレオ123",
      birthdate: "1973-04-12",
      email: "tanaka123++@example.co.jp",
      gender: "男",
      phone_number: "03-1234-5678",
      postal_code: "123-7934",
      created_at: 1675047930
    }
  ) {
    id
    meta
    address
    birthdate
    email
    gender
    phone_number
    postal_code
    created_at
    updated_at
  }
}
query GetQuery {
  getUser(
    key: {
      id: "01GR0A6322T20RKAWGXNT3QMBT",
      meta: "User",
    }
  ) {
    id
    meta
    address
    birthdate
    email
    gender
    phone_number
    postal_code
    created_at
    updated_at
  }
}
query QueyUsersFromEmail {
  queryUsersFromEmail(
    query: {
      email: "tanaka123++@example.co.jp",
    }
  ) {
    id
    meta
    address
    birthdate
    email
    gender
    phone_number
    postal_code
    created_at
    updated_at
  }
}
query QueryUsersFromPrefecturePrefix {
  queryUsersFromPrefecturePrefix(
    query: {
      prefecture_prefix: "東京都",
    }
  ) {
    id
    meta
    address
    birthdate
    email
    gender
    phone_number
    postal_code
    created_at
    updated_at
  }
}
mutation deleteMutation {
  deleteUser(
    key: {
      id: "01GR0A6322T20RKAWGXNT3QMBT",
      meta: "User",
    }
  ) {
    id
    meta
    address
    birthdate
    email
    gender
    phone_number
    postal_code
    created_at
    updated_at
  }
}Errorなくレスポンスが返ってくれば成功です
