AWS Amplifyを利用してS3へファイルアップロードとLambda呼び出し

# AWS Amplifyを利用してS3へファイルアップロードとLambda呼び出し

この記事は[サーバーレスWebアプリ Mosaic](https://mosaic.w2or3w.com "Mosaic")を開発して得た知見を振り返り定着させるための[ハンズオン記事](https://qiita.com/w2or3w/items/87b57dfdbcf218de91e2)の1つです。

以下を見てからこの記事をみるといい感じです。

* [Vue CLIで新規プロジェクト作成と、よくある雛形の実装](https://qiita.com/w2or3w/items/20d393560571f1531041)
* [AWS Amplifyのセットアップ](https://qiita.com/w2or3w/items/bb0ddecb8fe7c5dca8ae)

## はじめに
VueのプロジェクトへAmplify CLIでStorageとそれをトリガーとするFunctionをセットアップします。
WebアプリからS3にファイルをアップロードしてそれをトリガーにLambdaが動く仕組みをAmplifyでサクッと構築できちゃいます。

## コンテンツ

### Storageのセットアップ
それではWebアプリのルートディレクトリにcdして作業を進めてゆきます。

```
$ amplify add storage
? Please select from one of the below mentioned services Content (Images, audio, video, etc.)
? You need to add auth (Amazon Cognito) to your project in order to add storage for user files. Do you want to add au
th now? Yes
Using service: Cognito, provided by: awscloudformation
 
 The current configured provider is Amazon Cognito. 
 
 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Username
 Do you want to configure advanced settings? No, I am done.
Successfully added auth resource
? Please provide a friendly name for your resource that will be used to label this category in the project: samplevue
projectstrageauth
? Please provide bucket name: sample-vue-project-bucket
? Who should have access: Auth users only
? What kind of access do you want for Authenticated users? create/update, read, delete
? Do you want to add a Lambda Trigger for your S3 Bucket? Yes
? Select from the following options Create a new function
Successfully added resource S3Triggere66949d2 locally
? Do you want to edit the local S3Triggere66949d2 lambda function now? No
? Press enter to continue 
Successfully added resource samplevueprojectstrageauth locally

Some next steps:
"amplify push" builds all of your local backend resources and provisions them in the cloud
"amplify publish" builds all of your local backend and front-end resources (if you added hosting category) and provisions them in the cloud
```
`amplify add storage`コマンド実行で、例によってまた色々と聞いてきますので、答えていきます。
サービスの種類 : Content (Images, audio, video, etc.)
認証を追加する？ : Yes
認証の種類は？ : Default configration
サインインに必要なものは？ : Username
詳細設定する？ : No
ラベル名 : 何でもOK (samplevueprojectstrageauth)
バケット名 : 何でもOK (sample-vue-project-bucket)
バケットにアクセスできる人 : 認証ユーザーのみ (Auth users only)
(Guestも許可すると、認証せずにアップロードできるようになります。)
認証ユーザーができる操作 : 全て (create/updte, read, delete) (必要な操作を選択してください。)
Lambdaのトリガーを加える？ : Yes
Lambdaファンクションを選択？ : 新規作成 (Create a new function)
(S3Trigger********-work というファンクションが作成される。(名前を指定できない。なぜ、、。))
Lambdaファンクションを今実装する？ : No (あとで実装します。)

ここまでで、ローカルにセットアップの準備が完了しましたので、続いてクラウド側に実行させます。

```
$ amplify push

Current Environment: work

| Category | Resource name              | Operation | Provider plugin   |
| -------- | -------------------------- | --------- | ----------------- |
| Auth     | samplevueproject7d725456   | Create    | awscloudformation |
| Function | S3Triggere66949d2          | Create    | awscloudformation |
| Storage  | samplevueprojectstrageauth | Create    | awscloudformation |
? Are you sure you want to continue? Yes
⠋ Updating resources in the cloud. This may take a few minutes...
 :
 :
```
`amplify push`コマンド実行後、1,2分待って、以下のメッセージが出れば完了です。

```
✔ All resources are updated in the cloud
```

CloudFormationを見てみますと、3つ(Functon, Auth, Strage)のスタックが、`amplify init`で作成されたスタックにネストされた形で追加されています。
プロジェクトのamplifyフォルダの中は、色々なファイルが追加されたり更新されたりしています。

CloudFormationで色々追加されてるのですが、最低限以下は確認しておきましょう。

* S3 (sample-vue-project-bucket-work)
アプリがファイルをアップロードしたりダウンロードしたりするバケット。

* Lambda (S3Trigger********-work)
上のバケットへのアクションによりトリガーされるファンクション。

* IAM Role (S3Trigger******** LambdaRole********-work)
上のファンクションの権限を管理するロール。

* Cognito ユーザープール (samplevueproject******** _userpool_********-work)
アプリがS3にアクセスする際の認証情報を管理するユーザーディレクトリ。

### Cognitoユーザーの作成
S3へアクセスができるのは認証ユーザーのみとしましたので、そのためのユーザーをCognitoユーザープールに作成します。
(Guestもアクセスできるようにセットアップした場合は不要です。)

AWSコンソール > Cognito ユーザープール (samplevueproject******** _userpool_********-work) > 全般設定 > ユーザーとグループ > ユーザーの作成
から作成してください。
![Screenshot 2019-12-30 at 15.04.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/394775/3aaec682-3d76-6e9a-392a-616b4b283ddd.png)
このとき、自身のEメールを設定するのと、「Eメールを検証済みにしますか？」チェックはONにします。(Eメールを検証済みの状態で設定しておかないと、アプリからPW再設定時に困ります。)

Cognitoユーザー作成時に設定したPWは仮のPWで、必ず後からPWを再設定しないといけない仕様のようです。再設定するまではアカウントのステータスは`FORCE_CHANGE_PASSWORD`となっています。
![Screenshot 2019-12-30 at 15.06.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/394775/9fa60419-a399-280e-43c7-40d150e8d8ef.png)
この状態からPWを再設定して`CONFIRMED`にしたいのですが、そのためのUIがAWSコンソールには提供されていません。
今回のようにアプリのサービスアカウントとして利用する場合には、何かしらの手段でPWを再設定してあげる必要があります。今回は、アプリが自動的に同一PWを再設定する実装を施しています。(※悪手かもしれません。)

![Screenshot 2019-12-30 at 15.06.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/394775/720105bc-3a5b-1d88-9471-a73d1622b347.png)
アプリからPWの再設定を実施すると、アカウントのステータスは`CONFIRMED`になります。

ここで設定したユーザー名とパスワードはアプリの認証で利用しますので覚えておいてください。

### ファイルのアップロード
AmplifyとStrage、そしてCognito認証ユーザーの準備が整ったので、アプリの実装を再開しましょう。
ファイルチューザーで選択したファイルを、AmplifyのStrageでS3にアップロードします。


* aws-amplifyモジュールのインストール

```
$ npm install @aws-amplify/storage aws-amplify
```

* ソースコードの更新

```src/main.js
import Vue from 'vue'
import App from './App.vue'
import vuetify from './plugins/vuetify';
import router from './router';
import Amplify from 'aws-amplify';
import aws_exports from './aws-exports';

Vue.config.productionTip = false
Amplify.configure(aws_exports)

new Vue({
  vuetify,
  router, 
  render: h => h(App)
}).$mount('#app')
```
```src/components/Home.vue
<template>
  <v-container>
    <p>ホーム</p>
    <router-link to="about" >link to About</router-link>
    <hr>
    <v-btn @click="selectFile">
      SELECT A FILE !!
    </v-btn>
    <input style="display: none" 
      ref="input" type="file" 
      @change="uploadSelectedFile()">
  </v-container>
</template>

<script>
import { Auth, Storage } from 'aws-amplify';

export default {
  name: 'Home',
  data: () => ({
    loginid: "sample-vue-project-user", 
    loginpw: "sample-vue-project-user-password", 
  }), 
  mounted: async function() {
    this.login();
  }, 
  methods:{
    login() {
      console.log("login.");
      Auth.signIn(this.loginid, this.loginpw)
        .then((data) => {
          if(data.challengeName == "NEW_PASSWORD_REQUIRED"){
            console.log("new password required.");
            data.completeNewPasswordChallenge(this.loginpw, {}, 
              {
                onSuccess(result) {
                    console.log("onSuccess");
                    console.log(result);
                },
                onFailure(err) {
                    console.log("onFailure");
                    console.log(err);
                }
              }
            );
          }
          console.log("login successfully.");
        }).catch((err) => {
          console.log("login failed.");
          console.log(err);
        });
    },
    
    selectFile() {
      if(this.$refs.input != undefined){
        this.$refs.input.click();
      }
    }, 
    
    uploadSelectedFile() {
      let file = this.$refs.input.files[0];
      if(file == undefined){
        return;
      }
      console.log(file);

      let dt = new Date();
      let dirName = this.getDirString(dt);
      let filePath = dirName + "/" + file.name;
      Storage.put(filePath, file).then(result => {
        console.log(result);
      }).catch(err => console.log(err));
    }, 
    
    getDirString(date){
      let random = date.getTime() + Math.floor(100000 * Math.random());
      random = Math.random() * random;
      random = Math.floor(random).toString(16);
      return "" + 
        ("00" + date.getUTCFullYear()).slice(-2) + 
        ("00" + (date.getMonth() + 1)).slice(-2) + 
        ("00" + date.getUTCDate()).slice(-2) + 
        ("00" + date.getUTCHours()).slice(-2) + 
        ("00" + date.getUTCMinutes()).slice(-2) + 
        ("00" + date.getUTCSeconds()).slice(-2) + 
        "-" + random;
    }, 
  }
}
</script>
```
Home.vueが長いので、簡単に解説します。

* template
ボタン(v-btn)とファイルチューザー(input)を追加しました。

* script/methods/login
画面が表示されたらCognitoユーザーでログインします。
dataに定義したメンバ変数 loginid, loginpw には、上で作成したCognitoユーザーのユーザー名とパスワードを設定します。(コードに直書きなのが微妙なのですが、、。)
NEW_PASSWORD_REQUIREDのところが、前述した、アプリが自動的に同一PWを再設定する実装です。
  
* script/methods/selectImage, uploadSelectedImage, getGUIDString
selectImageでファイルチューザーを表示します。
ファイルチューザーでファイルが選択されるとuploadSelectedImageが呼ばれます。
getDirStringで「YYMMDDhhmmss-random」というフォルダ名を生成して、そのフォルダの中に選択されたファイルをアップロードしています。(厳密にはフォルダではないのですが、ここでは割愛します。)

それでは実行して、ファイルをアップロードしてみましょう。
![Screenshot 2019-12-30 at 21.20.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/394775/973fe973-4d43-0b11-75db-cacf4f96bfa1.png)
Cloud9のPreview画面右上の方にある`Pop Out Into New Window`によりブラウザの別タブで開くことができ、またそうすることでデベロッパーツールも利用できるようになります。
そろそろクライアントサイドのデバッグが必要になってくると思いますので、デベロッパーツールは使えるようになっておくと良いでしょうし、また、Chromeブラウザの拡張機能[Vue.js devtools](https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd?hl=ja)もインストールしておくと良いでしょうね。
(※デベロッパーツールについては触れません。必要な場合はどこかで学んできてください。)

アプリからファイルアップロードを実行したら、作成したS3 (sample-vue-project-bucket-work) のpublicフォルダ以下に、選択したファイルがアップロードされていることを確認してみましょう。
![Screenshot 2019-12-30 at 21.38.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/394775/849f8fb7-dc47-ae78-ca48-63e4b9162b37.png)

なおこのS3バケットは認証済みユーザーしかアクセスできませんので、アップロードしたファイルのオブジェクトURLに直接アクセスしても「HTTP 403: Access Denied」が返ることも確認してみてください。

#### console.logのビルドエラー対策
console.logでログ出力するコードがあると、`npm run build`でビルドした際に以下のようなエラーとなってしまいます。ESLintの仕様のようで、デフォルトでエラー扱いになるということはそれなりの理由があるのでしょうか、、。誰か教えて下さい。

```
error: Unexpected console statement (no-console) 
```
それなりの理由については定かではないのですが、このままだとAmplify Consoleでデプロイする時など困ってしまいますので、このエラーを抑制してしまいます。プロジェクトルートに`.eslintrc.js`というファイルを作成し、以下のコードを実装することでエラー対象にならなくなります。

```.eslintrc.js
module.exports = { 
  extends: [ 
    'plugin:vue/essential', 
  ], 
  rules: { 
    'no-console': 'off', 
  }, 
};
```

#### ゲストでアップロードする場合
Storageをセットアップする際に、ゲストによるアップロードを許可した上で、認証処理(ログイン)を行わずにファイルをアップロードすることも出来ます。
ただし、認証処理(ログイン)を行わずにということでその部分の実装を削除しただけでは、以下のようなCredentialsErrorという例外が発生すると思います。

```
AWSS3Provider - error uploading CredentialsError: Missing credentials in config
```
認証が行われていないわけではなく、ゲストユーザーとして認証は行われているためで、これに対応するためにはmain.jsを以下のようにしてあげる必要があります。

```main.js
import Vue from 'vue'
import App from './App.vue'
import vuetify from './plugins/vuetify';
import router from './router';
import Amplify from 'aws-amplify';
import aws_exports from './aws-exports';

Vue.config.productionTip = false
Amplify.configure(aws_exports)

import Storage from '@aws-amplify/storage';
import S3 from 'aws-sdk/clients/s3';
const hackyCreateS3 = (options) => {
  const { bucket, region, credentials } = options;
  return new S3({
    apiVersion: '2006-03-01',
    params: { Bucket: bucket },
    region: region,
    credentials: credentials
  });
}
Storage._createS3 = hackyCreateS3;

new Vue({
  vuetify,
  router, 
  render: h => h(App)
}).$mount('#app')
```

### Lambdaファンクション(Python)のセットアップ
Storageで結構おなかいっぱいなので、この記事のFunctionはあっさり行きたいと思います。

AWSコンソール > Lambda > 関数 > S3Trigger********-work
にアクセスします。
関数コードを以下のように設定してください。
コード エントリ タイプ : コードをインラインで編集
ランタイム : Python 3.6
ハンドラ : lambda_function.lambda_handler
Environmentへ`lambda_function.py`を追加し、以下のようなコードを実装し、画面右上の保存ボタンを押下してください。

```lambda_function.py
from urllib.parse import unquote_plus
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
    logger.info("Function Start : Bucket={0}, Key={1}" .format(bucket, key))
```
![Screenshot 2019-12-30 at 23.58.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/394775/c57f6f30-68dc-13d7-8494-eed80e866e4f.png)

アップロードされたバケット名とパス(Key)をINFOログとして出力するだけのPythonコードです。
ログはどこに出力されるかというと、CloudWatchに出力されます。

Webアプリからファイルをアップロードしたら、
AWSコンソール > CloudWatch > ロググループ > aws/lambda/S3Trigger********-work
にアクセスし、最新のログストリームを参照してみてください。
Function Start : Bucket=sample-vue-project-bucket-work, Key=public/191230140616-16f5720fd8d/xxxxxxxxxxxxx.xlsx
みたいなINFOログが出力されていればOKです。
![Screenshot 2019-12-30 at 23.16.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/394775/dc78e2a8-11ea-05bb-e782-d3da44ee1aad.png)
これで、WebアプリからファイルをS3にアップロードして、それをトリガーにLambdaのファンクション(Python)が実行され、そこに記述されたログ出力がCloudWatchで確認できました。

## あとがき
ここで作成したプロジェクトはGitHubの以下に置いておきます。
https://github.com/ww2or3ww/sample_vue_project/tree/work3

AWS Cognitoって難しいですよね。
今回のStrageの認証みたいに、サービスアカウントとして利用したいケースも普通にあると思うのですが、そういう用途はあまり想定されていないのでしょうか、、。それか別の手段が用意されているのか、、。
クライアント側にIDとPWが必要で、そのためにソースコードに直書きしてしまっているのですが、やっぱり微妙ですよね。ゲストでいいじゃないと思ったり、思わなかったり、、。一時的に無効にできたり、別のユーザーアカウントに変更出来たりする点で、ゲストよりは良いと思っているのですが、、。誰か教えて下さい。

~~ちなみに[ワタシのMosaic](https://mosaic.w2or3w.com "Mosaic")ですが、ゲストとしてアップロードしているし、それとは別にpublicバケットも利用しています。いずれこの記事の通り認証が必要なバケットだけ利用するようリファクタリングをしなければ、と思っています。いずれ、、。~~ 対応完了しました。(2020年2月某日)