Vue.js、AWS Amplifyおよびboto3でサンプルアプリを作ってみる（第三回：axiosでAPIコール編）

第二回で、Amplify CLIを使ってバックエンドリソースを作成し、CICDを回してVueアプリを外部公開するところまで辿り着いた。
今回は、さらにAPIとLambda関数を用意し、Vue.jsからaxiosで呼び出すところまでをトライ。これができれば、「Vue.jsでフロントエンドを作ってAWSサービスを叩いてみる」という当初の目的を達成できたことになる。

前回の内容はこちら。
[Vue.js、AWS Amplifyおよびboto3でサンプルアプリを作ってみる（第二回：Amplifyとバックエンドリソース編）](https://qiita.com/itsuki3/items/21925a1afb256dd8b935)

## （今回）やりたいこと
- axiosをセットアップする
- APIとLambda関数を作る
- Vue.jsからaxios経由でAPIを呼び出す

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609876/3317fc2b-185b-ab87-1a5f-313cc37fd9a0.png)

## あえてやらないこと
AmplifyはライブラリやUIコンポーネントを備えていて、その気になればVue.js内から直接AWSサービスを操作できる様子（例：学習/デプロイ済みの機械学習モデルエンドポイントに推論リクエストを投げる、S3にオブジェクトをアップロードするなど）。
ただ、それをやるとなるとVue.jsのディレクティブやJavascript SDKにもう少し深入りする必要がありそう。あっちもこっちも手を出したくないので、今回は初志貫徹でコードはPython、SDKはboto3に留めておく。

ということで、上図の通りフロントエンドからはAPIを叩くだけにして、ロジックやSDKの使用はバックエンドで行う役割分担にする。

## axiosについて
簡単に言うとHTTPクライアントで、Vue.jsのコード内からPostman的にREST APIを呼び出す方法がないかと探すうちに発見。

