AmplifyでAPI Gateway + Step Functions

[AWS Amplify Advent Calendar 2020](https://qiita.com/advent-calendar/2020/amplify) 10日目の記事です！

[Amplify CLI](https://docs.amplify.aws/cli)を使うと[API Gateway](https://aws.amazon.com/jp/api-gateway/)のREST APIをサクッと作成できます。
ただ、API GatewayのREST APIは、[Lambda統合のタイムアウトが最大29秒](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/limits.html#api-gateway-limits)なので、データのダウンロードとか時間のかかる処理を行うとタイムアウトしてしまうことがあります。そこで、[Step Functions](https://aws.amazon.com/jp/step-functions/)を使ってタイムアウトの問題を解決してみます。

※つまり、以下で説明されているようなことを、Amplify CLIでやろうという感じです。とてもわかりやすくまとまっているので、まず読んでもらってから続きを見てもらえると良いかと思います。
[【API Gatewayタイムアウト対策】Step Functionsを組み合わせて非同期処理にしてみる](
https://dev.classmethod.jp/articles/apigateway-stepfunctions-asynchronous/) 


### 概要
* Amplify CLIでAPI GatewayのREST APIとLambdaを作る。
* [Custom CloudFormation stacks](https://docs.amplify.aws/cli/usage/customcf)でStep Functionsを作成して、REST APIとLambdaの間に挟む。
* タスクの完了確認は、API Gatewayに確認用のリソースを作ってポーリングして確認する。

### 内容
最終的な結果は以下に置いてあります。
https://github.com/two-pack/example-amplify-api-with-stepfunctions

#### amplify init
**amplify init** のダイアログは全部デフォルトでいきます。

~~~bash
$ mkdir example-amplify-api-with-stepfunctions
$ cd example-amplify-api-with-stepfunctions
$ amplify init
~~~

#### REST APIとLambdaの追加
Amplify CLIからAPIを追加します。このなかでLambdaも作ってしまいました。
以下で設定しています。

| 設定 | 内容 |
|:-:|:-:|
| API名  | HelloApi|
| APIパス  | /hello  |
| APIの制限 | なし |
| Lambda名  | HelloFunc  |
| Lambdaランタイム | NodeJS |

~~~bash
$ amplify api add
? Please select from one of the below mentioned services: REST
? Provide a friendly name for your resource to be used as a label for this category in the project: HelloApi
? Provide a path (e.g., /book/{isbn}): /hello
? Choose a Lambda source Create a new Lambda function
? Provide an AWS Lambda function name: HelloFunc
? Choose the runtime that you want to use: NodeJS
? Choose the function template that you want to use: Hello World
<snip>
? Do you want to configure advanced settings? No
? Do you want to edit the local lambda function now? No
Successfully added resource HelloFunc locally.
<snip>

$ amplify status

Current Environment: dev

| Category | Resource name | Operation | Provider plugin   |
| -------- | ------------- | --------- | ----------------- |
| Function | HelloFunc     | Create    | awscloudformation |
| Api      | HelloApi      | Create    | awscloudformation |
~~~

一度ここでpushしておきます。

~~~
$ amplify push
~~~


#### Step Functionsの追加
次にStep FunctionsをCustom CloudFormation stackで追加します。
追加するStep Functionsは、 **HelloStep** としてHelloFuncラムダを実行するタスクとします。

##### **カテゴリとリソースの追加**
**/amplify/backend/backend-config.json** を編集して、Amplify CLIで扱うカテゴリとしてStep Functionsを追加します。
HelloFuncラムダを呼び出すので**dependsOn**に書いています。

~~~json:/amplify/backend/backend-config.json
  "stepFunction": {
    "HelloStep": {
      "service": "Step Function",
      "providerPlugin": "awscloudformation",
      "dependsOn": [
        {
          "category": "function",
          "resourceName": "HelloFunc",
          "attributes": ["Name", "Arn"]
        }
      ]
    }
  },
~~~   

次に、フォルダを作成してCloudFormationのテンプレートとして、 **/amplify/backend/stepFunction/HelloStep/HelloStep-cloudformation-template.json** を作成します。[参考ページ](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-lambda-state-machine-cloudformation.html)のテンプレートをベースにしつつ、Amplify CLIが生成したLambdaやREST APIのテンプレートの記述を参考にして作成します。

ロールの設定は、[ここ](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-api-gateway.html#api-gateway-step-1)で書かれているもの相当の設定が必要です。このため、**AssumeRolePolicyDocument**の**Service**に**apigateway.amazonaws.com** 、**Policies**に**states:\***アクションの許可を追加します。
また、HelloFunc Lambdaのテンプレートを参考に **RoleName属性** も追加してください。

~~~json:amplify/backend/stepFunction/HelloStep/HelloStep-cloudformation-template.json
    "StatesExecutionRole": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "RoleName": {
          "Fn::If": [
            "ShouldNotCreateEnvResources",
            "exampleamplifyapiwitStatesExecutionRole",
            {
              "Fn::Join": [
                "",
                [
                  "exampleamplifyapiwitStatesExecutionRole",
                  "-",
                  {
                    "Ref": "env"
                  }
                ]
              ]
            }
          ]
        },
        "AssumeRolePolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Principal": {
                "Service": [
                  {
                    "Fn::Sub": "states.${AWS::Region}.amazonaws.com"
                  }
                ]
              },
              "Action": "sts:AssumeRole"
            },
            {
              "Effect": "Allow",
              "Principal": {
                "Service": "apigateway.amazonaws.com"
              },
              "Action": "sts:AssumeRole"
            }
          ]
        },
        "Path": "/",
        "Policies": [
          {
            "PolicyName": "StatesExecutionPolicy",
            "PolicyDocument": {
              "Version": "2012-10-17",
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": ["lambda:InvokeFunction"],
                  "Resource": "*"
                },
                {
                  "Effect": "Allow",
                  "Action": "states:*",
                  "Resource": "*"
                }
              ]
            }
          }
        ]
      }
    },
