Help us understand the problem. What is going on with this article?

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

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

React Hooks ✕ AtomicDesignで画像投稿webアプリのフロントエンドを実装する

AWS Amplifyとは

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

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

AWS Amplify CLIのインストール・初期設定

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にアクセスすると「ユーザー」に新規で今回登録したユーザーが登録されているのが確認できます。
346000045bbfd45ad72390bea4e1c351.png

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

$ 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 |
| -------- | ------------- | --------- | --------------- |

AWS Amplify AuthでCognitoユーザー認証機能を実装してみる

上記までで初期設定は終わりました。しかし、現状では機能が空の状態かつ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ユーザープールにアクセスし、設定どおり新規生成されているのを確認します。
ユーザープール_-_Amazon_Cognito-2.png

フロントエンドからCognito認証する

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

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

$ npm i aws-amplify aws-amplify-react

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

src/@types/aws-amplify-react.d.ts
declare module 'aws-amplify-react';

アプリのルートコンポーネントでCognitoの設定を定義します。
(今回はシンプルに見せるためにコンポーネントファイルに直書きしていますが、実運用ではセキュリティの観点からconfigureは別ファイルから読み込みかつIDなどは.envから読み込み外部から見えないようにする必要があります。)

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
77d14f44cc82df05d4f570e400f594e2.png

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

AWS AppSyncとは

AWS AppSync 公式
AWS AppSync を使用すると、1 つ以上のデータソースからのデータに安全にアクセス、操作、結合するための柔軟な API を作成でき、アプリケーション開発がシンプルになります。AppSync は、GraphQL を使用してアプリケーションが必要なデータを正確に取得できるようにするマネージド型サービスです。

なぜREST APIではなくGraphQLを使うか

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

メリット

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

デメリット

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

AWS AppSyncでGraphQLサーバを実装してみる

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にも同時にデータリソースが生成されています。
fa3964e011bf20773f382641ec04d548.png

ひとまずテストクエリを実行してみる

AWS AppSyncコンソール画面のクエリから簡単にクエリのテストを行うことができます。サンプルでスキーマ&リソースDBが生成されていると思うので、こちらを利用してでひとまず挙動を確認します。今回はAPI認証にAmazon Cognito User Poolを使用したので、あらかじめAmplify Authで作ったテストユーザーでログインしておきましょう。(クエリの実行ボタンの右横にログインボタンがあります)

Mutation

mutationでデータの作成更新削除などいわゆる「更新系」の処理を行います。
f6258d1da84aab3239cef4954c324ab3.gif
DymanoDBにも記録されていることを確認します。
0de1b1799e696a3a6c96918c42a78a06.png

Query

次はqueryを試してみます。リスト表示などのいわゆる「参照系」の処理を行います。graphQLの強みとして欲しいデータをクエリで指定することができます(idtitleだけ取得など)。
395a26820ba2c57c127a826c5e835017.gif

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

ここからが本番です。
画像投稿アプリの実装を通して、AppSyncを利用したサーバサイド開発について検証します。

アプリ全体構成図

今更感がありますが、サーバサイドの構成も固まってきたところで全体の構成図を載せておきます。
ad737f1a2be16d345a96e0ae083bd9f6.png

データベースのER図

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

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

364392dd2aa37a4f4637f54189f8c058.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テーブルとのリレーションシップを構成する必要があります。リレーションを実現するためにはリゾルバーを書き換える必要があります。schemaresolversからAlbumPictureTableに対して「アタッチ」を行います。記述はVTL言語指定なので、それに従ってリレーションシップの記述を行います。
AWS_AppSync_Console.png

リクエストマッピングテンプレート
{
    "version": "2017-02-28",
    "operation": "Query",
    "index": "albumId-index",
    "query": {
        "expression": "albumId = :albumId",
        "expressionValues": {
            ":albumId": { "S": "$ctx.source.id" }
        }
    }
}
レスポンスマッピングテンプレート
$util.toJson($ctx.result.items)

リゾルバーの設定はこれで完了です。しかし、上記記述にもあるとおりDynamoDBAlbumPictureTableにグローバルセカンダリインデックスを設定しないとalbumIdで抽出することができません。DynamoDBの「インデックス」タブから設定を行います。
ddc2df1a4f45cd9a3b8f8f14c16a5bbf.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"])
}

React Reduxで状態管理を行い、AppSyncに対しクエリを送ってみる

ここまでの実装でサーバサイドの準備は整いました。
いよいよクライアントから、今回構築したAppSync(GraphQL)のエンドポイントに対して、クエリリクエストを送ってみます。
RESTAPIでもGraphQLでもAPI通信には変わらないので、Redexにおける非同期通信のフローは全く同じです。
redux_image.png

まずはReduxのstore・reducer設定から

全てを掲載すると長くなってしまうため、今回はアルバム機能のみに限定しました。

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();
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;
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
FSAの型に従ってアクションの処理を行っています。関数の呼び出し方などは若干のアレンジを加えてあります。

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;
}

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));
    }
  };
};

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

  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コンソールの「設定」画面で確認
  });
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
  );
};
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

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

export const onDeleteAlbum = `subscription onDeleteAlbumSub {
  onDeleteAlbum {
      __typename
      id
  }
}`;
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();
    })();
  }, []);

まとめ

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

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした