AWS Amplify / AppSyncで画像投稿webアプリのサーバサイドを実装する

アドベントカレンダーも中盤に差し掛かってきました。
今回は画像投稿アプリのサーバサイド（ユーザー認証・GraphQLによるAPI通信）をAWSのサービス`Amplify`・`AppSync`を主に活用して実装していきます。
一応、以下記事の続編という形で投稿を行っていますが、途中までは`Amplify`と`AppSync`の概要説明を比較的シンプルな実装例を用いて行います。

> [React Hooks ✕ AtomicDesignで画像投稿webアプリのフロントエンドを実装する](https://qiita.com/stranger1989/items/74b23062fa25e975af94)

# <font color="LightSeaGreen">AWS Amplifyとは</font>
> [AWS Amplify](https://aws.amazon.com/jp/amplify/) 公式
> AWS Amplify は、AWS を使用したスケーラブルなモバイルアプリおよびウェブアプリの作成、設定、実装を容易にします。Amplify はモバイルバックエンドをシームレスにプロビジョニングして管理し、バックエンドを iOS、Android、ウェブ、React Native のフロントエンドと簡単に統合するためのシンプルなフレームワークを提供します。また、Amplify は、フロントエンドとバックエンドの両方のアプリケーションリリースプロセスを自動化し、機能をより迅速に提供することができます。

簡単に言えば、モバイル・Webアプリケーションのサーバサイド（認証・API通信・DB操作・S3操作など）を超手軽に実装できるサービスです。

# <font color="LightSeaGreen">AWS Amplify CLIのインストール・初期設定</font>
aws amplify cli はnode moduleで提供されているので、グローバルにインストールしておきます。

```
$ npm install -g @aws-amplify/cli
```

インストール後に、AWSの初期設定（IAMユーザー作成）していきます。
以下コマンドを打つことで対話形式で設定を行うことができます。

```
$ amplify configure
```
```
$ Sign in to your AWS administrator account:
$ https://console.aws.amazon.com/
// awsのログイン画面に移動するので、ログイン後Enterをクリック
$ Specify the AWS Region
? region:  ap-northeast-1
Specify the username of the new IAM user:
? user name:  (amplify-XXXXX)
// IAM作成画面に移動するので、IAMユーザーでログイン後Enterをクリック。IAMユーザーを作成します。
// そのあと、上で作成したIAMユーザーのアクセス・シークレットキーを入力
$ Enter the access key of the newly created user:
? accessKeyId:  YYYYYYYYYYYYY**********
? secretAccessKey:  YYYYYYaaaaaaaaaa********************
This would update/create the AWS Profile in your local machine 
? Profile Name:  amplify_user
```

AWSからIAMにアクセスすると「ユーザー」に新規で今回登録したユーザーが登録されているのが確認できます。
<img width="714" alt="346000045bbfd45ad72390bea4e1c351.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/dd26e70d-0c8b-5664-c730-b0aee8436605.png">

とりあえずReactアプリのテンプレートを生成しておきます。

```shell
$ npx create-react-app amplify-js-app --typescript && cd amplify-js-app 
```

生成したReactアプリのルート上でamplifyでサーバサイドの初期設定をしていきます。
こちらも以下コマンドを打つことで回答形式で設定を行うことができます。
全ての回答が終わると構成が自動生成され、ルート配下の`amplify`フォルダに設定が保存されます。

```
$ amplify init
```

```
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project amplify-js-app
? Enter a name for the environment amplifyenv
? Choose your default editor: None
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using none
? Source Directory Path:  src
? Distribution Directory Path: dist
? Build Command:  npm run build
? Start Command: npm start
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use amplify_user
```

初期設定を行っただけなので、現状何の機能も登録されていない状態です。以下コマンドで確認します。

```
$ amplify status
| Category | Resource name | Operation | Provider plugin |
| -------- | ------------- | --------- | --------------- |
```

# <font color="LightSeaGreen">AWS Amplify AuthでCognitoユーザー認証機能を実装してみる</font>
上記までで初期設定は終わりました。しかし、現状では機能が空の状態かつAWS上にもなにも作成されていません。
とりあえずどんなアプリにも必要と思われる「ユーザー認証（Auth）機能」を作成しながら、amplifyの検証を行います。
今回は一番簡単な設定にするため、SNS認証機能などは作成しない前提で進めます。

## Amplify Auth による AWS Cognito 設定
`amplify add { 導入したい機能 }`で環境構築したい機能をAWSに追加していくことができます。

```
$ amplify add auth
```
```
 Using service: Cognito, provided by: awscloudformation
 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 // E-mailを必須項目にする
 How do you want users to be able to sign in? Email
 // 追加設定はしない
 Do you want to configure advanced settings? No, I am done.
Successfully added resource amplifyjsappxxxxxxx locally
```
追加されたか確認

```
$ amplify status
| Category | Resource name        | Operation | Provider plugin   |
| -------- | -------------------- | --------- | ----------------- |
| Auth     | amplifyjsappxxxxxxx | Create    | awscloudformation |
```

ローカルにauthの設定がされましたがまだAWSには反映されていません。以下コマンドでAWSにデプロイします。
（成功すれば裏で`CloudFormation`が動き設定が反映されていきます。）

```
amplify push
```

デプロイが完了後に`AWS Cognito`の`ユーザープール`にアクセスし、設定どおり新規生成されているのを確認します。
<img width="1233" alt="ユーザープール_-_Amazon_Cognito-2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/a521b367-066a-9b34-82f9-a7ab022dea59.png">

## フロントエンドからCognito認証する
フロントエンドのサインアップ・ログイン・パスワード変更機能を実装していきます。とはいっても、`aws-amplify-react`というマテリアルデザインのコンポーネントを手軽に使えるのでデザインにこだわりがなければかなり楽に実装できます。

まず、amplifyをreactで使うためのモジュールをインストールします

```shell
$ npm i aws-amplify aws-amplify-react
```

aws-amplify-reactとtypescriptを組み合わせて使うと、型定義でエラーが出てしまうため型を別途作成します。

```typescript:src/@types/aws-amplify-react.d.ts
declare module 'aws-amplify-react';
```
アプリのルートコンポーネントで`Cognito`の設定を定義します。
（今回はシンプルに見せるためにコンポーネントファイルに直書きしていますが、実運用ではセキュリティの観点から`configure`は別ファイルから読み込みかつIDなどは`.env`から読み込み外部から見えないようにする必要があります。）

```typescript:src/App.tsx
import React from 'react';

import Amplify from 'aws-amplify';
import { Authenticator } from 'aws-amplify-react';

Amplify.configure({
    Auth: {
        // REQUIRED - Amazon Cognito Identity Pool ID
        // 「ユーザープール」ではなく「フェレデーティットアイデンティティ」の「サンプルコード」から確認できます
        identityPoolId: 'XX-XXXX-X:XXXXXXXX-XXXX-1234-abcd-1234567890ab',

        // REQUIRED - Amazon Cognito Region
        // Cognitoを使用しているリージョンです
        region: 'XX-XXXX-X',

        // OPTIONAL - Amazon Cognito User Pool ID
        // 「ユーザープール」の「プールID」です
        userPoolId: 'XX-XXXX-X_abcd1234',

        // OPTIONAL - Amazon Cognito Web Client ID
        // 「ユーザープール」の「アプリクライアント」の「xxxxxx_app_clientWeb」のIDです
        userPoolWebClientId: 'XX-XXXX-X_abcd1234',
    }
});

const App: React.FC = () => <Authenticator />;

export default App;
```
たったこれだけのコードで、「サインアップ」・「eメールへの登録認証コードの送信」・「サインイン」・「サインアウト」・「パスワードの変更」機能を実装できてしまいます。AWS恐るべしですね。
![7e9187c0ad871b77beea23c691fc5c2c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/e65e0b6f-1de4-2bf4-ee4b-f34743553eff.gif)
<img width="748" alt="77d14f44cc82df05d4f570e400f594e2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/32d4ba39-1f60-b54c-15c0-125a2472b666.png">

ユーザー認証機能単体なら手軽に実装することができました。
しかし、Authコンポーネントをカスタマイズしたり、Reduxのフローにのせようとすると`Authenticator`のコンポーネント・ファンクションを分解して再度組み立てる必要があるため難易度が上がります。
ここでは、説明が長くなってしまうため説明は割愛しますが、近いうちにまとめて記事にする予定です。

# <font color="LightSeaGreen">AWS AppSyncとは</font>
> [AWS AppSync](https://aws.amazon.com/jp/appsync/) 公式
> AWS AppSync を使用すると、1 つ以上のデータソースからのデータに安全にアクセス、操作、結合するための柔軟な API を作成でき、アプリケーション開発がシンプルになります。AppSync は、**GraphQL** を使用してアプリケーションが必要なデータを正確に取得できるようにするマネージド型サービスです。

# <font color="LightSeaGreen">なぜREST APIではなくGraphQLを使うか</font>
単に話題のGraphQLを使ってみたかったというだけです。
REST APIと比較してもGraphQLの方が圧倒的に優れているということはないようです。
一応参考までにメリット・デメリットをまとめてみました。

## メリット
- エンドポイントが一つしかなく、管理がしやすい（REST APIの場合うまく設計しないとエンドポイントが増え続ける）
- フロントエンドからクエリを使用して必要な分だけデータを返すことができる
- 単一のエンドポイントから全てのデータにアクセスできる（簡単にリレーションを構築する）
- 結果的にAPIリクエストの回数を削減することができる

## デメリット
- エンドポイントが1つしかないので、細かいログ・キャッシュモニタリングが難しい
- クライアント側でクエリを記述しなければならないため、RESTよりもクライアント側のコードがファットになる
- エラーの場合もHTTPヘッダのレスポンス 200OKが返ってくるのでRESTのエラーハンドリングよりも複雑になる
- 「N+1」問題は緩和されない。設計時に検討する必要がある。

# <font color="LightSeaGreen">AWS AppSyncでGraphQLサーバを実装してみる</font>

## Amplify Api による AWS AppSync 環境構築

```
$ amplify add api
```

```
? Please select from one of the below mentioned services: GraphQL
? Provide API name: amplifyjsapp
? Choose the default authorization type for the API Amazon Cognito User Pool
Use a Cognito user pool configured as a part of this project.
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? No
? Provide a custom type name MyType
Creating a base schema for you...

The following types do not have '@auth' enabled. Consider using @auth with @model
	 - MyType
```
追加されたか確認

```
$ amplify status
| Category | Resource name        | Operation | Provider plugin   |
| -------- | -------------------- | --------- | ----------------- |
| Api      | amplifyjsapp         | Create    | awscloudformation |
| Auth     | amplifyjsappxxxxxxxx | No Change | awscloudformation |
```
AppSync設定をデプロイ

```
$ amplify push
```
```
✔ Successfully pulled backend environment amplifyenv from the cloud.

Current Environment: amplifyenv

| Category | Resource name        | Operation | Provider plugin   |
| -------- | -------------------- | --------- | ----------------- |
| Api      | amplifyjsapp         | Create    | awscloudformation |
| Auth     | amplifyjsappxxxxxxxx | No Change | awscloudformation |
? Are you sure you want to continue? Yes

The following types do not have '@auth' enabled. Consider using @auth with @model
	 - MyType
Learn more about @auth here: https://aws-amplify.github.io/docs/cli-toolchain/graphql#auth 


GraphQL schema compiled successfully.

Edit your schema at /xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/schema.graphql or place .graphql files in a directory at /xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/schema
? Do you want to generate code for your newly created GraphQL API No
```

ブラウザから`AWS AppSync`にアクセスして想定どおり環境が構築されているか確認します。DynamoDBにも同時にデータリソースが生成されています。
<img width="1396" alt="fa3964e011bf20773f382641ec04d548.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/7da82a91-74c1-03ed-a032-42d6e7ed35b3.png">

## ひとまずテストクエリを実行してみる
AWS AppSyncコンソール画面のクエリから簡単にクエリのテストを行うことができます。サンプルでスキーマ&リソースDBが生成されていると思うので、こちらを利用してでひとまず挙動を確認します。今回はAPI認証に`Amazon Cognito User Pool`を使用したので、あらかじめ`Amplify Auth`で作ったテストユーザーでログインしておきましょう。（クエリの実行ボタンの右横にログインボタンがあります）
### Mutation
`mutation`でデータの`作成`・`更新`・`削除`などいわゆる「更新系」の処理を行います。
![f6258d1da84aab3239cef4954c324ab3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/02e6ef87-ac50-01a1-8d0d-69af4e71b6e7.gif)
DymanoDBにも記録されていることを確認します。
<img width="1036" alt="0de1b1799e696a3a6c96918c42a78a06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/4d7a46fe-bdb1-2641-e4c5-9ca1441a1703.png">

### Query
次は`query`を試してみます。`リスト表示`などのいわゆる「参照系」の処理を行います。graphQLの強みとして欲しいデータをクエリで指定することができます（`id`と`title`だけ取得など）。
![395a26820ba2c57c127a826c5e835017.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/c525edac-7178-33b8-f4aa-b56a7fbb4ef1.gif)

# <font color="LightSeaGreen">AWS AppSyncで画像投稿webアプリのサーバサイドを実装する</font>
ここからが本番です。
画像投稿アプリの実装を通して、AppSyncを利用したサーバサイド開発について検証します。

## アプリ全体構成図
今更感がありますが、サーバサイドの構成も固まってきたところで全体の構成図を載せておきます。
![ad737f1a2be16d345a96e0ae083bd9f6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/dc92d28d-9ad8-b481-8545-2f221d777b7c.png)


## データベースのER図
単純なアプリケーションかつ、ユーザー管理は`Cognito`にまかせてあるため、ER図はシンプルです。画像投稿アプリのため、1つのアルバムに対して1つ以上の画像が投稿されているという条件にしました。`AlbumPicture`の`albumId`を外部キーとしてリレーションシップを構築します。
また、`AlbumPicture`の`file`にはjson形式でS3の画像を特定できる情報を登録します。

```
  "file": {
    "bucket": "yyyyyyyyyyyyyyyyyyyyyy", // バケット名
    "key": "xxxxxxxxxxxxxxxxxxxxxxx", // 画像名（キー）
    "region": "ap-northeast-1" //リージョン名
  },
```

![364392dd2aa37a4f4637f54189f8c058.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/5f060889-d102-ad7c-72f5-e847becdc779.png)




## AppSyncにスキーマ・リレーション・サブスクリプションの記述を行う
### Schema（データベース構造）
上記ER図で示した、DBの構造をスキーマに記載します。

```
type Album {
	id: ID!
	title: String!
	visible: String!
	picture: [AlbumPicture]
	note: String
	owner: String!
	createdAt: String!
}

type AlbumConnection {
	items: [Album]
	nextToken: String
}

type AlbumPicture {
	id: ID!
	albumId: ID!
	name: String!
	file: S3Object!
	createdAt: String!
}

type AlbumPictureConnection {
	items: [AlbumPicture]
	nextToken: String
}

input CreateAlbumInput {
	title: String!
	visible: String!
	note: String
	owner: String!
	createdAt: String!
}

input CreateAlbumPictureInput {
	albumId: ID!
	name: String!
	file: S3ObjectInput!
	createdAt: String!
}

input DeleteAlbumInput {
	id: ID!
}

input DeleteAlbumPictureInput {
	id: ID!
}

type Mutation {
	createAlbumPicture(input: CreateAlbumPictureInput!): AlbumPicture
	updateAlbumPicture(input: UpdateAlbumPictureInput!): AlbumPicture
	deleteAlbumPicture(input: DeleteAlbumPictureInput!): AlbumPicture
	createAlbum(input: CreateAlbumInput!): Album
	updateAlbum(input: UpdateAlbumInput!): Album
	deleteAlbum(input: DeleteAlbumInput!): Album
}

type Query {
	getAlbumPicture(id: ID!): AlbumPicture
	listAlbumPictures(filter: TableAlbumPictureFilterInput, limit: Int, nextToken: String): AlbumPictureConnection
	getAlbum(id: ID!): Album
	listAlbums(filter: TableAlbumFilterInput, limit: Int, nextToken: String): AlbumConnection
}

type S3Object {
	bucket: String!
	region: String!
	key: String!
}

input S3ObjectInput {
	bucket: String!
	region: String!
	key: String!
}

input TableAlbumFilterInput {
	id: TableIDFilterInput
	title: TableStringFilterInput
	visible: TableStringFilterInput
	note: TableStringFilterInput
	owner: TableStringFilterInput
	createdAt: TableStringFilterInput
}

input TableAlbumPictureFilterInput {
	id: TableIDFilterInput
	albumId: TableIDFilterInput
	name: TableStringFilterInput
	createdAt: TableStringFilterInput
}

input TableBooleanFilterInput {
	ne: Boolean
	eq: Boolean
}

input TableFloatFilterInput {
	ne: Float
	eq: Float
	le: Float
	lt: Float
	ge: Float
	gt: Float
	contains: Float
	notContains: Float
	between: [Float]
}

input TableIDFilterInput {
	ne: ID
	eq: ID
	le: ID
	lt: ID
	ge: ID
	gt: ID
	contains: ID
	notContains: ID
	between: [ID]
	beginsWith: ID
}

input TableIntFilterInput {
	ne: Int
	eq: Int
	le: Int
	lt: Int
	ge: Int
	gt: Int
	contains: Int
	notContains: Int
	between: [Int]
}

input TableStringFilterInput {
	ne: String
	eq: String
	le: String
	lt: String
	ge: String
	gt: String
	contains: String
	notContains: String
	between: [String]
	beginsWith: String
}

input UpdateAlbumInput {
	id: ID!
	title: String
	visible: String
	note: String
	owner: String
	createdAt: String
}

input UpdateAlbumPictureInput {
	id: ID!
	albumId: ID
	name: String
	createdAt: String
}
```

### Resolver（データベースマッピング・リレーション）
「アルバムに**紐付いた**、複数枚の画像をアップロード・表示することができる」機能を実現させるためには`Album`テーブルと`AlbumPicture`テーブルとのリレーションシップを構成する必要があります。リレーションを実現するためには`リゾルバー`を書き換える必要があります。`schema`、`resolvers`から`AlbumPictureTable`に対して「アタッチ」を行います。記述は`VTL`言語指定なので、それに従ってリレーションシップの記述を行います。
<img width="1354" alt="AWS_AppSync_Console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/ec3ac3ac-f7f3-d524-42df-1ec7fbdd15ba.png">

```vtl:リクエストマッピングテンプレート
{
    "version": "2017-02-28",
    "operation": "Query",
    "index": "albumId-index",
    "query": {
        "expression": "albumId = :albumId",
        "expressionValues": {
            ":albumId": { "S": "$ctx.source.id" }
        }
    }
}
```
```vtl:レスポンスマッピングテンプレート
$util.toJson($ctx.result.items)
```
リゾルバーの設定はこれで完了です。しかし、上記記述にもあるとおりDynamoDB`AlbumPictureTable`にグローバルセカンダリインデックスを設定しないと`albumId`で抽出することができません。DynamoDBの「インデックス」タブから設定を行います。
<img width="929" alt="ddc2df1a4f45cd9a3b8f8f14c16a5bbf.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/9dec6d3b-9ca3-8a0a-80d8-d26cc30bb7b1.png">

### Subscription（リアルタイム更新）
「複数デバイスでアクセスした時に、一方のデバイスでアルバムを「投稿」or「削除」or「更新」すると、もう一方のデバイスでリアルタイム更新される」機能を実装するために、クライアントからのAPIリクエストを検知して、サーバサイドから他のデバイスにも結果を返すという処理が必要になります。AppSyncでは`Subscription`の記述をスキーマにしておくだけで簡単にチャットのようなリアルタイムアップデートが実現できます。

```
type Subscription {
	onCreateAlbumPicture(
		id: ID,
		albumId: ID,
		name: String,
		createdAt: String
	): AlbumPicture
		@aws_subscribe(mutations: ["createAlbumPicture"])
	onUpdateAlbumPicture(
		id: ID,
		albumId: ID,
		name: String,
		createdAt: String
	): AlbumPicture
		@aws_subscribe(mutations: ["updateAlbumPicture"])
	onDeleteAlbumPicture(
		id: ID,
		albumId: ID,
		name: String,
		createdAt: String
	): AlbumPicture
		@aws_subscribe(mutations: ["deleteAlbumPicture"])
	onCreateAlbum(
		id: ID,
		title: String,
		visible: String,
		note: String,
		owner: String
	): Album
		@aws_subscribe(mutations: ["createAlbum"])
	onUpdateAlbum(
		id: ID,
		title: String,
		visible: String,
		note: String,
		owner: String
	): Album
		@aws_subscribe(mutations: ["updateAlbum"])
	onDeleteAlbum(
		id: ID,
		title: String,
		visible: String,
		note: String,
		owner: String
	): Album
		@aws_subscribe(mutations: ["deleteAlbum"])
}
```

# <font color="LightSeaGreen">React Reduxで状態管理を行い、AppSyncに対しクエリを送ってみる</font>
ここまでの実装でサーバサイドの準備は整いました。
いよいよクライアントから、今回構築したAppSync（GraphQL）のエンドポイントに対して、クエリリクエストを送ってみます。
RESTAPIでもGraphQLでもAPI通信には変わらないので、Redexにおける非同期通信のフローは全く同じです。
![redux_image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/c863e606-b906-f4eb-1799-07e3480b10ee.png)

## まずはReduxのstore・reducer設定から
全てを掲載すると長くなってしまうため、今回は`アルバム`機能のみに限定しました。

```typescript:src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store/configureStore';
import './index.css';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';
import { MuiThemeProvider } from '@material-ui/core/styles';
import { theme } from './constants/GlobalUITheme';

ReactDOM.render(
  <MuiThemeProvider theme={theme}>
    <Provider store={store}>
      <App />
    </Provider>
  </MuiThemeProvider>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
```

```typescript:src/store/configureStore.tsx
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import amplifyAuthReducer from './reducers/Auth';
import albumReducer from './reducers/Album';
import { reducer as reduxFormReducer } from 'redux-form';

const enhancer =
  process.env.NODE_ENV === 'development' ? composeWithDevTools(applyMiddleware(thunk)) : applyMiddleware(thunk);

const store = createStore(
  combineReducers({
    auth: amplifyAuthReducer,
    form: reduxFormReducer,
    album: albumReducer,
  }),
  enhancer,
);

export default store;
```
```typescript:src/store/reducers/Album.tsx
import { Reducer } from 'redux';
import * as AlbumAPIActionType from '../../constants/Album';

export interface AlbumApiState {
  albums: any;
  isLoading: boolean;
  isLoaded: boolean;
  error?: any;
}

export const initialState: AlbumApiState = {
  albums: [],
  isLoading: false,
  isLoaded: false,
};

const apiReducer: Reducer<AlbumApiState> = (
  state: AlbumApiState = initialState,
  action: any
): AlbumApiState => {
  switch (action.type) {
    case AlbumAPIActionType.LIST_ALBUMS_START:
    case AlbumAPIActionType.CREATE_ALBUM_START:
    case AlbumAPIActionType.UPDATE_ALBUM_START:
    case AlbumAPIActionType.DELETE_ALBUM_START:
      return {
        ...state,
        albums: [],
        isLoading: true,
        isLoaded: false,
      };
    case AlbumAPIActionType.LIST_ALBUMS_SUCCEED:
    case AlbumAPIActionType.CREATE_ALBUM_SUCCEED:
    case AlbumAPIActionType.UPDATE_ALBUM_SUCCEED:
    case AlbumAPIActionType.DELETE_ALBUM_SUCCEED:
      return {
        ...state,
        albums: action.payload.result,
        isLoading: false,
        isLoaded: true,
      };
    case AlbumAPIActionType.LIST_ALBUMS_FAIL:
    case AlbumAPIActionType.CREATE_ALBUM_FAIL:
    case AlbumAPIActionType.UPDATE_ALBUM_FAIL:
    case AlbumAPIActionType.DELETE_ALBUM_FAIL:
      return {
        ...state,
        isLoading: false,
        isLoaded: false,
        error: action.payload.error,
      };
    default: {
      return state;
    }
  }
};

export default apiReducer;

```

## Reduxアクションを発行したタイミングでGraphQLのAPI通信を行う
![a85948a33dd9834e0128470b8327c86b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/f52e86a9-06d6-d95d-f588-aa9a23de2f8b.gif)
FSAの型に従ってアクションの処理を行っています。関数の呼び出し方などは若干のアレンジを加えてあります。

```typescript:src/actions/index.d.ts
export interface Action<Payload> {
  type: string;
  payload?: Payload;
  error?: boolean;
  meta?: { [key: string]: any } | null;
}

export interface ActionStart<ActionType, Params> {
  type: ActionType;
  payload?: Params;
  meta?: { [key: string]: any } | null;
}

export interface ActionSucceed<ActionType, Params, Result> {
  type: ActionType;
  payload: {
    params: Params;
    result: Result;
  };
  meta?: { [key: string]: any } | null;
}

export interface ActionFail<ActionType, Params, AnyError> {
  type: ActionType;
  payload: {
    params: Params;
    error: AnyError;
  };
  error: boolean;
  meta?: { [key: string]: any } | null;
}

```

```typescript:src/actions/index.tsx
import { Dispatch } from 'redux';

const apiActionStart: Function = (startActionType: string): any => {
  return {
    type: startActionType,
  };
};

const apiActionSuccess: Function = <T extends {}>(successActionType: string, result: T): any => {
  return {
    type: successActionType,
    payload: { result },
  };
};

const apiActionFail: Function = <T extends {}>(failActionType: string, error: T): any => {
  return {
    type: failActionType,
    payload: { error },
    error: true,
  };
};

export const apiRequestFunc = (
  startActionType: string,
  successActionType: string,
  failActionType: string,
  uniqueLogicFunc: Function,
) => {
  return async (dispatch: Dispatch): Promise<void> => {
    dispatch(apiActionStart(startActionType));
    try {
      dispatch(apiActionSuccess(successActionType, await uniqueLogicFunc()));
    } catch (error) {
      dispatch(apiActionFail(failActionType, error));
    }
  };
};

```

`AppSyncApi`・`Storage`が使えるように`amplify configure`の設定を更新してから、アクションにアルバム投稿におけるCRUD機能の処理を記述します。

```typescript
  Amplify.configure({
    Auth: {
      identityPoolId: process.env.REACT_APP_IDENTITY_POOL_ID,
      region: process.env.REACT_APP_REGION,
      userPoolId: process.env.REACT_APP_USERPOOL_ID,
      userPoolWebClientId: process.env.REACT_APP_USERPOOL_WEBCLIENT_ID,
    },
    Storage: {
      AWSS3: {
        bucket: process.env.REACT_APP_S3_BUCKET, // アップロードするS3のバケット名
        region: process.env.REACT_APP_REGION, // アップロードするS3のリージョン名
      },
    },
    aws_appsync_graphqlEndpoint: process.env.REACT_APP_APPSYNC_GRAPHQL_ENDPOINT, // AppSyncのエンドポイント。AppSyncコンソールの「設定」画面で確認
    aws_appsync_region: process.env.REACT_APP_REGION, //.envに設定したAppSyncのリージョン名
    aws_appsync_authenticationType: 'AMAZON_COGNITO_USER_POOLS',
    aws_appsync_apiKey: process.env.REACT_APP_APPSYNC_APIKEY, // AppSyncのapiキー。AppSyncコンソールの「設定」画面で確認
  });
```

```typescript:src/actions/Album.tsx
import { Auth, API, graphqlOperation, Storage } from 'aws-amplify';
import _ from 'lodash';
import uuid from 'uuid/v4';
import { apiRequestFunc } from './index';
import {
  listAlbums,
  createAlbum,
  createAlbumPicture,
  updateAlbum,
  deleteAlbum,
  deletePicture,
} from '../graphql/Album';
import * as AlbumAPIActionType from '../constants/Album';

export const getAlbumListFunc = () => {
  const uniqueLogicFunc = async () => {
    return API.graphql(graphqlOperation(listAlbums)).then(
      (response: any) => response.data.listAlbums.items
    );
  };

  return apiRequestFunc(
    AlbumAPIActionType.LIST_ALBUMS_START,
    AlbumAPIActionType.LIST_ALBUMS_SUCCEED,
    AlbumAPIActionType.LIST_ALBUMS_FAIL,
    uniqueLogicFunc
  );
};

export const createAlbumFunc = (albums: any, values: any) => {
  const uniqueLogicFunc = async () => {
    const loginInfo = await Auth.currentUserInfo();

    const albumInfo = await API.graphql(
      graphqlOperation(createAlbum, {
        createAlbuminput: {
          title: values.title,
          visible: values.visible,
          note: values.notes,
          owner: loginInfo.username,
          createdAt: new Date().toISOString(),
        },
      })
    );

    if (values.imageToUpload.length !== 0) {
      albumInfo.data.createAlbum.picture = await Promise.all(
        _.map(values.imageToUpload, async (image: any) => {
          const S3image: any = await Storage.put(uuid(), image, {
            contentType: image.type,
          });

          const picture: any = await API.graphql(
            graphqlOperation(createAlbumPicture, {
              createAlbumPictureinput: {
                name: image.name,
                albumId: albumInfo.data.createAlbum.id,
                file: {
                  bucket: process.env.REACT_APP_S3_BUCKET,
                  region: process.env.REACT_APP_REGION,
                  key: S3image.key,
                },
                createdAt: new Date().toISOString(),
              },
            })
          );

          return picture.data.createAlbumPicture;
        })
      );
    } else {
      albumInfo.data.createAlbum.picture = null;
    }

    return [...albums, albumInfo.data.createAlbum];
  };

  return apiRequestFunc(
    AlbumAPIActionType.CREATE_ALBUM_START,
    AlbumAPIActionType.CREATE_ALBUM_SUCCEED,
    AlbumAPIActionType.CREATE_ALBUM_FAIL,
    uniqueLogicFunc
  );
};

export const updateAlbumFunc = (albums: any, album: any): any => {
  const uniqueLogicFunc = async () => {
    const albumInfo = await API.graphql(
      graphqlOperation(updateAlbum, {
        updateAlbuminput: {
          id: album.id,
          title: album.title,
          visible: album.visible,
          note: album.note,
          owner: album.username,
          createdAt: new Date().toISOString(),
        },
      })
    ).then((response: any) => response.data.updateAlbum);

    let updatePicture = album.picture;

    const removeAlbums: any[] = _.compact(
      await Promise.all(
        _.map(albums, async (updateAlbum: any) => {
          if (updateAlbum.id === albumInfo.id) {
            if (updateAlbum.picture !== album.picture) {
              await Promise.all(
                _.map(updateAlbum.picture, async (picture: any) => {
                  await Storage.remove(picture.file.key, { level: 'public' });

                  return API.graphql(
                    graphqlOperation(deletePicture, {
                      deleteAlbumPictureinput: {
                        id: picture.id,
                      },
                    })
                  );
                })
              );

              if (album.picture.length !== 0) {
                updatePicture = await Promise.all(
                  _.map(album.picture, async (image: any) => {
                    const S3image: any = await Storage.put(uuid(), image, {
                      contentType: image.type,
                    });

                    const picture: any = await API.graphql(
                      graphqlOperation(createAlbumPicture, {
                        createAlbumPictureinput: {
                          name: image.name,
                          albumId: albumInfo.id,
                          file: {
                            bucket: process.env.REACT_APP_S3_BUCKET,
                            region: process.env.REACT_APP_REGION,
                            key: S3image.key,
                          },
                          createdAt: new Date().toISOString(),
                        },
                      })
                    );

                    return picture.data.createAlbumPicture;
                  })
                );
              }
            }

            return '';
          } else {
            return updateAlbum;
          }
        })
      )
    );

    albumInfo.picture = updatePicture;

    return [...removeAlbums, albumInfo];
  };

  return apiRequestFunc(
    AlbumAPIActionType.UPDATE_ALBUM_START,
    AlbumAPIActionType.UPDATE_ALBUM_SUCCEED,
    AlbumAPIActionType.UPDATE_ALBUM_FAIL,
    uniqueLogicFunc
  );
};

export const deleteAlbumFunc = (albums: any, album: any): any => {
  const uniqueLogicFunc = async () => {
    const deleteId = await API.graphql(
      graphqlOperation(deleteAlbum, {
        deleteAlbuminput: {
          id: album.id,
        },
      })
    );

    return _.compact(
      await Promise.all(
        _.map(albums, async (deleteAlbum: any) => {
          if (deleteAlbum.id === deleteId.data.deleteAlbum.id) {
            await Promise.all(
              _.map(deleteAlbum.picture, async (picture: any) => {
                await Storage.remove(picture.file.key, { level: 'public' });

                return API.graphql(
                  graphqlOperation(deletePicture, {
                    deleteAlbumPictureinput: {
                      id: picture.id,
                    },
                  })
                );
              })
            );
            return '';
          } else {
            return deleteAlbum;
          }
        })
      )
    );
  };

  return apiRequestFunc(
    AlbumAPIActionType.DELETE_ALBUM_START,
    AlbumAPIActionType.DELETE_ALBUM_SUCCEED,
    AlbumAPIActionType.DELETE_ALBUM_FAIL,
    uniqueLogicFunc
  );
};
```
```typescript:src/graphql/Album.tsx
export const listAlbums = `query listAlbums {
  listAlbums {
    items {
      id
      title
      visible
      note
      owner
      createdAt
      picture {
        id
        name
        file{
          key
        }
      }
    }
  }
}`;

export const createAlbum = `mutation createAlbum($createAlbuminput: CreateAlbumInput!) {
  createAlbum(input: $createAlbuminput) {
    id
    title
    visible
    note
    owner
    createdAt
  }
}`;

export const updateAlbum = `mutation updateAlbum($updateAlbuminput: UpdateAlbumInput!) {
  updateAlbum(input: $updateAlbuminput) {
    id
    title
    visible
    note
    owner
    createdAt
  }
}`;

export const deleteAlbum = `mutation deleteAlbum($deleteAlbuminput: DeleteAlbumInput!) {
  deleteAlbum(input: $deleteAlbuminput) {
    id
  }
}`;

export const createAlbumPicture = `mutation createAlbumPicture($createAlbumPictureinput: CreateAlbumPictureInput!) {
  createAlbumPicture(input: $createAlbumPictureinput) {
    id
    name
    albumId
    file {
      bucket
      region
      key
    }
    createdAt
  }
}`;

export const deletePicture = `mutation deleteAlbumPicture($deleteAlbumPictureinput: DeleteAlbumPictureInput!) {
  deleteAlbumPicture(input: $deleteAlbumPictureinput) {
    id
  }
}`;

```

## Subscriptionでリアルタイムに更新を反映する
アルバムコンポーネントが読み込まれたときにサブスクライブ（購読）を開始し、もし他のデバイス（ブラウザ）で「更新系」のリクエストが送られたらサーバサイドから「更新された」というレスポンスが返され、ブラウザをリロードしなくても画面にリアルタイムで反映させることができます。
![0f596bc5f3cb74076c457e45ee8d6e20.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498345/4bfb9611-bf8b-f91b-19e1-2e97a825119d.gif)

```typescript:src/graphql/Album.tsxに追加
export const onCreateAlbumPicture = `subscription onCreateAlbumPictureSub {
  onCreateAlbumPicture {
      __typename
      id
      albumId
      name
      createdAt
  }
}`;

export const onDeleteAlbum = `subscription onDeleteAlbumSub {
  onDeleteAlbum {
      __typename
      id
  }
}`;
```

```typescript:src/containers/Album.tsxコンテナコンポーネントに追加
  useEffect(() => {
    (async () => {
      await API.graphql(graphqlOperation(onCreateAlbumPicture)).subscribe({
        next: async () => {
          await albumActions.getAlbumListFunc();
        },
      });
      await API.graphql(graphqlOperation(onDeleteAlbum)).subscribe({
        next: async () => {
          await albumActions.getAlbumListFunc();
        },
      });
      await albumActions.getAlbumListFunc();
    })();
  }, []);
```

# <font color="LightSeaGreen">まとめ</font>
今回はAmplifyフレームワークを使ってサーバサイド構築をしてみましたが、本当に手軽に環境を整えることができます。
実運用面で使用するのはもう少し調査が必要かなとは思いますが、AWSのIAM、認証系統、ストレージ、データベース、AppSyncなどのプロビジョニングをコマンドと対話形式で自動で行ってくれるので、様々な機能を実験的に試すには一番簡単な方法だと思います。（手動で行うとかなり骨が折れると思います。。）
GraphQLに関しては今回始めて使用してみましたが、クエリとリゾルバを組み合わせれば少ないAPIリクエストで過不足なくデータを取得できるため、負荷軽減のために活用できそうな技術であることは間違いなさそうです。その一方で、クエリの記述をクライアントで記述する分フロントエンドでのコード量が増える印象を持ちました（今回のreduxのactionのコードはリファクタリングをしていないため余計粗が目立つのたもしれませんが...）。
AppSyncはLambdaと連携もできるため、画像投稿系の処理はLambdaに記述したほうがフロントエンドのコードはすっきりとしたかもしれません。また時間がある時にLambdaとの連携を試してみたいです。

さて次回、アドベントカレンダー投稿の最後を締めくくるのは、アプリの品質を保つためにもっとも重要な「`テスト自動化`」についてです。`testcafe`・`cypress`というモダンE2Eテストツールについての投稿を予定しています。もし時間があれば`Github Actions`を利用した継続的インテグレーション（CI）についても触れたいなと考えています。