~~~


次に、ステートマシンの定義です。[CloudFormationの仕様](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-stepfunctions-statemachine.html#cfn-stepfunctions-statemachine-statemachinename)に従ったものになりますが、以下のあたりがポイントかと思います。

* **StateMachineName**は、Amplifyの環境名を付与してステートマシンの名前を定義しています。
* **DefinitionString**は、ステートマシンの定義です。
  + Step Functionsのコンソールから定義をJSONで確認できるので、コンソールで作成したもの持ってくると簡単かと思います。
  + **lambdaArn** はプレースホルダーの参照です。テンプレート内の **Parameters** でHelloFunc LambdaのARNを参照できるように記載しています。

~~~json:amplify/backend/stepFunction/HelloStep/HelloStep-cloudformation-template.json
    "HelloStepStateMachine": {
      "Type": "AWS::StepFunctions::StateMachine",
      "Properties": {
        "StateMachineName": {
          "Fn::If": [
            "ShouldNotCreateEnvResources",
            "HelloStep",
            {
              "Fn::Join": [
                "",
                [
                  "HelloStep",
                  "-",
                  {
                    "Ref": "env"
                  }
                ]
              ]
            }
          ]
        },
        "DefinitionString": {
          "Fn::Sub": [
            "{\n  \"Comment\": \"A Hello World example using an AWS Lambda function\",\n  \"StartAt\": \"HelloWorld\",\n  \"States\": {\n    \"HelloWorld\": {\n      \"Type\": \"Task\",\n      \"Resource\": \"${lambdaArn}\",\n      \"End\": true\n    }\n  }\n}",
            {
              "lambdaArn": {
                "Ref": "functionHelloFuncArn"
              }
            }
          ]
        },
        "RoleArn": {
          "Fn::GetAtt": ["StatesExecutionRole", "Arn"]
        }
      }
    }
  },
~~~


ここまでできたら、以下のコマンドでAmplify CLIにカテゴリとリソースとしてHelloStepを認識させます。
**amplify env checkout** を行うと認識させることができます。

~~~bash
$ amplify env checkout dev
$ amplify status

Current Environment: dev

| Category     | Resource name | Operation | Provider plugin   |
| ------------ | ------------- | --------- | ----------------- |
| Stepfunction | HelloStep     | Create    | awscloudformation |
| Function     | HelloFunc     | No Change | awscloudformation |
| Api          | HelloApi      | No Change | awscloudformation |

REST API endpoint: https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev
~~~

というわけで **amplify push** します。

~~~bash
$ amplify push
~~~

AWS ConsoleでStep Functionsを確認すると、 **HelloStep-dev** というステートマシンができていると思います（**-dev** は、Amplifyの環境が付与されているものです）。
実行すると、**HelloFunc-dev** Lambdaが実行されることを確認できます。


#### REST APIからStep Functionsを呼び出す
ステートマシンができたので、次はREST APIからStep Functionsのステートマシンを呼び出すようにします。
今回は、API Gatewayのコンソールで統合リクエストをStep Functionsの呼び出しに変更して、その結果をエクスポートしたものをもとに、テンプレートを書き換えていきます。[^1]