[axios](https://github.com/axios/axios)

これなら簡単にAPIの呼び出しと結果の受領ができそうだ。

以下を参考にさせていただきました。感謝。
[axios を利用した API の使用](https://jp.vuejs.org/v2/cookbook/using-axios-to-consume-apis.html)
[axiosを乗りこなす機能についての知見集](https://qiita.com/terrierscript/items/ccb56b6fc05aa7821c42)
[Vue.js+axiosでDynamo DBにAjax通信する](https://ky-yk-d.hatenablog.com/entry/2018/05/27/223135)
[[axios] axios の導入と簡単な使い方](https://qiita.com/ksh-fthr/items/2daaaf3a15c4c11956e9)

## Step by Step

### 1. Amplifyライブラリのインストール
やらないとは言いつつも、後学のために、セットアップして使えるようにするところまでは試しておく。

```
% npm install aws-amplify
% npm install aws-amplify-vue 
```

[このあたり](https://www.npmjs.com/package/aws-amplify-vue)を参照しながらインストール。
[公式](https://aws-amplify.github.io/docs/js/vue)も参照のこと。

### 2. src/main.jsへの取り込み

src/main.jsを編集して、Amplifyライブラリの取り込みを指定する。

```vuejs:src/main.js
// Amplify
import Amplify, * as AmplifyModules from 'aws-amplify'
import { AmplifyPlugin } from 'aws-amplify-vue'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)

Vue.use(AmplifyPlugin, AmplifyModules)
```

aws-exportsが見つからない、というエラーが出る場合は、`.gitignore`でGitのトラッキング対象外になってしまっていないかを確認する。
自分の環境では、これをコメントアウトすると動いた。
`.gitignore`への追加はAmplify自身が行っているようなので若干謎だが、とりあえず動いたので、気にせず先に進む。

```yaml:.gitignore
.DS_Store
node_modules
/dist

# local env files
.env.local
.env.*.local

# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

#amplify
amplify/\#current-cloud-backend
amplify/.config/local-*
amplify/mock-data
amplify/backend/amplify-meta.json
amplify/backend/awscloudformation
build/
dist/
node_modules/
# aws-exports.js <-- ここ
awsconfiguration.json
amplifyconfiguration.json
amplify-build-config.json
amplify-gradle-config.json
amplifyxc.config
```

### 3. axiosのインストール

Amplifyライブラリと同じ手順。

```
% npm install axios
```

### 4. src/main.jsへの取り込み

ここもAmplifyライブラリと同様だが、axoisの仕様に若干のクセがありハマった。
axiosは厳密にはプラグインでないので、main.jsで`Vue.use()`に書いてあっても、`this.axios`が`Undefined`となってしまう。
代わりに以下のように`prototype.$axios`として定義し、読み込み元では`$axios.get()`として呼ぶ必要がある。

```javascript
// ダメな例

// Axios
import axios from 'axios'
import VueAxios from 'vue-axios'

Vue.use(AmplifyPlugin, AmplifyModules, VueAxios, axios)
```

```javascript
// 動く例

// Axios
import axios from 'axios'
import VueAxios from 'vue-axios'

Vue.use(AmplifyPlugin, AmplifyModules, VueAxios)
Vue.prototype.$axios = axios
```

`main.js`は最終的にこのようになる。

```javascript:src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'

// Element UI
import './plugins/element.js'

// Axios
import axios from 'axios'
import VueAxios from 'vue-axios'

// Amplify
import Amplify, * as AmplifyModules from 'aws-amplify'
import { AmplifyPlugin } from 'aws-amplify-vue'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)

Vue.config.productionTip = false
Vue.use(AmplifyPlugin, AmplifyModules, VueAxios)
Vue.prototype.$axios = axios

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

```

これでようやくaxiosの事前準備が完了。

### 5. Lambda関数の準備

バックエンド側を作る。
そろそろAmplifyに全部やらせるのも飽きてきたので、練習も兼ねて、`amplify add function`ではなくスクラッチで用意する。
こんな感じのLambda関数を書く。

```python:lambda_function.py
import boto3
import json
import os
import datetime

print('Loading function')
glue = boto3.client('glue')
gluedb = os.environ['GLUEDBNAME']

# dict内のdatetime型データをJSON対応のISOフォーマット（文字列）に変換する
def convert_datetime2iso(object):
    if isinstance(object, type(datetime)):
        return object.isoformat()

# 本体
def lambda_handler(event, context):
    operation = 'GET'
    if operation == 'GET': 
        tables = glue.get_tables(
            DatabaseName=gluedb
        )["TableList"]
        response = json.dumps(tables, default=convert_datetime2iso)
        return {
            'isBase64Encoded': False,
            'statusCode': 200,
            'headers': {
                # CORSの許可
                'Content-Type': 'application/json', 
                'Access-Control-Allow-Origin': '*' 
            },
            'body': response
        }
    else:
        response = ('Unsupported method:' + format(operation))
        return response
```

ファイル名が`lambda_function.py`、関数名が`lambda_handler()`なので、Lambdaからは`lambda_function.lambda_handler`として呼び出すことになる。

AWS Glueを呼び出し、DB名を渡してテーブル一覧を取得する簡単な関数で、`GLUEDBNAME`をLambdaの環境変数として渡す形を取っている。今回は`sh10_external`をDB名とした。よーく見る（見なくても）とメソッドがGETしか定義されてないが、お試しなのでご容赦。。

<img width="1611" alt="スクリーンショット 2020-06-03 02.07.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609876/42f99e1b-8a5e-e9a1-f456-f7f7ee168b69.png">

実際にGlueに渡す命令は[glue.get_tables()](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/glue.html#Glue.Client.get_tables)のみで、特に複雑な処理はない。

### 6. APIの準備

次に、axiosから呼び出すREST APIを作成する。このAPIのバックエンドとして、先程のLambda関数を実行する形。
これも`amplify add api`ではなくスクラッチでAPIを作成してみる。
マネジメントコンソールでAPI Gatewayの画面に移動し、以下の仕様でAPIを作成。

- Lambdaプロキシー統合
- /tablesリソース
- ANYメソッド

![スクリーンショット 2020-06-03 13.40.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609876/05b1fde7-4e56-d733-d9ec-b6d364982444.png)

API作成まではすんなりいったものの、Lambda関数と統合して動かすまでに色々ハマった。

まず、`not JSON seriarizable`エラー（クライアントから見ると500エラー）が出まくる。Lambda単体では動くようになっても、APIから呼ぶとまた出る。ここで大分時間を使った。
詳細はまた項を改めるが、結論としてはGlueからのdict型のレスポンスを`json.dump()`した上で、[API GatewayがLambdaプロキシ統合で要求する形式](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format)に成形して返すことで、無事API様に受け取って貰えた。

ようやく関門突破かと思いきや、今度は`CORS header 'Access-Control-Allow-Origin' missing'といったエラーが出る。何か見覚えある単語が。
これは、axiosというか今回のSPAが当該APIを別のオリジンから呼ぶ形になるので、CORSを許可する必要があるということだ。
CORSは一般にサーバー側で設定し、API Gatewayも`OPTIONS`メソッドでこれを設定するメニューがあったので設定してみたが、どうやら効いてなさそうだ。

色々調べたところ、今回使用した[Lambdaプロキシー統合の場合](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/how-to-cors.html)はどうもバックエンドのLambdaの方で明示的にCORSを許可するヘッダを記述してやる必要がある模様。
上記の返値の中ほど、`'headers':{}`の中にCORS設定を書いてやると、ようやく動いた。やれやれ。

```python
        return {
            'isBase64Encoded': False,
            'statusCode': 200,
            'headers': {
                # CORSの許可
                'Content-Type': 'application/json', 
                'Access-Control-Allow-Origin': '*' 
            },
            'body': response
        }
```

### 7. API呼び出しとテーブルへの取り込み設定

最後は再びフロントエンド側に戻り、axiosでAPIを呼び出してデータを取得する箇所と、取得したデータをElement UIのtablesの中に成形して取り込む受け皿部分を作る。
今回のSPAでは`src/components/`配下に全てのコンポーネントを配置してVue Routerからルーティングしているが、この一つとして`CatalogueTable.vue`というページを用意する。

ここでのポイントは以下の三つ。
- getDummyData()内の`this.$axios.get`でaxiosを呼び出し、REST APIを叩く
- `tables`という配列でデータを受け取る
- Element UIの`el-table`コンポーネントでtablesのデータ（`:data="tables"`）を成形する

axiosの引数となるREST APIのURIには、先程API Gatewayで作成したAPIのエンドポイントを指定する。
成形は`<el-table></el-table>`や`<el-table-column></el-table-column>`の中で、Element UIの書式を使ってわりと自由に行うことができる。
ここでは最低限の属性だけを設定してみた。

- テーブル

|属性|内容|
|:----|:----|
|:data|データソース|
|stripe|ストライプ表示にする|
|style="width: 100%"|横幅の長さ|  
|align="center"|中央揃え|  

- 列

|属性|内容|
|:----|:----|
|prop|列名|
|label|列の表示名|
|width|列の長さ|
|sortable|ソート可能な列として指定|      

```vuejs:src/components/CatalogueTable.vue
<template>
  <div class="cataloguetable">
    <p></p>
    <h2>テーブル一覧</h2>
    <el-button type="primary" @click="getDummyData" :loading="false">実行</el-button>
    <el-table
      :data="tables"
      stripe
      style="width: 100%"
      align="center">
      <el-table-column
        prop="Name"
        label="Table"
        width="250"
        sortable>
      </el-table-column>
      <el-table-column
        prop="DatabaseName"
        label="Database"
        width="200"
        sortable>
      </el-table-column>
      <el-table-column
        prop= "TableType"
        label="Type"
        width="200"
        sortable>
      </el-table-column>
      <el-table-column
        prop="PartitionKeys[0][Name]"
        label="Partition Key 1"
        width="150"
        sortable>
      </el-table-column>
      <el-table-column
        prop="PartitionKeys[1][Name]"
        label="Partition Key 2"
        width="150"
        sortable>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
export default {
  data() {
    return {
      input: '',
      tables: []
    }
  },
  methods: {
    getDummyData() {
      let uri = 'https://XXXXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/prod/tables';
      this.$axios.get(uri)
        .then((response) => {
          this.tables = response.data
        })
        .catch((e) => {
          alert(e);
        });
    }
  }
}
</script>
```

これを呼び出す側の`App.vue`はこんな感じで記述する。
（最終的には色々やりたいので、Element UIを使ったコンポーネントのガラだけはたくさん作ってあるが、`CatalogueTable.vue`以外の中身はまだ空に近い）。

```vuejs:src/App.vue
<template>
  <div id="app">
    <div>
    <img alt="Vue Logo" src="@/assets/gluelogo.png/" width="60" height="60">
      <h1>Data Catalogue Explorer</h1>
      <el-menu :default-active="activeIndex" mode="horizontal" background-color="#545c64" text-color="#fff" active-text-color="#ffd04b" router>
        <el-menu-item index="home" :route="{ name:'Home' }">ホーム</el-menu-item>
        <el-menu-item index="dataset" :route="{ name:'Dataset' }">データセットの一覧を見る</el-menu-item>
        <el-menu-item index="finder" :route="{ name:'Finder' }">データセットを探す</el-menu-item>
        <el-menu-item index="adhoc" :route="{ name:'Adhoc' }">アドホック検索</el-menu-item>
        <el-submenu index="catalogue">
            <template slot="title">システムカタログ</template>
            <el-menu-item index="catalogue-database" :route="{ name:'CatalogueDatabase' }">データベース</el-menu-item>
            <el-menu-item index="catalogue-table" :route="{ name:'CatalogueTable' }">テーブル</el-menu-item>
            <el-menu-item index="catalogue-crawler">クローラー</el-menu-item>
            <el-menu-item index="catalogue-job">ジョブ</el-menu-item>
            <el-menu-item index="catalogue-jdbc">JDBC接続</el-menu-item>
        </el-submenu>
        <el-menu-item index="api" :route="{ name:'API' }">API実行</el-menu-item>
        <el-menu-item index="element" :route="{ name:'Element' }">Element UI</el-menu-item>
        <el-menu-item index="about" :route="{ name:'About' }">Vue.js</el-menu-item>
        <el-menu-item index="resources" :route="{ name:'Resources' }">リソース</el-menu-item>
      </el-menu>
      <router-view />
    </div>
  </div>
</template>

<script>
export default {
  name: 'app',
  data () {
    return {
      activeIndex: this.$route.name
    }
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>
```

Vue Routerにもこのコンポーネントへのルートを忘れずに追加してやる必要がある。

```javascript:router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
...（略）...
# ここと
import CatalogueTable from '@/components/CatalogueTable.vue'
...（略）...

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
...（略）...
  # ここ
  {
    path: '/cataloguetable',
    name: 'CatalogueTable',
    component: CatalogueTable
  },
...（略）...

```

最後にここまでの内容をコミットし、レポジトリにプッシュ（このままAmplifyの方で自動ビルドが回り、ホスティングされている内容が更新される。詳しくは[前回の記事](https://qiita.com/itsuki3/items/21925a1afb256dd8b935)を参照）。

```
% git add .
% git commit -m "axios defined"
% git push -u origin master
```

### 9. 動作確認

Amplifyがデプロイを回してサイトの更新を完了するまで、待つこと数分。
ブラウザ、スマホでアクセスしてみると、どうやら無事動作している模様。

<img width="1160" alt="スクリーンショット 2020-06-03 16.07.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609876/7c595717-bf43-ddbe-7b01-ee29da2d6fa9.png">

### 10. 落ち穂拾い

- Amplifyマネージドのビルドで、なぜか`console.log`が失敗する。
  - 構文チェックツールの`ESlint`設定が何かおかしいのかも、と疑って、[このあたり](https://stackoverflow.com/questions/34215526/eslint-how-to-disable-unexpected-console-statement-in-node-js/53333105#53333105)を参考にあれこれ検証してみるも解決に至らず。
`console.log`を出さなければいいだけの話なので、ここではいったん忘れることにした。いずれ解決したい。

ポスト三個分の長丁場になってしまったが、ようやくタイトル通りの自習が完了。
追々、APIを追加したりUIをいじってみたりと、色々遊んでみたい。