AppSync DynamoDB リゾルバーのマッピングテンプレートで管理用メタデータ(バージョン・更新日付・TTL)を解決する

# はじめに

AWS AppSync では GraphQL の Resolver として Velocity によるマッピングテンプレートを書くことで、プログラムを書くことなく [^1] 入力を成形してデータソースにデータを渡すことができる。 

[^1]: Velocity 自体に if やループ構文があるため、Velocityによるプログラミングをしているのだが、ここではLambdaなどの外部プログラムを使わない、という意味でプログラムを書くことなく、という表現を使った。

ここではデータソースに DynamoDB を指定した場合、ある程度汎用的に使えそうな自己流のメタデータ管理方法をメモ書きする。

- 2021/10/13追記: TTL管理の方法を追加

# リゾルバーのマッピングテンプレートの書き方

こちらの DynamoDB Resolver のサンプルの書き方を参考にしつつ

https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-dynamodb.html

ユーティリティなどは同じカテゴリーの別ジャンルにまとめられているので、こちらを見つつ作業していく。

https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference.html

また、Management Console のエディタを使うと、マッピングテンプレートがどのような形になるかをテストできるので、トライアンドエラーでマッピングテンプレートを記載する場合はこちらで始めると分かりやすかった。 
AppSync API を選択 > スキーマ > Resolver を選択、の順でブラウザ上での編集が行える。 以下は編集後に更新に使われるデータをテスト用に準備し、テストを実行した場合、最終的にどのように Velocity が解決されて処理されるかをテストした例。

![SnapCrab_NoName_2021-10-12_18-2-43_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/d069ca97-4a5f-6e0d-555f-f1d4b99c49d0.png)


# 今回実施したいこと

管理用メタデータの処理をリゾルバーレベルで処理したい。 

- DynamoDB へのデータ登録時にメタデータを自動的に挿入したい
    - Put 時にデータの生成時刻 `created` 項目を自動で追加する
    - Put 時/ Update 時に更新時刻 `updated` 項目を自動で追加する
- DynamoDB のアイテムに TTL を設置し、自動的にデータが消去される仕組みを使いたい
    - DynamoDB の TTL 機能を使うことで期限切れになったデータを自動削除することができる (※削除時間は厳密ではない)
    - しかし、クライアント側の時計がこちらの想定した時計になっていない場合もあるため、基準時刻はGraphQL API側のものを利用したい
    - 今回用意したテーブルでは `ttl` カラムに格納された値を元にデータを消去する設定を行っている
- 楽観ロックを行う
    - 新規登録時、キーが衝突する場合は新規登録に失敗する
    - 更新時、 テーブル上の `version` 項目が更新されている場合は更新に失敗する

今回データソースに使うテーブルのキーはパーティションキーのみであり、そのキー名は `key` とする。

また、テスト用に用意した GraphQL のスキーマは以下の通り。

```graphql
# TTL 設置用。 上から優先的に適用
input TTLArgs {
  # 有効期限を unixtime で指定
  expired: Int          
  # 有効期限を ISOFormat の時刻表現で指定
  expiredISO: String
  # 現在時刻からの相対時間で指定 (単位: 秒)
  delta: Int
  # TTL を撤廃する (更新専用)
  remove: Boolean
}

interface Meta {
  version: Int!
  created: String!
  updated: String!
  ttl: Int
}


type User implements Meta {
  key: String!
  name: String!
  age: Int!
  hobby: String
  version: Int!
  created: String!
  updated: String!
  ttl: Int
}

type Query {
  hello: String
  getUsers: [ User! ]
}

type Mutation {
  createUser(
      key: String!, name: String!, age: Int!, hobby: String
      ttlArgs: TTLArgs
  ): User
  updateUser(
      key: String!,
      name: String, age: Int, hobby: String,
      ttlArgs: TTLArgs, version: Int!
  ): User
}
```

# 新規データ登録

公式ドキュメントの以下の例を参考に、登録処理を記載。