##### 依存関係を変更する
**/amplify/backend/backend-config.json** の **api** の **dependsOn** をHelloFuncからHelloStepへ変えます。
**attributes** は、参照して使うArnとRoleNameを定義しておきます。

~~~json:/amplify/backend/backend-config.json
  "api": {
    "HelloApi": {
      "service": "API Gateway",
      "providerPlugin": "awscloudformation",
      "dependsOn": [
        {
          "category": "stepFunction",
          "resourceName": "HelloStep",
          "attributes": ["Arn", "RoleName"]
        }
      ]
    }
  }
~~~

同様に **/amplify/backend/api/HelloApi/api-params.json** の **dependsOn** もHelloFuncからHelloStepへ変えます。


##### 統合先をStep Functionsに変更する
API Gatewayのコンソールで、Amplifyで作成した **HelloApi** の統合先を変更します。今回は、CloudFormationのテンプレートを変更する前に、コンソールで設定を試してみてから、結果をエクスポートしてテンプレートに適用していきます。

**/hello** と **/hello/{proxy+}** の**ANYリソース** の統合リクエストを開き、統合タイプを**AWSサービス**にしてStep Functionsが呼び出されるように設定します。設定時のARNなど、Amplifyで作成したステートマシンをコンソールで見ながら入れていきます。
アクションは**[StartExecution](https://docs.aws.amazon.com/ja_jp/step-functions/latest/apireference/API_StartExecution.html)**を指定することで、ステートマシンを実行することができます。
このあたりは、[参考ページ](https://dev.classmethod.jp/articles/apigateway-stepfunctions-asynchronous/)で新規に作っていく流れが詳しく書かれているので参照してもらえればと思います。
加えて、[ここ](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-api-gateway.html#api-gateway-step-3)に記載のある、マッピングテンプレートの設定も行います。

設定をしたら、リソースのテストで確認します。
メソッドは **POST** 、リクエスト本文は **{}** でとりあえずOKです。
設定がうまく行っていれば、**HelloStep**が呼び出されます。Step Functionsのコンソールから確認しましょう。


##### APIのデプロイ結果をエクスポート
変更をデプロイして、API Gatewayのコンソールの**ステージ**から設定した内容をエクスポートします。
**Swagger +API Gateway 拡張の形式でエクスポート**で**JSON**を選ぶと、JSONファイルがダウンロードされます。


##### REST APIのCloudFormationに適用
エクスポートしたJSONとREST APIのCloudFormationのファイル（/amplify/backend/api/HelloApi/HelloApi-cloudformation-template.json）とで、 **paths** 以下の見比べながら適用していきます。
エクスポートしたJSONではARNなどが実際の直値担っていると思いますが、Amplify CLIで生成された他のテンプレートと同様にユーティリティ関数（Fn::Josinとか）を使って生成するように修正しながら適用します（credentials、uri、requestTemplatesなどが該当）。
また、HelloStepのテンプレートで定義したARNやロールを参照するために、 **Parameters** に書き加えます。

~~~json:/amplify/backend/api/HelloApi/HelloApi-cloudformation-template.json
    "stepFunctionHelloStepArn": {
      "Type": "String"
    },
    "stepFunctionHelloStepRoleName": {
      "Type": "String"
    }
~~~

また、APIからはHelloFuncを参照しなくなるため、関連箇所は削除しておきます。

できたら **amplify push** します。

#### REST APIからの呼び出し
API Gatewayのエンドポイントを確認してcurlで呼び出します。
Step Functionsのコンソールで実行されたことが確認できます。やった！[^2]

~~~bash
$ curl -X POST -H "Content-Type: application/json" -d '{}' ＜エンドポイントURL＞/hello
{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","startDate":1.607175965069E9}
~~~

### ステートマシンの状態を確認する
ステートマシンの実行ができたので、次は状態確認を作ります。
これは[参考ページ](https://dev.classmethod.jp/articles/apigateway-stepfunctions-asynchronous/)の **[DescribeExecution](https://docs.aws.amazon.com/ja_jp/step-functions/latest/apireference/API_DescribeExecution.html)** についての記載に従ってAPI Gatewayのコンソールでリソースを作成してもらい、それをCloudFormationのテンプレートに適用します。
これはStartExecutionと同じ感じなので割愛しますが、 **/status** のリソースを追加したとして進めます。

#### 確認用にLambdaを変更
状態を確認するために、HelloFuncを書き換えて20秒待たせます。

~~~javascript:amplify/backend/function/HelloFunc/src/index.js
exports.handler = async (event) => {
  await new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 20000);
  });

  const response = {
    statusCode: 200,
    body: JSON.stringify("Hello from Step Functions with API Gateway!"),
  };
  return response;
};
~~~

忘れずに **amplify push** しましょう。

#### REST APIからの呼び出し
状態確認のAPIである**DescribeExecution**には、**StartExecution**のレスポンスに含まれる**executionArn**を渡してあげます。
ちょっと長くて見にくいですが、**status**が**RUNNING**の状態が続いた後に**SUCCEEDED**になっています。statusには[ここで定義](https://docs.aws.amazon.com/ja_jp/step-functions/latest/apireference/API_DescribeExecution.html#StepFunctions-DescribeExecution-response-status)されているものが返るので、これで状態を確認できます。やった！[^2]

~~~bash
$ curl -X POST -H "Content-Type: application/json" -d '{}' ＜エンドポイントURL＞/hello
{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","startDate":1.607175965069E9}
$ curl -X POST -H "Content-Type: application/json" -d '{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}' ＜エンドポイントURL＞/status
{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","input":"{}","inputDetails":{"__type":"com.amazonaws.swf.base.model#CloudWatchEventsExecutionDataDetails","included":true},"name":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","startDate":1.607215992503E9,"stateMachineArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:stateMachine:HelloStep-dev","status":"RUNNING","traceHeader":"Root=1-xxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx;Sampled=1"}
$ curl -X POST -H "Content-Type: application/json" -d '{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}' ＜エンドポイントURL＞/status
{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","input":"{}","inputDetails":{"__type":"com.amazonaws.swf.base.model#CloudWatchEventsExecutionDataDetails","included":true},"name":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","startDate":1.607215992503E9,"stateMachineArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:stateMachine:HelloStep-dev","status":"RUNNING","traceHeader":"Root=1-xxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx;Sampled=1"}
$ curl -X POST -H "Content-Type: application/json" -d '{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}' ＜エンドポイントURL＞/status
{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","input":"{}","inputDetails":{"__type":"com.amazonaws.swf.base.model#CloudWatchEventsExecutionDataDetails","included":true},"name":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","startDate":1.607215992503E9,"stateMachineArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:stateMachine:HelloStep-dev","status":"RUNNING","traceHeader":"Root=1-xxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx;Sampled=1"}
$ curl -X POST -H "Content-Type: application/json" -d '{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}' ＜エンドポイントURL＞/status
{"executionArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:execution:HelloStep-dev:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","input":"{}","inputDetails":{"__type":"com.amazonaws.swf.base.model#CloudWatchEventsExecutionDataDetails","included":true},"name":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","output":"{\"statusCode\":200,\"body\":\"\\\"Hello from Step Functions with API Gateway!\\\"\"}","outputDetails":{"__type":"com.amazonaws.swf.base.model#CloudWatchEventsExecutionDataDetails","included":true},"startDate":1.607215992503E9,"stateMachineArn":"arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:stateMachine:HelloStep-dev","status":"SUCCEEDED","stopDate":1.607216012948E9,"traceHeader":"Root=1-xxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx;Sampled=1"}
~~~


### さいごに
Amplify CLIを使って、API Gateway経由でのStep Functions実行を行うことができました。
AWSのコンソールで設定したものもJSONなどで出力が比較的できるので、初心者でも試行錯誤しやすかった気もします[^3]。
Custom CloudFormation stackを使えば、色々幅が広がりそうなので、いろいろやってみたいと思います。


### 参考ページ
*  [【API Gatewayタイムアウト対策】Step Functionsを組み合わせて非同期処理にしてみる](
https://dev.classmethod.jp/articles/apigateway-stepfunctions-asynchronous/) 
* [API Gateway を使用して Step Functions API を作成する](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-api-gateway.html)
* [AWS CloudFormation を使用してStep Functions 用 Lambda ステートマシンを作成する](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-lambda-state-machine-cloudformation.html)
* [Custom CloudFormation stacks](https://docs.amplify.aws/cli/usage/customcf)
* [EXTEND AWS AMPLIFY WITH CUSTOM RESOURCES](https://cevo.com.au/post/2020-10-14-extend-aws-amplify-with-custom-resources/)


[^1]: CloudFormationを熟知していれば即書いていけばよいのだと思いますが、AWS歴4ヶ月のワタシにはハードルが高かった・・・
[^2]: **amplify push**してからエンドポイントにPOSTすると、**{"message": "Internal server error"}** や **{"message":"Missing Authentication Token"}** が返ってくる場合がありました。その際は、API Gatewayのコンソールからデプロイすると解消しました。
[^3]: しやすかったは言いすぎか？ちゃんとCloudFormationの勉強をしろって話な気がする・・・