AWS CloudFormationでAWS AppSyncのパイプラインリゾルバーを作成する

# はじめに
AWS Amplifyのamplify pushコマンドでパイプラインリゾルバーを作成したかったのですが、色々とハマったので備忘録として記事にしました。
その時にこんな記事があったらよかったなぁという内容を記事にしました。

#### AWS Amplifyとは
AWS AmplifyとはAWSの環境上にアプリケーションを構築してくれるツールです。
AWS コマンドラインインターフェース (CLI) を利用して、"amplify push"でバックエンドのリソース設定ができます。
詳細はAmplifyの公式ドキュメントをご覧ください。
https://docs.amplify.aws/

#### パイプラインリゾルバーとは
パイプラインリゾルバーは関数を構成して順番に実行できる機能です。
Before マッピングテンプレート・関数のリスト・After マッピングテンプレートで構成されます。
https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/pipeline-resolvers.html

# やりたいこと
やりたいことは、AWS AppSyncのパイプラインリゾルバーを作成すること！
そんなの簡単にこの画面👇からResolver「アタッチ」でできるじゃーんと思ったあなた、正解です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2695523/31b4f525-c601-5cb0-cb28-f3238da68572.png)


簡単じゃーんと思ったあなた、まだ早いです。今回はこんな問題があったのです。

#### 手動設定したときの問題
バックエンドの環境はすべてファイル管理しており、AWS Amplifyで構築していたため、
Dynamo DBのテーブル定義の変更やschema.graphqlの定義が更新される度に amplify push が実行されます。
当然のことながら、上図の画面でリゾルバーを手動設定しているとファイル管理されていないため、amplify push でリゾルバー設定は消えてしまうという訳です。

つまり、今回の真の「やりたいこと」はこれです。

**amplify pushでパイプラインリゾルバーを作成したい！！！**

# 選ばれたのはCloudFormationでした

#### AWS CloudFormationとは？
テンプレートから情報を取り込み、簡単にAWSの環境構築ができるサービスです。
テンプレートがあれば、環境を何回でも同じように構築できます。
今回の用途は先程GraphQLに書いたクエリ(querySeated)にアタッチするパイプラインリゾルバーの設定をCloudFormationテンプレートに書きます。

色々と実現する方法はあるのかも知れませんが、私が見つけたのはタイトルにもある通り、**AWS CloudFormation**を使う方法でした。
ただ実現するにあたり、困ったことがいくつも出てきました、、、