https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-dynamodb.html#:~:text=ctx.args.version)%0A%20%20%20%20%7D%0A%7D-,%E4%BE%8B%202,-%E4%BB%A5%E4%B8%8B%E3%81%AF%E3%80%81GraphQL

```velocity
{
    "version" : "2018-05-29",
    "operation" : "PutItem",
    "key" : {
        "key": $util.dynamodb.toDynamoDBJson($ctx.args.key)
    },

    #set( $attr = $util.dynamodb.toMapValues($ctx.args) )
    ## Set MetaData automatically
    #set( $attr.version = $util.dynamodb.toNumber(1) )
    #set( $attr.created = $util.dynamodb.toString($util.time.nowISO8601()) )
    #set( $attr.updated = $attr.created )

    ## Set Item TTL
    #if ($!{attr.ttlArgs})
      $util.qr($!{attr.remove('ttlArgs')})
      #if ("$!{ctx.args.ttlArgs.expired}" != "")
        #set( $attr.ttl = $util.dynamodb.toNumber(${ctx.args.ttlArgs.expired}) )
      #elseif ("$!{ctx.args.ttlArgs.expiredISO}" != "")
        #set( $ttl = $util.time.parseISO8601ToEpochMilliSeconds(${ctx.args.ttlArgs.expiredISO}) / 1000 )
        #set( $attr.ttl = $util.dynamodb.toNumber($ttl) )
      #elseif ("$!{ctx.args.ttlArgs.delta}" != "")
        #set( $ttl = $util.time.nowEpochSeconds() + ${ctx.args.ttlArgs.delta} )
        #set( $attr.ttl = $util.dynamodb.toNumber($ttl) )
      #end
    #end

    "attributeValues": $util.toJson($attr),
    "condition": {
        "expression": "attribute_not_exists(#id)",
        "expressionNames": {
            "#id": "key",
        },
    }
}
```

このマッピングテンプレートでは、API 経由で渡ってきた全ての項目をそのまま DynamoDB テーブルの項目として登録する。 その登録内容に `version` (登録時なので1で固定)、`created` = `updated` (登録・更新日時は`$util.time` モジュールを用いて取得し、値を設定する) を注入する。
また、テーブルの `ttl` 項目に `ttlArgs` で指定された方法で値を登録する。 `expired: Int` は unixtime で有効期限を指定、`expiredISO: String` はISOフォーマットの日付文字列表現で期限を指定、`delta: Int` は現在時刻に `delta` の値を加えた値を自動的に生存期限として設定する。
これらの値を Resolver で解決した後、DynamoDB の PutItem を呼び出すようにしている。 この時 `condition` 部分で既に同じキーを持つ項目が存在している場合、エラーとなるようにしている。

ここで記載したものは、サンプルとバージョンが異なる。 バージョンの違いは[こちら](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-changelog.html) に記載されているが、大きいものはエラーが発生してもエラーにならずに null が返ってくるというところがある。 そのため、レスポンスマッピングテンプレートで自明にエラーを処理するコードを書く必要がある。 具体的には、以下の通り。


```velocity:response.vtl
#if($ctx.error)
    ## 何らかのエラーがある場合、エラーレスポンスを明示的に返す
    $utils.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson($ctx.result)
```

これらのリゾルバーを実際に登録して `createUser` の mutation を行うと以下の様な挙動となり、メタデータが自動生成されて入力されていることが分かる。　DynamoDB のテーブルにもこれらの値はちゃんと登録されている。

```graphql:入力
mutation MyMutation {
  createUser(age: 10, key: "test", name: "testuser", ttlArgs: {delta: 3600}) {
    key, created, updated, version, ttl
  }
}
```

```json:出力
{
  "data": {
    "createUser": {
      "key": "test",
      "created": "2021-10-13T03:47:17.146Z",
      "updated": "2021-10-13T03:47:17.146Z",
      "version": 1,
      "ttl": 1634100437
    }
  }
}
```

同じクエリを2度発行すると、既に同一キーでデータが登録されているので、以下の様なエラーとなる。

```json:エラー出力
{
  "data": {
    "createUser": null
  },
  "errors": [
    {
      "path": [
        "createUser"
      ],
      "data": null,
      "errorType": "DynamoDB:ConditionalCheckFailedException",
      "errorInfo": null,
      "locations": [
        {
          "line": 2,
          "column": 3,
          "sourceName": null
        }
      ],
      "message": "The conditional request failed (Service: DynamoDb, Status Code: 400, Request ID: AQ1HCHTEEA9S3CQS5IGHNEMHIBVV4KQNSO5AEMVJF66Q9ASUAAJG, Extended Request ID: null)"
    }
  ]
}
```


# データ更新

公式ドキュメントの以下の例を参考に、更新処理を記載。 引数で渡した値だけ更新したかったので、PutItem ではなく UpdateItem を実施する。

https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-dynamodb.html#:~:text=plusOne%22%20%3A%20%7B%20%22N%22%20%3A%201%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D-,%E4%BE%8B%202,-%E4%BB%A5%E4%B8%8B%E3%81%AF%E3%80%81GraphQL


```velocity
{
    "version" : "2018-05-29",
    "operation" : "UpdateItem",
    "key" : {
        "key": $util.dynamodb.toDynamoDBJson($ctx.args.key)
    },

    ## Set MetaData automatically
    #set( $nextVersion = $ctx.args.version + 1 )
    #set( $updated = $util.time.nowISO8601() )

    ## update 用の expression を動的に構築する
    #set( $expNames  = {} )
    #set( $expValues = {} )
    #set( $expSet = {} )
    #set( $expRemove = [] )
        
    ## 更新用のキーを取得 (主キー系を除く)
    #foreach( $entry in $ctx.args.entrySet() )
        #if( $entry.key != "key" && $entry.key != "version" && $entry.key != "ttlArgs" )
            $!{expNames.put("#${entry.key}", "${entry.key}")}
            #if( (!$entry.value) && ("$!{entry.value}" == "") )
                ## null 値が渡された場合、該当項目は Item から削除
                $util.qr($!{expRemove.add("${entry.key}")})
            #else
                ## 非 null 値が渡された場合、該当項目を追加・更新
                $!{expSet.put("${entry.key}", ":${entry.key}")}
                $!{expValues.put(":${entry.key}", $entry.value)}
            #end
        #end
    #end
    
    ## TTL の更新対応
    #if ($!{ctx.args.ttlArgs})
      #if ("$!{ctx.args.ttlArgs.expired}" != "")
        $!{expNames.put("#ttl", "ttl")}
        $!{expSet.put("ttl", ":ttl")}
        $!{expValues.put(":ttl", ${ctx.args.ttlArgs.expired})}
      #elseif ("$!{ctx.args.ttlArgs.expiredISO}" != "")
        #set( $ttl = $util.time.parseISO8601ToEpochMilliSeconds(${ctx.args.ttlArgs.expiredISO}) / 1000 )
        $!{expNames.put("#ttl", "ttl")}
        $!{expSet.put("ttl", ":ttl")}
        $!{expValues.put(":ttl", $ttl)}
      #elseif ("$!{ctx.args.ttlArgs.delta}" != "")
        #set( $ttl = $util.time.nowEpochSeconds() + ${ctx.args.ttlArgs.delta} )
        $!{expNames.put("#ttl", "ttl")}
        $!{expSet.put("ttl", ":ttl")}
        $!{expValues.put(":ttl", $ttl)}
      #elseif ("$!{ctx.args.ttlArgs.remove}" != "" && $!{ctx.args.ttlArgs.remove})
        $!{expNames.put("#ttl", "ttl")}
        $util.qr($!{expRemove.add("ttl")})
      #end
    #end

    ## 更新用固定値 (version は +1 に上書き)
    $util.qr($!{expValues.put(":version", $nextVersion)})
    $util.qr($!{expValues.put(":updated", $updated)})
    
    ## 更新用の expression を構築
    #set( $expression = "SET version = :version, updated = :updated" )
    ## 値の追加・更新を実施
    #foreach( $entry in $expSet.entrySet() )
      #set( $expression = "${expression}, #${entry.key} = :${entry.key}" )
    #end

    ## 値の削除を実施
    #if( !${expRemove.isEmpty()} )
        #set( $expression = "${expression} REMOVE" )
        #foreach( $entry in $expRemove )
            #set( $expression = "${expression} #${entry}" )
            #if ( $foreach.hasNext )
                #set( $expression = "${expression}," )
            #end
        #end
    #end
    
    ## 構築した SET, REMOVE で値を更新する
    "update": {
        "expression" : "${expression}",
        "expressionNames": $util.toJson(${expNames}),
        "expressionValues" : $util.dynamodb.toMapValuesJson(${expValues})
    },
    
    ## 更新条件は取得時と同一のバージョンであること (楽観ロック)
    "condition": {
        "expression" : "version = :expectedVersion",
        "expressionValues" : {
            ":expectedVersion" : $util.dynamodb.toDynamoDBJson($ctx.args.version)
        }
    }
}
```

