ServerlessFramework＋AppSyncで、マッピングテンプレート（VTLファイル）レスなGraphQL構成を実現する

# はじめに

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22893/8f06826c-dfc5-c857-dfe4-e7aa07faafcc.png" width=500 alt="AppSync-Serverless">

AppSyncは、GraphQLの開発を容易にする、AWSが提供するマネージドサービスです。
ServerlessFrameworkでもAppSyncに対応したプラグインが存在し、それらを利用することで、GraphQLを簡単にサービスに取り込むことが可能になります。

- [AWS AppSync ースケーラブルな GraphQL API を使用してアプリケーション開発を加速するー](https://aws.amazon.com/jp/appsync/)

典型的な方法として、AppSyncのリゾルバー（GraphQLのAPIとデータソースを連携させるための処理）では Apache Velocity Template Language (VTL) で記述されたマッピングテンプレートを利用します。
ただ、すでにAppSyncを利用している人は実感があるかもしれませんが、このマッピングテンプレートで、複雑な処理をしようとすると、なかなか苦労します。

定義するだけでDynamoDBへのアクセスを実現できるのは良いのですが、実際のサービス開発となると、単にDynamoDBへアクセスするだけではなく、複雑なバリデーションを行ったり、処理完了後に通知を行ったりすることが必要となり、その都度調査しながら、VTLファイルを作成するのが大変に感じる状況がありました。

AppSyncは、データソースとして、DynamoDBだけでなく、Lambdaも利用することができます。
そこで今回は、AppSyncから直接DynamoDBにはアクセスせず、Lambdaを利用するようにすることで、マッピングテンプレート（VTLファイル）レスなGraphQL構成を実現したいと思います。
サービスの内容は、シンプルなTODO管理です。


# ファイル構成

全体を把握しやすくするため、最初にファイル構成を示します。

```
├── customs
│   ├── appsync-dev.yml     ・・・ AppSyncの内容における環境ごとの定義内容
│   └── dev.yml             ・・・ Lambdaやその他のリソースにおける環境ごとの定義内容
├── resources
│   ├── serverless-cognito.yml
│   └── serverless-dynamodb.yml
├── appsync
│   ├── appsync.yml         ・・・ serverless-appsync-pluginの定義内容
│   ├── schemas
│   │   └── todos.graphql   ・・・ GraphQLのスキーマ定義
│   └── serverless.yml      ・・・ AppSyncに関するServerlessFramework定義
├── common
│   ├── appsync_util.py
│   └── timestamp_util.py
│── todos
│       ├── domain
│       │   └── todo_service.py
│    ├── todo_handler.py   ・・・ Lambdaのハンドラ
│    └── serverless.yml    ・・・ Lambdaに関するServerlessFramework定義
└── requirements.txt
```

ServerlessFrameworkを利用してデプロイできるようにしていますが、Lambdaの定義と、AppSync関連の定義を分けています。
今回、ServerlessFramework自体の定義方法や利用方法については、説明を割愛しますが、おおよそ以下のようにしています。

- Lambdaは、今回Pythonを利用して実装しています。Lambda関数の内容は `todos` の配下になります。
- AppSyncの内容は `appsync` の配下になります。

詳細については、以下のリポジトリに関連ファイル一式を登録していますので、そちらを参照してください。

- https://github.com/takanorig/serverless-appsync-todoapp/tree/main/backend



# Lambdaの内容

Lambdaに関する定義は、以下のファイルに別れます。

- customs/dev.yml  ・・・ 環境ごとに定義する内容です。
- todos/serverless.yml  ・・・ ServerlessFrameworkのデプロイ用の定義です。

## customs/dev.yml

`dev.yml` の内容は、AWSプロファイルの定義や、全体で共通となる定義を行っています。
AppSyncとは直接関係はありませんが、デプロイサイズを小さくして高速化できるよう、 [Serverless Python Requirements プラグイン](https://www.serverless.com/plugins/serverless-python-requirements) を利用して、[Lambda Layers](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-layers.html) を利用できるようにしています。

```yaml:customs/dev.yml
deployRegion: us-west-2
profile: todoapp-dev
runtime: python3.8

prop:
  retentionInDays: 3
  timeout: 30
  memorySize: 256

layers:
  - ${cf:todoapp-requirements-layer-dev.PythonRequirementsLambdaLayerQualifiedArn}
```

## todos/serverless.yml

`serverless.yml` は、Lambda関数の定義をしています。
ServerlessFrameworkにおける通常のLambda定義と同じ内容ですが、AppSyncから呼び出される際は [events](https://www.serverless.com/framework/docs/providers/aws/events/) の定義は不要です。
また、関数名（ここでは、「findTodoList」や「addTodo」など）は、別途AppSyncから参照される名前になりますので、わかりやすい名前をつけておくと良いでしょう。

```yaml:todos/serverless.yml
custom: ${file(../customs/${opt:stage, 'dev'}.yml)}

service: todoapp-todos

provider:
  name: aws
  runtime: ${self:custom.runtime}
  stage: ${opt:stage, 'dev'}
  region: ${self:custom.deployRegion}
  profile: ${self:custom.profile}
  timeout: ${self:custom.prop.timeout}
  memorySize: ${self:custom.prop.memorySize}
  iam:
    role:
      statements:
        - Effect: 'Allow'
          Action:
            - "xray:PutTraceSegments"
            - "xray:PutTelemetryRecords"
            - "dynamodb:GetItem"
            - "dynamodb:PutItem"
            - "dynamodb:UpdateItem"
            - "dynamodb:DeleteItem"
            - "dynamodb:Query"
            - "dynamodb:Scan"
          Resource: '*'

package:
  patterns:
    - '../common/**'
    - '!tests/**'

plugins:
  - serverless-prune-plugin

functions:
  findTodoList:
    handler: todo_handler.find_todolist_handler
    layers: ${self:custom.layers}
  findTodo:
    handler: todo_handler.find_todo_handler
    layers: ${self:custom.layers}
  addTodo:
    handler: todo_handler.add_todo_handler
    layers: ${self:custom.layers}
  updateTodo:
    handler: todo_handler.update_todo_handler
    layers: ${self:custom.layers}
  deleteTodo:
    handler: todo_handler.delete_todo_handler
    layers: ${self:custom.layers}
```

## Lambda関数の実装（Python）

Lambda関数の実装内容です。

AppSyncからのイベントは、JSONの内容をdictに変換した内容として受信されます（eventパラメータ）。
GraphQLのメッセージは、 `arguments` 配下に格納されますので、そこからメッセージのパラメータを取得するようにしています。

```python
todo_id = event.get('arguments', {}).get('todo_id')
```

一覧表示で、条件を指定してのフィルタリングを行えるようにしているので、 `find_todolist_handler`関数の内容は少し複雑になっています。
この内容については、GraphQLのスキーマ定義に合わせて、データをパースできるようにしています。

また、データの格納にはDynamoDBを利用していますが、処理の内容は `todo_service.py` に実装しています。
この内容自体は、AppSyncには直接関係はないため、今回は説明を割愛しますが、具体的な実装内容はリポジトリを参照してください。
DynamoDBに対して、動的にフィルター条件を指定できるように工夫していたりします。

```python:todos/todo_handler.py
import logging

from common.appsync_util import FilterInput

from domain.todo_service import TodoService

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


def find_todolist_handler(event, context):
    logger.info(event)

    filters = event.get('arguments', {}).get('filters')
    next_token = event.get('arguments', {}).get('next_token')

    filter_options = []
    if (filters is not None) and (len(filters) > 0):
        for filter in filters:
            filter_attr = list(filter.keys())[0]
            condition_dict = filter[filter_attr]
            condition_fn = list(condition_dict.keys())[0]
            condition_value = condition_dict[condition_fn]

            filter_input = FilterInput(attr=filter_attr, condition=condition_fn, value=condition_value)
            filter_options.append(filter_input)

    todo_service = TodoService()
    todolist, next_token = todo_service.find_todolist(filters=filter_options, next_token=next_token)

    response = {
        'todos': todolist,
        'next_token': next_token
    }
    return response


def find_todo_handler(event, context):
    todo_id = event.get('arguments', {}).get('todo_id')
    todo_service = TodoService()
    todo = todo_service.find_todo(todo_id)
    return todo


def add_todo_handler(event, context):
    logger.info(event)

    todo_params = event.get('arguments', {}).get('todo')
    todo_service = TodoService()
    todo = todo_service.add_todo(todo_params)
    return todo


def update_todo_handler(event, context):
    logger.info(event)

    todo_params = event.get('arguments', {}).get('todo')
    todo_service = TodoService()
    todo = todo_service.update_todo(todo_params)
    return todo


def delete_todo_handler(event, context):
    todo_id = event.get('arguments', {}).get('todo_id')
    todo_service = TodoService()
    deleted = todo_service.delete_todo(todo_id)
    return deleted
```

# AppSyncの内容（ServerlessFramework関連）

AppSyncについては、まずServerlessFrameworkに関する内容について説明します。
定義内容は、以下のファイルに別れます。

- customs/appsync-dev.yml ・・・ 環境ごとに定義する内容です。
- appsync/appsync.yml     ・・・ AppSyncの定義内容です。
- appsync/serverless.yml  ・・・ ServerlessFrameworkのデプロイ用の定義です。


## customs/appsync-dev.yml

Lambdaに関する `dev.yml` と同じような内容ですが、AppSyncに関する定義の構成上、同一のファイルにしにくかったので、ファイルを分けています。

```yaml:customs/appsync-dev.yml
deployRegion: us-west-2
accountId: ${opt:accountId, ''}
profile: todoapp-dev

appSync: ${file(../appsync/appsync.yml)}

# Cognito
cognito:
  userPoolId: us-west-2_XXXXXXXXXX
  clientId: XXXXXXXXXXXXXXXXXXX
  authorizer:
    name: todoapp-authorizer
    arn: arn:aws:cognito-idp:{self:deployRegion}:{self:accountId}:userpool/uus-west-2_XXXXXXXXXX
```

#### 定義のポイント

この内容では、

```yaml
appSync: ${file(../appsync/appsync.yml)}`
```

のように定義して、AppSyncに関する定義を分割した上で、参照できるようにしています。

## appsync/appsync.yml
AppSyncの定義は、[Serverless Appsync Plugin プラグイン](https://www.serverless.com/plugins/serverless-appsync-plugin) を利用して行います。
このファイルは、前述した `customs/appsync-dev.yml` で include されます。


```yaml:appsync/appsync.yml
- name: "todoapp-endpoint-${opt:stage, 'dev'}"
  schema:
    - ./schemas/todos.graphql
  authenticationType: AMAZON_COGNITO_USER_POOLS
  userPoolConfig:
    defaultAction: ALLOW
    awsRegion: ${self:custom.deployRegion}
    userPoolId: ${self:custom.cognito.userPoolId}
  defaultMappingTemplates:
    request: false
    response: false
  mappingTemplatesLocation: ./mapping-templates
  # クエリ／リゾルバー
  mappingTemplates:
    # タスク情報を複数件取得する
    - dataSource: findTodolistFn
      type: Query
      field: listTodos
    # タスク情報を1件取得する
    - dataSource: findTodoFn
      type: Query
      field: getTodo
    # タスクを登録する
    - dataSource: addTodoFn
      type: Mutation
      field: addTodo
    # タスクを更新する
    - dataSource: updateTodoFn
      type: Mutation
      field: updateTodo
    # タスクを削除する
    - dataSource: deleteTodoFn
      type: Mutation
      field: deleteTodo
  # データソース
  dataSources:
    - type: AMAZON_DYNAMODB
      name: todoTable
      description: TODO
      config:
        tableName: todo
        serviceRoleArn: { Fn::GetAtt: [AppSyncDynamoDBServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: findTodolistFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.FindTodoListLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: findTodoFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.FindTodoLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: addTodoFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.AddTodoLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: updateTodoFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.UpdateTodoLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: deleteTodoFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.DeleteTodoLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
  xrayEnabled: true
```

#### 定義のポイント

定義のポイントをいくつか説明します。

```yaml
  schema:
    - ./schemas/todos.graphql
```

GraphQLのスキーマ定義です。複数指定することも可能です。

```yaml
  userPoolConfig:
    defaultAction: ALLOW
    awsRegion: ${self:custom.deployRegion}
    userPoolId: ${self:custom.cognito.userPoolId}
```

GraphQLのAPIの認証に、Cognitoのユーザープールを利用するので、その指定をしています。

```yaml
  defaultMappingTemplates:
    request: false
    response: false
```

GraphQLのリゾルバーのマッピングテンプレートの指定です。
個々のリゾルバーのマッピング（mappingTemplates）でも指定可能ですが、今回、Lambdaを利用するので、デフォルトで false を指定しています。
AppSyncから、Lambdaを直接呼び出す場合は、 request/response 共に false を指定してください。

```yaml
  mappingTemplates:
    # タスク情報を複数件取得する
    - dataSource: findTodolistFn
      type: Query
      field: listTodos
```

個々のリゾルバーのマッピングテンプレートです。
`dataSource` には、後述のデータソースの名前を指定してください。
`type` は `Query` か `Mutation`、`Subscription` を指定します。 `field` は関数に応じたフィールド名を指定します。

```yaml
  dataSources:
    - type: AWS_LAMBDA
      name: findTodolistFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.FindTodoListLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
```

リゾルバーから指定されるAppSyncのデータソースです。
Lambdaの場合は `type` に `AWS_LAMBDA` を指定してください。
`name` は、マッピングテンプレートで指定されるデータソースの名称です。`config` には、LambdaのARN、および、それを実行するためのロールを指定します。

`${cf:todoapp-todos-dev.FindTodoListLambdaFunctionQualifiedArn}` という定義は、ServerlessFrameworkでデプロイした際に、別にデプロイされたCloudFormationのLambdaのARNを参照します。

ServerlessFrameworkを利用して、Lambda関数のデプロイをした上で、
AWSコンソールから、CloudFormationのスタック情報を確認すると、以下のようなキーで登録されていることが分かります。

![lambda-cfn.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22893/0244bc5e-c741-3bf5-89ec-4b91012128c3.jpeg)

上記の形式ではなく、以下のようなARNを指定することも可能です。
`${cf:ARNキー}` で指定した場合は、バージョンまで自動で指定されますが、以下の場合はバージョンが含まれないため、常に最新のデプロイバージョンのLambdaが適用されます。

```yml
lambdaFunctionArn: arn:aws:lambda:${リージョン}:${アカウtのID}:function:todoapp-todos-dev-{Lambdaの関数名}
```

また、`{ Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }` というロールのARNは、次で説明するAppSyncの serverless.yml で定義をしています。

`appsync.yml` は、最終的に `serverless.yml` に include されるため、その中に定義されているロールの定義を参照しています。


## appsync/serverless.yml

AppSyncの内容をデプロイするためのServerlessFrameworkの定義です。
`appsync-dev.yml`、`appsync.yml` は、最終的にこのファイルに include されます。

ここでは、AppSyncから、DynamoDB／Lambdaにアクセスするためのロールを定義していますが、今回の内容では、DynamoDB へは直接連携しないので、削除しても問題ありません。

```yaml:appsync/serverless.yml
custom: ${file(../customs/appsync-${opt:stage, 'dev'}.yml)}

service: todoapp-appsync

provider:
  name: aws
  stage: ${opt:stage, 'dev'}
  region: ${self:custom.deployRegion}
  profile: ${self:custom.profile}

plugins:
  - serverless-appsync-plugin

resources:
  Resources:
    # AppSync が DynamoDB を操作するためのロール
    AppSyncDynamoDBServiceRole:
      Type: "AWS::IAM::Role"
      Properties:
        RoleName: ${self:service}-dynamodb-role-${opt:stage, self:provider.stage}
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: "Allow"
              Principal:
                Service:
                  - "appsync.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Policies:
          - PolicyName: "${self:service}-dynamo-policy-${opt:stage, self:provider.stage}"
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: "Allow"
                  Action:
                    - "dynamodb:GetItem"
                    - "dynamodb:PutItem"
                    - "dynamodb:UpdateItem"
                    - "dynamodb:DeleteItem"
                    - "dynamodb:BatchGetItem"
                    - "dynamodb:BatchWriteItem"
                    - "dynamodb:Query"
                    # - "dynamodb:Scan"
                  Resource:
                    - "*"
    # AppSync が Lambda を操作するためのロール
    AppSyncLambdaServiceRole:
      Type: "AWS::IAM::Role"
      Properties:
        RoleName: ${self:service}-lambda-role-${opt:stage, self:provider.stage}
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: "Allow"
              Principal:
                Service:
                  - "appsync.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Policies:
          - PolicyName: "${self:service}-lambda-policy-${opt:stage, self:provider.stage}"
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: "Allow"
                  Action:
                    - "lambda:invokeFunction"
                  Resource:
                    - "*"
```


# AppSyncの内容（GraphQLのスキーマ定義）

GraphQLのスキーマ定義です。

`type` として、1件のデータを示す `Todo` と、一覧取得用の `TodoList` を定義しています。
スキーマの定義内容は、以下が参考になると思います。AppSyncの場合、`AWSDataTime` などのカスタムスカラー型も利用できます。

Query や Mutaion の内容は、サービスとして利用したい内容に応じて定義してください。
ここでは、基本的なCRUDの内容を定義しており、それぞれの内容がLambda関数と連携されるようになっています。

- https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/designing-your-schema.html

```graphql:appsync/schema/todos.graphql
type Todo {
  todo_id: ID!
  title: String!
  description: String
  status: String
  assignee: String
  created_at: AWSDateTime
  updated_at: AWSDateTime
}

type TodoList {
  todos: [Todo]
  next_token: String
}

type Query {
  listTodos(filters: [ModelTodoFilterInput], next_token: String): TodoList
  getTodo(todo_id: ID!): Todo
}

type Mutation {
  addTodo(todo: TodoInput!): Todo
  updateTodo(todo: TodoInput!): Todo
  deleteTodo(todo_id: ID!): Boolean
}

input TodoInput {
  todo_id: ID
  title: String
  description: String
  status: String
  assignee: String
}

input ModelTodoFilterInput {
    title: ModelStringInput
    status: ModelStringInput
    assignee: ModelStringInput
}

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


# デプロイ
デプロイの仕方はServerlessFrameworkの標準の内容ですが、今回、LambdaとAppSycnを分けてデプロイしているため、実際には、Lambdaをデプロイ → AppSyncをデプロイ、という流れでそれぞれデプロイしています。

```zsh
sls deploy --stage dev --aws-profile {your-profile} --accountId {your-account-id} --config serverless.yml
```

# テスト

AWSコンソールで、AppSync画面の「クエリ」メニューから、定義したスキーマ／クエリのテストを行うことができます。

この画面から、実行の確認をすることで、デプロイした内容が適切に動作するかどうか、すぐに確認ができます。
Cognitoの認証を設定している場合でも、画面から認証を行ってテストすることができるので、便利ですね。

![appsync-query-test.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22893/b6dac94a-52af-c05c-5ad5-c0d3dc29f414.jpeg)


# まとめ
AppSyncで、Lambdaと連携させることで、マッピングテンプレート（VTL）ファイルを書かずにGraphQLの処理を実現してみました。
定義が複数あるので、最初は複雑に感じるかもしれませんが、基本的には毎回同様の定義をするだけなので、一度内容を覚えればそれほど難しさは感じないように思います。

実際のサービス開発では、単にDynamoDBから値を取得するだけではなく、様々な処理が関係してくることも多いので、それらはLambdaで実現し、テスト／デバッグ可能なようにしておくことで、継続した開発もしやすくなります。
また、既存で API Gateway と Lambda を連携していた場合でも、今回の内容を元にすれば、リクエスト／レスポンスのパラメータの処理だけを変更すれば、既存の内容をそのままAppSync対応にすることも可能です。

今回の構成では、すべてLambdaで実現していますが、もちろん、DynamoDBへのアクセスと、Lambdaの呼び出しを混在させることも可能です。
どのような構成にするかは、サービスの内容に応じて適宜検討してください。