本記事は長いため、すべて読まなくても良いです。
必要とする部分だけを読んでいただくため、Tipsを用意しました。
**Tips**
- [CloudFormationテンプレート、どこに置けばいいの？](#どこに置けばいいの)
- [CloudFormationテンプレート、何を書けばいいの？](#何を書けばいいの)
- [JSONにVTLのソースをどう埋め込むの？](#jsonにvtlのソースをどう埋め込むの)
- [記述ミスに気付くには？](#記述ミスに気付くには)


これらの不明点を解決しつつ、AWS CloudFormationでパイプラインリゾルバーを作成しましょう！

# まずは準備から

取得先のテーブルとリゾルバーをアタッチするクエリを用意します。

#### 取得先のテーブル
**テーブル作成スキーマ**
```
 type SeatingHistory @model 
 @key(fields: ["seatId", "unixTimeId"])
 @key(name: "bySeated", fields: ["seatedDate","seatedTime"])
 {
  seatId: ID!
  unixTimeId: Int!
  timestamp: String
  seatedDate: String!
  seatedTime: String!
}
```

***
**本編の内容とは逸れますので、ご参考まで**

 **着席履歴テーブル(SeatingHistoryテーブル)**
 - 着席履歴を管理する。
 - 着席するとuserId・userNameに着席者のデータが登録される。
 - 離席するとuserId・userNameがNULLのデータが登録される。
 
**テーブル定義**
- パーティションキー：seatId（座席ID） 
- ソートキー：unixTimeId（レコードが登録されたUNIXタイム）
- グローバルセカンダリインデックス
   - パーティションキー：seatedDate（着席時・離席時の日付）
   - ソートキー：seatedTime（着席時・離席時の時間）

>**補足**
DynamoDBのキー・インデックスについてはこちらの記事をご覧ください！
オカメも大変お世話になった記事です
https://qiita.com/shibataka000/items/e3f3792201d6fcc397fd

***
#### アタッチするクエリ
**クエリ作成用スキーマ**
```graphql
type Query{
	querySeated(
		seatedDate: String,
		seatedTime: ModelStringKeyConditionInput,
		sortDirection: ModelSortDirection,
    	filter: ModelSeatingHistoryFilterInput,
		limit: Int,
		nextToken: String
	): ModelSeatingHistoryConnection
}

input ModelStringKeyConditionInput {
	eq: String
	le: String
	lt: String
	ge: String
	gt: String
	between: [String]
	beginsWith: String
}

enum ModelSortDirection {
	ASC
	DESC
}

type ModelSeatingHistoryConnection {
	items: [SeatingHistory]
  nextToken: String
}

input ModelSeatingHistoryFilterInput {
	seatId: ModelIDInput
	unixTimeId: ModelIntInput
	timestamp: ModelStringInput
	seatedDate: ModelStringInput
	seatedTime: ModelStringInput
	installationLocation: ModelStringInput
	xAxis: ModelFloatInput
	yAxis: ModelFloatInput
	projectCd: ModelStringInput
	projectName: ModelStringInput
	userId: ModelStringInput
	userName: ModelStringInput
	button1Event: ModelStringInput
	button2Event: ModelStringInput
	button3Event: ModelStringInput
	button4Event: ModelStringInput
	button5Event: ModelStringInput
	and: [ModelSeatingHistoryFilterInput]
	or: [ModelSeatingHistoryFilterInput]
	not: ModelSeatingHistoryFilterInput
}

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

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

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
}

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

enum ModelAttributeTypes {
	binary
	binarySet
	bool
	list
	map
	number
	numberSet
	string
	stringSet
	_null
}

input ModelSizeInput {
	ne: Int
	eq: Int
	le: Int
	lt: Int
	ge: Int
	gt: Int
	between: [Int]
}
```
***
- querySeated
今回リゾルバーをアタッチするクエリ
***

# CloudFormationテンプレート
準備が整ったのでCloudFormationテンプレートを触っていきましょうー

#### どこに置けばいいの？
私はCustomResources.jsonファイルに記載しました。
amplify > backend > api > stacks > CustomResources.json
しかし、別のサイトを見ると同じディレクトリに別ファイルを作成しても良いそうです。
私は試していませんが、こちらの記事ではやっていますね。
https://thinkami.hatenablog.com/entry/2019/07/15/201356
>project_root/amplify/backend/api/infraAPI/stacks の中に ExistsDynamoDB.json ファイルを作成します。
なお、同じディレクトリには CustomResources.json があります。このファイルに追記しても良いですし、別ファイルとして作成しても良いです。
今回は、 CustomResources.json をベースに必要な項目を追加した ExistsDynamoDB.json となります。

#### 何を書けばいいの？
👇CustomResources.jsonに何を書くの？という部分ですが、こちらに全文を載せました

<details><summary>CustomResources.json</summary>

```json
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Parameters": {
    "AppSyncApiId": {
      "Type": "String",
      "Description": "The id of the AppSync API associated with this project."
    }
  },
  "Resources": {
    "FunctionConfiguration1": {
      "Type": "AWS::AppSync::FunctionConfiguration",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "Name": "QueryPrevDate",
        "DataSourceName": "SeatingHistoryTable",
        "FunctionVersion": "2018-05-29",
        "RequestMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "{",
              " \"operation\" : \"Query\",",
              " \"query\" : {",
              "   \"expression\": \"seatedDate = :seatedDate\",",
              "   \"expressionValues\" : {",
              "     \":seatedDate\" : $util.dynamodb.toDynamoDBJson(${ctx.stash.prevDate})",
              "   }",
              " },",
              " \"index\" : \"bySeated\"",
              "}"
            ]
          ]
        },
        "ResponseMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#set ($myMap = {})",
              "#foreach ($item in $ctx.result.items)",
              " #if($myMap.containsKey($item.seatId) == false)",
              "   $util.qr($myMap.put($item.seatId, $item))",
              " #end",
              " #if($myMap.get($item.seatId).unixTimeId < $item.unixTimeId)",
              "   $util.qr($myMap.put($item.seatId, $item))",
              " #end",
              "#end",
              "$util.toJson({\"items\": $myMap.values()})"
            ]
          ]
        }
      }
    },
    "FunctionConfiguration2": {
      "Type": "AWS::AppSync::FunctionConfiguration",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "Name": "QuerySeatedDateTime",
        "DataSourceName": "SeatingHistoryTable",
        "FunctionVersion": "2018-05-29",
        "RequestMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#set( $modelQueryExpression = {} )",
              "#if( !$util.isNull($ctx.args.seatedDate) )",
              " #set( $modelQueryExpression.expression = \"#seatedDate = :seatedDate\" )",
              " #set( $modelQueryExpression.expressionNames = {",
              " \"#seatedDate\": \"seatedDate\"",
              "} )",
              " #set( $modelQueryExpression.expressionValues = {",
              " \":seatedDate\": {",
              "     \"S\": \"$ctx.args.seatedDate\"",
              " }",
              "} )",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.beginsWith) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND begins_with(#sortKey, :sortKey)\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.beginsWith\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.between) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey BETWEEN :sortKey0 AND :sortKey1\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey0\", { \"S\": \"$ctx.args.seatedTime.between[0]\" }))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey1\", { \"S\": \"$ctx.args.seatedTime.between[1]\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.eq) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey = :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.eq\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.lt) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey < :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.lt\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.le) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey <= :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.le\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.gt) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey > :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.gt\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.ge) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey >= :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.ge\" }))",
              "#end",
              "#set( $limit = $util.defaultIfNull($context.args.limit, 100) )",
              "#set( $QueryRequest = {",
              " \"version\": \"2018-05-29\",",
              " \"operation\": \"Query\",",
              " \"limit\": $limit,",
              " \"query\": $modelQueryExpression,",
              " \"index\": \"bySeated\"",
              "} )",
              "#if( !$util.isNull($ctx.args.sortDirection) && $ctx.args.sortDirection == \"DESC\" )",
              "  #set( $QueryRequest.scanIndexForward = false )",
              "#else",
              "  #set( $QueryRequest.scanIndexForward = true )",
              "#end",
              "#if( $context.args.nextToken ) #set( $QueryRequest.nextToken = $context.args.nextToken ) #end",
              "#if( $context.args.filter ) #set( $QueryRequest.filter = $util.parseJson(\"$util.transform.toDynamoDBFilterExpression($ctx.args.filter)\") ) #end",
              "$util.toJson($QueryRequest)"
            ]
          ]
        },
        "ResponseMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#if($ctx.error)",
              " $util.error($ctx.error.message, $ctx.error.type)",
              "#end",
              "#set ($myMap = {})",
              "#foreach ($item in $ctx.result.items)",
              "  #if($myMap.containsKey($item.seatId) == false)",
              "     $util.qr($myMap.put($item.seatId, $item))",
              "  #end",
              "  #if($myMap.get($item.seatId).unixTimeId < $item.unixTimeId)",
              "     $util.qr($myMap.put($item.seatId, $item))",
              "  #end",
              "#end",
              "#foreach ($item in $ctx.prev.result.items)",
              "  #if($myMap.containsKey($item.seatId) == false)",
              "     $util.qr($myMap.put($item.seatId, $item))",
              "  #end",
              "#end",
              "$util.toJson({",
              " \"items\": $myMap.values()",
              "})"
            ]
          ]
        }
      }
    },
    "PipelineResolver": {
      "Type": "AWS::AppSync::Resolver",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "TypeName": "Query",
        "FieldName": "querySeated",
        "Kind": "PIPELINE",
        "PipelineConfig": {
          "Functions": [
            {
              "Fn::GetAtt": [
                "FunctionConfiguration1",
                "FunctionId"
              ]
            },
            {
              "Fn::GetAtt": [
                "FunctionConfiguration2",
                "FunctionId"
              ]
            }
          ]
        },
        "RequestMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#set($utcDate = $util.time.parseFormattedToEpochMilliSeconds(\"$ctx.args.seatedDate 00:00:00+0900\", \"yyyyMMdd HH:mm:ssZ\"))",
              "#set($utcPrevDate = $utcDate - 86400000)",
              "#set($prevDate = $util.time.epochMilliSecondsToFormatted($utcPrevDate, \"yyyyMMdd\", \"+09:00\"))",
              "$util.qr($ctx.stash.put(\"prevDate\", $prevDate))",
              "{}"
            ]
          ]
        },
        "ResponseMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#set($myMap = {})",
              "#foreach($item in $ctx.prev.result.items)",
              "  #if($item.userId)",
              "   $util.qr($myMap.put($item.seatId, $item))",
              "  #end",
              "#end",
              "$util.toJson({",
              "  \"items\": $myMap.values()",
              "})"
            ]
          ]
        }
      }
    }
  },
  "Outputs": {}
}
```
</details>

> **補足**
CloudFormationの設定ファイルはJSONとYAMLの両方をサポートしていますが、Amplifyで作成する場合はJSONのみサポートしています。
そのため、YAMLで記述したCustomResourceを使おうとすると、以下のエラーが発生します。
> Yaml is not yet supported. Please convert the CloudFormation stack ExistsDynamoDB.yaml to json.
https://thinkami.hatenablog.com/entry/2019/07/15/201356


#### 詳しく見てみよう！！
では何を設定しているのか、細かく見てみましょう。

```json
  "AWSTemplateFormatVersion": "2010-09-09"
```
- テンプレートの形式バージョン
```json
  "Parameters": {
    "AppSyncApiId": {
      "Type": "String",
      "Description": "The id of the AppSync API associated with this project."
    }
```
- テンプレートでのパラメーターの定義
```json
    "FunctionConfiguration1": {
      "Type": "AWS::AppSync::FunctionConfiguration",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "Name": "QueryPrevDate",
        "DataSourceName": "SeatingHistoryTable",
        "FunctionVersion": "2018-05-29",
        "RequestMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "{",
              " \"operation\" : \"Query\",",
              " \"query\" : {",
              "   \"expression\": \"seatedDate = :seatedDate\",",
              "   \"expressionValues\" : {",
              "     \":seatedDate\" : $util.dynamodb.toDynamoDBJson(${ctx.stash.prevDate})",
              "   }",
              " },",
              " \"index\" : \"bySeated\"",
              "}"
            ]
          ]
        },
        "ResponseMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#set ($myMap = {})",
              "#foreach ($item in $ctx.result.items)",
              " #if($myMap.containsKey($item.seatId) == false)",
              "   $util.qr($myMap.put($item.seatId, $item))",
              " #end",
              " #if($myMap.get($item.seatId).unixTimeId < $item.unixTimeId)",
              "   $util.qr($myMap.put($item.seatId, $item))",
              " #end",
              "#end",
              "$util.toJson({\"items\": $myMap.values()})"
            ]
          ]
        }
      }
    }
```
- GraphQLAPIの関数を定義

>|プロパティ名|説明|必須|型|
>|---|---|---|---|
>|ApiId|リゾルバーをアタッチするAWSAppSyncGraphQLAPI|Yes|文字列|
>|DataSourceName|リゾルバーのデータソース名|Yes|文字列|
>|FunctionVersion|リクエストマッピングテンプレートのバージョン|Yes|文字列|
>|Name|関数の名前|Yes|文字列|
>|RequestMappingTemplate|Functionリクエストマッピングテンプレート|No|文字列|
>|ResponseMappingTemplate|Function応答マッピングテンプレート|No|文字列|
>
>公式
> https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-appsync-functionconfiguration.html

#### JSONにVTLのソースをどう埋め込むの？
という疑問もこれでお分かりですね。
```json
        "RequestMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "{",
              " \"operation\" : \"Query\",",
              " \"query\" : {",
              "   \"expression\": \"seatedDate = :seatedDate\",",
              "   \"expressionValues\" : {",
              "     \":seatedDate\" : $util.dynamodb.toDynamoDBJson(${ctx.stash.prevDate})",
              "   }",
              " },",
              " \"index\" : \"bySeated\"",
              "}"
            ]
          ]
        }
```
RequestMappingTemplate・ResponseMappingTemplateは文字列型のため、このようにVTLのソースはFn::Joinを使って1つの文字列の値にします。


https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-join.html


```json
    "FunctionConfiguration2": {
      "Type": "AWS::AppSync::FunctionConfiguration",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "Name": "QuerySeatedDateTime",
        "DataSourceName": "SeatingHistoryTable",
        "FunctionVersion": "2018-05-29",
        "RequestMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#set( $modelQueryExpression = {} )",
              "#if( !$util.isNull($ctx.args.seatedDate) )",
              " #set( $modelQueryExpression.expression = \"#seatedDate = :seatedDate\" )",
              " #set( $modelQueryExpression.expressionNames = {",
              " \"#seatedDate\": \"seatedDate\"",
              "} )",
              " #set( $modelQueryExpression.expressionValues = {",
              " \":seatedDate\": {",
              "     \"S\": \"$ctx.args.seatedDate\"",
              " }",
              "} )",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.beginsWith) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND begins_with(#sortKey, :sortKey)\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.beginsWith\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.between) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey BETWEEN :sortKey0 AND :sortKey1\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey0\", { \"S\": \"$ctx.args.seatedTime.between[0]\" }))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey1\", { \"S\": \"$ctx.args.seatedTime.between[1]\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.eq) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey = :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.eq\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.lt) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey < :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.lt\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.le) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey <= :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.le\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.gt) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey > :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.gt\" }))",
              "#end",
              "#if( !$util.isNull($ctx.args.seatedTime) && !$util.isNull($ctx.args.seatedTime.ge) )",
              " #set( $modelQueryExpression.expression = \"$modelQueryExpression.expression AND #sortKey >= :sortKey\" )",
              " $util.qr($modelQueryExpression.expressionNames.put(\"#sortKey\", \"seatedTime\"))",
              " $util.qr($modelQueryExpression.expressionValues.put(\":sortKey\", { \"S\": \"$ctx.args.seatedTime.ge\" }))",
              "#end",
              "#set( $limit = $util.defaultIfNull($context.args.limit, 100) )",
              "#set( $QueryRequest = {",
              " \"version\": \"2018-05-29\",",
              " \"operation\": \"Query\",",
              " \"limit\": $limit,",
              " \"query\": $modelQueryExpression,",
              " \"index\": \"bySeated\"",
              "} )",
              "#if( !$util.isNull($ctx.args.sortDirection) && $ctx.args.sortDirection == \"DESC\" )",
              "  #set( $QueryRequest.scanIndexForward = false )",
              "#else",
              "  #set( $QueryRequest.scanIndexForward = true )",
              "#end",
              "#if( $context.args.nextToken ) #set( $QueryRequest.nextToken = $context.args.nextToken ) #end",
              "#if( $context.args.filter ) #set( $QueryRequest.filter = $util.parseJson(\"$util.transform.toDynamoDBFilterExpression($ctx.args.filter)\") ) #end",
              "$util.toJson($QueryRequest)"
            ]
          ]
        },
        "ResponseMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#if($ctx.error)",
              " $util.error($ctx.error.message, $ctx.error.type)",
              "#end",
              "#set ($myMap = {})",
              "#foreach ($item in $ctx.result.items)",
              "  #if($myMap.containsKey($item.seatId) == false)",
              "     $util.qr($myMap.put($item.seatId, $item))",
              "  #end",
              "  #if($myMap.get($item.seatId).unixTimeId < $item.unixTimeId)",
              "     $util.qr($myMap.put($item.seatId, $item))",
              "  #end",
              "#end",
              "#foreach ($item in $ctx.prev.result.items)",
              "  #if($myMap.containsKey($item.seatId) == false)",
              "     $util.qr($myMap.put($item.seatId, $item))",
              "  #end",
              "#end",
              "$util.toJson({",
              " \"items\": $myMap.values()",
              "})"
            ]
          ]
        }
      }
    }
```
- 上記と同じように２つ目の関数を定義します。
```json
"PipelineResolver": {
      "Type": "AWS::AppSync::Resolver",
      "Properties": {
        "ApiId": {
          "Ref": "AppSyncApiId"
        },
        "TypeName": "Query",
        "FieldName": "querySeated",
        "Kind": "PIPELINE",
        "PipelineConfig": {
          "Functions": [
            {
              "Fn::GetAtt": [
                "FunctionConfiguration1",
                "FunctionId"
              ]
            },
            {
              "Fn::GetAtt": [
                "FunctionConfiguration2",
                "FunctionId"
              ]
            }
          ]
        },
        "RequestMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#set($utcDate = $util.time.parseFormattedToEpochMilliSeconds(\"$ctx.args.seatedDate 00:00:00+0900\", \"yyyyMMdd HH:mm:ssZ\"))",
              "#set($utcPrevDate = $utcDate - 86400000)",
              "#set($prevDate = $util.time.epochMilliSecondsToFormatted($utcPrevDate, \"yyyyMMdd\", \"+09:00\"))",
              "$util.qr($ctx.stash.put(\"prevDate\", $prevDate))",
              "{}"
            ]
          ]
        },
        "ResponseMappingTemplate": {
          "Fn::Join": [
            "\n",
            [
              "#set($myMap = {})",
              "#foreach($item in $ctx.prev.result.items)",
              "  #if($item.userId)",
              "   $util.qr($myMap.put($item.seatId, $item))",
              "  #end",
              "#end",
              "$util.toJson({",
              "  \"items\": $myMap.values()",
              "})"
            ]
          ]
        }
      }
```
- GraphQLリゾルバーを定義
補足：上で定義した２つの関数をパイプラインリゾルバーにする、繋ぎの部分になります。

>| プロパティ名 | 説明 | 必須  | 型 |
>|---|---|---|---|
>| ApiId                   | このリゾルバーをアタッチするAWSAppSyncGraphQLAPI | Yes | 文字列 |
>| DataSourceName          | リゾルバーのデータソース名                      | Yes | 文字列 |
>| FieldName               | リゾルバタイプ ※PIPELINE or UNITを指定 | Yes | 文字列 |
>| PipelineConfig | パイプラインリゾルバーにリンクされた関数  | No | PipelineConfig |
>| RequestMappingTemplate  | リクエストマッピングテンプレート        | No  | 文字列 |
>| ResponseMappingTemplate | 応答マッピングテンプレート              | No  | 文字列 |
>
>※この場合はRequestMappingTemplateがBeforeテンプレート・ResponseMappingTemplateがAfterテンプレートになります。
>
> 公式
> https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-appsync-resolver.html

***
仕組みはなんとなく理解したけど、amplify pushをしたときのエラーではCloudFormationの何が間違えているか分からない！という方もいらっしゃるのでは？
#### 記述ミスに気付くには？
CloudFormationテンプレートの記述ミスに気付くには、CloudFormationのLinterがあります。

例えばこの部分のParametersの定義を消してみましょう
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2695523/72d4150c-b5a7-1c1c-8c7f-c01f6332d72a.png)

こうなりますね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2695523/3920a0b2-439b-3d2d-c0ae-9dd0fee9ba83.png)

ではこの状態で
＜＜＜＜＜＜ amplify push ＞＞＞＞＞＞
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2695523/2af7ab33-c266-2e42-691f-1cd6af0891b4.png)


エラーになったということは分かるのですが、どこのエラーなのか…もはやCloudFormation関連のエラーなのかすらも見当がつかない

＜＜＜＜＜＜ 　Linterだと　 ＞＞＞＞＞＞
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2695523/a56c1654-b543-6b3d-31cd-4fde805709c2.png)
ちゃんとAppSyncApiiIdがないよーと教えてくれますね。超便利！！これを知らなかったのでオカメは2日ほど訳の分からないエラーに苦しめられていましたね。


インストール方法や使い方はこちらを参考にしてください。

https://dev.classmethod.jp/articles/use-linter-to-notice-mistakes-of-cloudformation/

# おまけ雑談
CloudFormationに辿り着くまでも、CloudFormationテンプレートでもめちゃめちゃ苦労しました。
何度もエラーを経験し、やっと動いたときには全米が泣いていました。