AWS ChaliceとAWS CDKを統合して、L2 ConstructでStepFunctionsを定義してみた

# はじめに

みずほリサーチ＆テクノロジーズ株式会社 先端技術研究部の@yosuke_kosakaです。  
今回は、AWS ChaliceとAWS CDKの統合の発展形として、AWS CDKのStep FunctionsのL2 Constructを利用して、Chaliceで定義したAPIとStep Functionsを統合しました。

# そもそもAWS Chaliceとは？

サーバーレスアプリケーションをAWSで作成するためのPythonのフレームワークです。  
少量のコードで、API GatewayとLambdaを数分で作成してくれます。

【参考】
[Chalice公式](https://aws.github.io/chalice)
[爆速 API 開発を実現するサーバーレスアプリケーション開発向けフレームワーク](https://aws.amazon.com/jp/builders-flash/202003/chalice-api/?awsf.filter-name=*all)

# 前提

Chaliceの[Quick Start](https://aws.github.io/chalice/quickstart.html)の序盤の設定が完了しているものとします。

# AWS ChaliceとAWS CDKの統合

Chalice単独で利用する場合、chalice deployのコマンドでAPIのデプロイが可能ですが、  
CDKを利用することで、DynamoDB等、他の資産も含めてデプロイすることが可能となります。  

【参考】  
[Deploying with the AWS CDK — AWS Chalice](https://aws.github.io/chalice/tutorials/cdk.html)

# CDKを利用したChaliceプロジェクトの作成

Chaliceを、AWS CDKを利用してデプロイします。

Chaliceプロジェクトの新規作成時、[CDK] Rest API with a DynamoDB tableを選ぶことでCDKと統合されたプロジェクトが作成されます。

```shell
$ chalice new-project


   ___  _  _    _    _     ___  ___  ___
  / __|| || |  /_\  | |   |_ _|/ __|| __|
 | (__ | __ | / _ \ | |__  | || (__ | _|
  \___||_||_|/_/ \_\|____||___|\___||___|


The python serverless microframework for AWS allows
you to quickly create and deploy applications using
Amazon API Gateway and AWS Lambda.

Please enter the project name
[?] Enter the project name: cdk-sfntest
[?] Select your project type: [CDK] Rest API with a DynamoDB table
   REST API
   S3 Event Handler
   Lambda Functions only
   Legacy REST API Template
 > [CDK] Rest API with a DynamoDB table

Your project has been generated in ./cdk-sfntest
```

フォルダ構成

```
$ tree
.
├── infrastructure
│   ├── app.py
│   ├── cdk.json
│   ├── requirements.txt
│   └── stacks
│       ├── chaliceapp.py
│       └── __init__.py
├── README.rst
├── requirements.txt
└── runtime
    ├── app.py
    └── requirements.txt

3 directories, 9 files
```

runtime/app.py(Chaliceのコード)には以下のようなコードが初期設定されています。

- create_userメソッド
- get_userメソッド

```python:runtime/app.py
import os
import boto3
from chalice import Chalice


app = Chalice(app_name='cdk-sfntest')
dynamodb = boto3.resource('dynamodb')
dynamodb_table = dynamodb.Table(os.environ.get('APP_TABLE_NAME', ''))


@app.route('/users', methods=['POST'])
def create_user():
    request = app.current_request.json_body
    item = {
        'PK': 'User#%s' % request['username'],
        'SK': 'Profile#%s' % request['username'],
    }
    item.update(request)
    dynamodb_table.put_item(Item=item)
    return {}


@app.route('/users/{username}', methods=['GET'])
def get_user(username):
    key = {
        'PK': 'User#%s' % username,
        'SK': 'Profile#%s' % username,
    }
    item = dynamodb_table.get_item(Key=key)['Item']
    del item['PK']
    del item['SK']
    return item

```

infrastructure/stacks/chaliceapp.pyには以下のコードが初期設定されています。
__init__内でChaliceが定義されており、runtimeフォルダがソースディレクトリとして利用されています。
DynamoDBもこちらで定義されています。

```
import os

from aws_cdk import aws_dynamodb as dynamodb

try:
    from aws_cdk import core as cdk
except ImportError:
    import aws_cdk as cdk

from chalice.cdk import Chalice

RUNTIME_SOURCE_DIR = os.path.join(
    os.path.dirname(os.path.dirname(__file__)), os.pardir, 'runtime')

class ChaliceApp(cdk.Stack):

    def __init__(self, scope, id, **kwargs):
        super().__init__(scope, id, **kwargs)
        self.dynamodb_table = self._create_ddb_table()
        self.chalice = Chalice(
            self, 'ChaliceApp', source_dir=RUNTIME_SOURCE_DIR,
            stage_config={
                'environment_variables': {
                    'APP_TABLE_NAME': self.dynamodb_table.table_name
                }
            }
        )
        self.dynamodb_table.grant_read_write_data(
            self.chalice.get_role('DefaultRole')
        )

    def _create_ddb_table(self):
        dynamodb_table = dynamodb.Table(
            self, 'AppTable',
            partition_key=dynamodb.Attribute(
                name='PK', type=dynamodb.AttributeType.STRING),
            sort_key=dynamodb.Attribute(
                name='SK', type=dynamodb.AttributeType.STRING
            ),
            removal_policy=cdk.RemovalPolicy.DESTROY)
        cdk.CfnOutput(self, 'AppTableName',
                      value=dynamodb_table.table_name)
        return dynamodb_table

```

infrastructure/app.py(CDKのapp.py)には以下のコードが初期設定されています。
CDKでChaliceを利用する場合、stacks.chaliceappからChaliceAppのスタックをインポートして利用することで統合が可能です。

```python:ruinfrastructurentime/app.py
#!/usr/bin/env python3
try:
    from aws_cdk import core as cdk
except ImportError:
    import aws_cdk as cdk
from stacks.chaliceapp import ChaliceApp

app = cdk.App()
ChaliceApp(app, 'cdk-sfntest')

app.synth()
```

## CDKを利用してデプロイ

必要なライブラリをインストールします。

```shell
pip3 install aws-cdk-lib
pip3 install boto3
```

初期状態のままデプロイしてみます。infrastructureフォルダでcdk deployを実行します。

```shell
cd cdk-sfntest/infrastructure
cdk deploy

Do you wish to deploy these changes (y/n)? y
cdk-sfntest: deploying... [1/1]

・・・

✨  Total time: 114.9s
```

完了すると以下のリソースがCDKによって作成されます。

- API Gateway
- Lambda
- DynamoDB

## ChaliceのAPIを、CDKのStep Funtionsで利用する

infrastructure/stacks/sfn_stack.pyを作成し、以下のコードを入力します。

```python:infrastructure/stacks/sfn_stack.py
from aws_cdk import (
    Stack,
    aws_stepfunctions_tasks as tasks,
    aws_stepfunctions as sfn,
    aws_apigateway,
)


class StepfunctionsStack(Stack):
    def __init__(self, scope, id: str, api_stack: any, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # スタック内のAPIを取得
        user_api = aws_apigateway.RestApi.from_rest_api_id(
            self, "user", api_stack.chalice.get_resource("RestAPI").ref
        )

        # ユーザー作成
        create_user_task = tasks.CallApiGatewayRestApiEndpoint(
            self,
            id="ユーザー作成",
            api=user_api,
            stage_name="api",
            method=tasks.HttpMethod.POST,
            api_path="/users",
            auth_type=tasks.AuthType.IAM_ROLE,
            request_body=sfn.TaskInput.from_json_path_at("$"),
            input_path="$",
            result_path="$.CreateResult",
            headers=sfn.TaskInput.from_object({
              "Content-Type": [
                "application/json"
              ]
            })
        )

        # ユーザー取得
        get_user_task = tasks.CallApiGatewayRestApiEndpoint(
            self,
            id="ユーザー取得",
            api=user_api,
            stage_name="api",
            method=tasks.HttpMethod.GET,
            api_path=sfn.JsonPath.format(
                "/users/{}", sfn.JsonPath.string_at("$.username")
            ),
            auth_type=tasks.AuthType.IAM_ROLE,
            request_body=sfn.TaskInput.from_json_path_at("$"),
            input_path="$",
            headers=sfn.TaskInput.from_object({
              "Content-Type": [
                "application/json"
              ]
            }
            )
        )

        # definition作成
        definition = sfn.Chain.start(create_user_task).next(
            get_user_task
        )

        # StateMachine定義
        sfn.StateMachine(
            self,
            "UserCreateStateMachine",
            definition=definition,
            state_machine_type=sfn.StateMachineType.STANDARD,
        )

```

AWS CDKのStep FunctionsとChalice APIとの統合のポイントは以下です。

## get_resource

[Chalice #get_resource](https://aws.github.io/chalice/api.html?highlight=get_resource#cdk.Chalice.get_resource)
によると、get_resource(resource_name)によってChaliceで作成されたリソースをCDKのリソースとして取り込み可能です。

しかし、単純にget_resourceでAPIを取得すると、aws_cdk.cdk.CfnResource型で返されてしまい、L1のコンストラクトになってしまいます。
このままでは、StepFunctionsのL2コンストラクトが使えず、Step Functionsのステートマシン定義はJSONで記入する必要が出てしまいます。

## from_rest_api_idの利用

aws_apigateway.RestApiのfrom_rest_api_idメソッドを使うことで、APIのIDから取り込むことが可能です。
これを利用して、以下のように定義しました。
※RESTAPIというリソース名で定義されています。

```
        # スタック内のAPIを取得
        user_api = aws_apigateway.RestApi.from_rest_api_id(
            self, "user", api_stack.chalice.get_resource("RestAPI").ref
        )
```

## taskで利用

tasksの、CallApiGatewayRestApiEndpoint内のapiに、上記from_rest_api_idで取得したuser_apiを指定します。
こうすることでAWS CDKのStep FunctionsとChalice APIとの統合が完了します。

```
        # ユーザー作成
        create_user_task = tasks.CallApiGatewayRestApiEndpoint(
            self,
            id="ユーザー作成",
            api=user_api,
            stage_name="api",
            method=tasks.HttpMethod.POST,
            api_path="/users",
            auth_type=tasks.AuthType.IAM_ROLE,
            request_body=sfn.TaskInput.from_json_path_at("$"),
            input_path="$",
            result_path="$.CreateResult",
            headers=sfn.TaskInput.from_object({
              "Content-Type": [
                "application/json"
              ]
            })
        )
```

## StateMachineを定義

必要なtaskを定義できたら、後はdefinitionとStateMachineを定義して完成です。
JSONで定義するよりも簡単にStateMachineを利用できるので、CDKの恩恵を受けられます。

```
        # definition作成
        definition = sfn.Chain.start(create_user_task).next(
            get_user_task
        )

        # StateMachine定義
        sfn.StateMachine(
            self,
            "UserCreateStateMachine",
            definition=definition,
            state_machine_type=sfn.StateMachineType.STANDARD,
        )
```

## infrastructure/app.pyの修正

Chaliceスタックを変数に格納し、作成したStepFunctionsのスタックを追加します。

```
#!/usr/bin/env python3
try:
    from aws_cdk import core as cdk
except ImportError:
    import aws_cdk as cdk
from stacks.chaliceapp import ChaliceApp
from stacks.sfn_stack import StepfunctionsStack


app = cdk.App()
# Chaliceのスタックを変数に格納
api_stack = ChaliceApp(app, 'cdk-sfntest')

# ChaliceスタックをStepFunctionsスタックに渡す
StepfunctionsStack(app,'sfn-stack',api_stack)

app.synth()
```

## デプロイ前

```
pip install -r requirements.txt
cd ./infrastructure
pip install -r requirements.txt
```

## デプロイ

infrastructureフォルダでcdk deployを実行します。

```
cd infrastructure
cdk deploy --all

✨  Deployment time: 93.08s

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:XXXXXXXXXXXX:stack/sfn-stack/XXXXXXXXXXXXXXXXXXXXXXXXXX

✨  Total time: 106.1s
```

## 確認

ステートマシンが作成されていることが確認できます。

![statemachine.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989049/47f28854-d3b9-baec-2743-803fed57bf9b.png)

※今回はL2コンストラクトを利用してステートマシンを定義することに主観を置いており、作ったステートマシンは単純にAPIを繋いだだけのものですので、動作に関しては検証しておりません。

# まとめ

今回はChalice+CDKを利用してステートマシンを作る方法をご紹介しました。
CDKのL2以上のコンストラクタは非常に便利に作られており、利用することで開発効率も上げることが可能ですので、
Step Functionsに限らず、他のサービスとも統合してより効率的に開発していきましょう。