Amplify REST APIを使ったWebアプリ開発体験談

この記事は[BeeX Advent Calendar 2022](https://qiita.com/advent-calendar/2022/beex)の10日目です。

# 概要
AmplifyといえばバックエンドはAppSync(GraphQL)を利用した形が多いと思いますが、今回REST APIを使う必要があり、その開発を行なった際のメリット・工夫が必要だった部分の体験談を紹介します。

# Amplifyとは
フロントエンドのウェブ/モバイルアプリケーションを簡単・最速で構築するためのプラットフォーム。
3年以上前から使用できるので、技術的なところで新しくはなく、使い方などは他の記事を見て頂ければと思います。

# 今回紹介する構成
ユーザ毎に認証を行い、ファイルアップロードとそれに紐づく情報を保持し、管理者が一括でファイルをダウンロードできるようシステム開発を行いました。
構成としては、よくある構成だと思いますが、IaCを使うにしてもコード管理するにはある程度時間が掛かります。
この構成であればAmplifyならスムーズに作れます。

## Amplifyのメリットに感じた点
Amplifyを実際に使ったメリットについて自分の主観を記載したいと思います。
### 環境を簡単に作成可能
基本的には `amplify env add {環境名}`にて追加したのち `amplify push`をすれば、新しい環境にWebやCognitoなどAWSリソースがが出来上がる。
使い捨ての環境を作成し、利用終了後は`amplify env remove {環境名}`するだけ。
### serverless-expressが便利
`amplify add function`からLambdaのランタイムをNodeJSを選択した場合に、serverless-serverless-expressのテンプレートのコードが自動生成されます。
[serverless-express](https://github.com/vendia/serverless-express)はexpressをLambda上で起動できるようにするライブラリで、expressのルーティング機能を使って関数を切り替えできます。
その場合、API-Gatewayは対象のPath以下をProxyさせる形になります。
Lambdaを使用するとリクエスト数が増えるとコールドスタートの可能性が増えますが、コンテナよりコストパフォーマンスを優先する場合では十分に使えるのではないのでしょうか？
サンプルコードを載せたかったのですが、記事がかなり長くなってしまうので、ここでは割愛します。

### Amplifyから作成されたリソースは管理しなくて良い
+ フロントエンド
AWSリソースへの接続は`aws-exports.js`が使用されますが、ビルド時にAmplifyが自動生成するため、特に管理する必要がありません。
また `amplify env checkout {環境名}`など環境を変更をすると、ローカル側にも`aws-exports.js`がダウンロードされます。
（.gitignoreには含まれるので、コミットされることはありません）

+ バックエンド
DynamoDBなどバックエンド側からAWSにリソースを接続する場合は、`amplify update function`から`Resource access permissions`を行い、必要な権限をfunctionに追加すると、環境変数から利用できるようになります。エントリーポイントのapp.jsに使用できる環境変数名が自動で追記されます。環境毎にARNを設定する必要がありません。

``` ts
/* Amplify Params - DO NOT EDIT
	AUTH_USERPOOLXXXXXXX_USERPOOLID
	ENV
	REGION
	STORAGE_GAMESCORES_ARN
    STORAGE_GAMESCORES_NAME
	STORAGE_GAMESCORES_STREAMARN
Amplify Params - DO NOT EDIT */

...
```

### フロントエンドからAWSリソースへのアクセス
Amplifyには[amplify library](https://docs.amplify.aws/lib/q/platform/js/)が提供されています。このlibraryを利用することでフロント側から直接AWSのリソースにアクセス可能になります。
例えば、特定のS3にファイルを保存したい場合に、バックエンドを通さずに`Storage.put()`を使うだけでアップロードが可能です。
アクセスレベルを設定できるので、
内部的にはフェデレーティッドアイデンティティーから設定した有効期限の一時認証キーを使っているようです。

## 工夫が必要だった点
### Amplifyコンソール上とGitとの同期問題
他のIaCツールも同様ですが、ローカルからPushすると、どのバージョンのソースが反映しているかわからなくなります。
また Amplifyの場合、環境を追加すると、`amplify/team-provider-info.json`に環境毎のARNなどが追記されるのですが、追記した情報をロストするとその環境に対してアクセスできなくなります。
amplifyからリソースに変更があってもローカルからの`amplify push`を行わず、変更分のソースをCommit・PushするだけでAmplifyのCIに任せるようにしました。
（厳密には`amplify push`を禁止にはしていないです。おそらくIAM Userに割り当てているポリシーで制御できるのではと思っています）

## デプロイが遅い問題
AWSリソースとAmplifyの設定ファイルの差分をチェックしている影響なのか、他のツールに比べてデプロイがかなり遅い印象があります。Lambdaの1行更新するにも10分ぐらい待たされるのは苦痛なので、ローカルではexpressからAPIを実行できるようにし、ローカルからもLambda上でも大体同様の結果を返せるようにしました。
ExpressとAPI Gatewayではhandlerに渡されるEventが異なるため、どちらも同じリクエストになるようにマッピングを作成しました。
ローカルにもDynamoDBが必要なので、LocakStackを導入しました。
ローカル環境を整備することで、AWS環境にデプロイしなくても確認できるようになり、開発がだいぶ楽になりました。
AppSyncだとまともに使えそうなmockが用意されているので、こちらを使えば良いかなと思います。

## Cognito Authroizerを指定するためにはoverrideが必要
AppSyncでは設定できるのですが、REST APIではapi追加だけではcognito authroizerはサポートされていません。
https://github.com/aws-amplify/amplify-category-api/issues/345#issuecomment-1129358065
ここに記載されているようにoverrideを追加する必要があります。
`amplify api override`コマンドを実行し、override.tsファイルを作成したのち、以下のように修正します。
"auth<name for auth>"の部分には`amplify add auth`で追加したauth nameを指定します。

``` ts
export function override(resources: AmplifyApiRestResourceStackTemplate) {
  // Add a parameter to your Cloud Formation Template for the User Pool's ID
  resources.addCfnParameter(
    {
      type: 'String',
      description:
        'The id of an existing User Pool to connect. If this is changed, a user pool will not be created for you.',
      default: 'NONE',
    },
    'AuthCognitoUserPoolId',
    { 'Fn::GetAtt': ["auth<name for auth>", 'Outputs.UserPoolId'] }
  );

  // Create the authorizer using the AuthCognitoUserPoolId parameter defined above
  resources.restApi.addPropertyOverride('Body.securityDefinitions', {
    Cognito: {
      type: 'apiKey',
      name: 'Authorization',
      in: 'header',
      'x-amazon-apigateway-authtype': 'cognito_user_pools',
      'x-amazon-apigateway-authorizer': {
        type: 'cognito_user_pools',
        providerARNs: [
          {
            'Fn::Sub':
              'arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${AuthCognitoUserPoolId}',
          },
        ],
      },
    },
  });

  // For every path in our REST API
  for (const path in resources.restApi.body.paths) {
    // Add the Authorization header as a parameter to requests
    resources.restApi.addPropertyOverride(
      `Body.paths.${path}.x-amazon-apigateway-any-method.parameters`,
      [
        ...resources.restApi.body.paths[path]['x-amazon-apigateway-any-method'].parameters,
        {
          name: 'Authorization',
          in: 'header',
          required: false,
          type: 'string',
        },
      ]
    );
    // Use our new Cognito User Pool authorizer for security
    resources.restApi.addPropertyOverride(
      `Body.paths.${path}.x-amazon-apigateway-any-method.security`,
      [{ Cognito: [] }]
    );
  }
}
```
フロント側のApp.tsは以下のように修正し、リクエスト実行時にTokenを渡すようにしています。
``` ts
import { Amplify } from 'aws-amplify';

import awsExports from './aws-exports';

Amplify.configure({
  ...awsExports,
  aws_cloud_logic_custom: awsExports.aws_cloud_logic_custom.map((api) => ({
    ...api,
    custom_header: async () => ({
      Authorization: `Bearer ${(await Auth.currentSession()).getIdToken().getJwtToken()}`,
    }),
  })),
});
```

## DynamoDBのインデックス
`amplify add storage`からDynamoDBのGSI、SLIの設定可能です。ただオンデマンドを選択できません。globalSecondaryIndexesの中で `readCapacityUnits`、`writeCapacityUnits`を0にする必要があります。
また既存のGSIに対して変更を加えると`Cannot perform more than one GSI creation or deletion in a single update`というエラーが発生します。
これはAmplifyに限った話ではないですが、削除してから再作成とデプロイを2回に分ける必要があります。
AppSync経由でアノテーションを付ける形であれば対応しているようです。
https://aws.amazon.com/jp/about-aws/whats-new/2021/04/aws-amplify-orchestrates-multiple-amazon-dynamodb-gsi-updates-single-deployment/

``` ts
export function override(resources: AmplifyDDBResourceTemplate) {
  delete resources.dynamoDBTable.provisionedThroughput;
  resources.dynamoDBTable.billingMode = 'PAY_PER_REQUEST';
  resources.dynamoDBTable.globalSecondaryIndexes = [
    {
      contributorInsightsSpecification: {
        enabled: true,
      },
      indexName: 'GameTitleIndex',
      keySchema: [
        {
          attributeName: 'GameTitle',
          keyType: 'HASH',
        },
        {
          attributeName: 'TopScore',
          keyType: 'RANGE',
        },
      ],
      projection: {
        projectionType: 'ALL',
      },
      provisionedThroughput: {
        readCapacityUnits: 0,
        writeCapacityUnits: 0,
      },
    },
  ];
}
```

# まとめ
Amplify使用してのバックエンド側の開発は初めてだったのですが、簡単な対話式で構築できたり、便利だと思った反面、細かい設定を加える場面ではoverrideする必要なケースが結構出てきました。あとAmplifyのリソース外を管理できる`amplify add custom`を使いそうな場面がありましたが、これらは複雑性が増すので、極力控えたほうが良いと思いました。
Amplifyの守備範囲以外が出てきた場合はTerraformなどでIaC化したほうが良さそうです。

とはいえ、スタートアップの速さ、環境を簡単に複製できる点や、Amplify Libraryとの統合などはAmplifyに強みがあると思います。AppSyncに比べ、REST APIは若干置いていかれている感があるので、次回はAppSyncを使ってみたいと思います。

今回サンプルコードを記載しながら説明したかったのですが、あまりにもボリュームが増えてしまったので、説明だけにしました。読みづらい長文失礼しました。