複雑だが、やっていることは Velocity で `expression` 部分を動的に生成しているだけである。 `expressionNames` と `expressionValues` を使っているので、インジェクションなどへの対処もしている。

実際に利用する場合は以下のようになる。

```graphql
mutation MyMutation {
  updateUser(key: "test", hobby: "coffee", ttlArgs: {expiredISO: "2022-01-01T00:00:00Z"}, version: 1) {
    key, hobby, created, updated, version, ttl
  }
}

# {
#   "data": {
#     "updateUser": {
#       "key": "test",
#       "hobby": "coffee",
#       "created": "2021-10-13T03:47:17.146Z",
#       "updated": "2021-10-13T03:49:56.452Z",
#       "version": 2,
#       "ttl": 1640995200
#     }
#   }
# }
```

と、このように

- 引数で渡した値 `hobby` は追加されている
- `version` , `updated` , `ttl` が更新されている (`created` は登録時のまま)

ということが確認できる。

更新後、もう一度同じ mutation を行おうとすると、保存されている version と値が異なるため、以下のようなエラーになる。

```json:エラー出力
{
  "data": {
    "updateUser": null
  },
  "errors": [
    {
      "path": [
        "updateUser"
      ],
      "data": null,
      "errorType": "DynamoDB:ConditionalCheckFailedException",
      "errorInfo": null,
      "locations": [
        {
          "line": 2,
          "column": 3,
          "sourceName": null
        }
      ],
      "message": "The conditional request failed (Service: DynamoDb, Status Code: 400, Request ID: J8HR2GLEFF0F46E7E3J4NFC63NVV4KQNSO5AEMVJF66Q9ASUAAJG, Extended Request ID: null)"
    }
  ]
}
```

登録した `hobby` や `ttl` を削除する場合、以下の様になる。

```graphql
mutation MyMutation {
  updateUser(key: "test", hobby: null, ttlArgs: {remove: true}, version: 2) {
    key, hobby, created, updated, version, ttl
  }
}
```

```json
{
  "data": {
    "updateUser": {
      "key": "test",
      "hobby": null,
      "created": "2021-10-13T03:47:17.146Z",
      "updated": "2021-10-13T03:54:33.230Z",
      "version": 3,
      "ttl": null
    }
  }
}
```

これで、DynamoDB 上からも `hobby` , `ttl` の項目は削除される。

![SnapCrab_NoName_2021-10-13_12-55-24_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/e3179b1d-6c63-7e59-48f7-5fa14f934b64.png)


# まとめ

マッピングテンプレートは割と癖があるが、マニュアルを見つつトライアンドエラーを繰り返すことでかなり高度なことができることが分かった。 個人的にはこの内容は汎用性があると考えているため、メモ書きとして残しておく。