API Gateway + LambdaでCSVデータ取得のWebAPIを作る [Node.js]

#はじめに
本記事では、「API Gateway」と「Lambda」を使って、「ブラウザからCSVアップロード→データ取得する」までをテーマにまとめています。
あまりLambdaを触ったことがないという人にもわかりやすいように画像多めに載せてみました。

:earth_africa: *[Wantedyストーリー記事](https://www.wantedly.com/companies/company_8774554/post_articles/322461) の内容をこちらのQiitaに記載しています。*

###やりたいこと
やりたいことの流れとしてはこんな感じです。

① クライアント側でCSVファイルをアップロード
② URLを叩いて、API Gatewayにアクセス
③ Lambda発火
④ LambdaがアップロードされたCSVからデータを取得する

図を書くまでもないかもしれないですが、一応このような感じですね。
![スクリーンショット 2021-04-25 11.10.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/15104938-9c44-ef33-e9e9-212bd74ff827.png)

###Lambdaの前準備
Lambdaのコードを乗せる箱をAWS上に作ります。Lambdaのコンソール画面から任意の関数名をつけて新しく関数を作成してください。今回VPCに入れる必要はないので、詳細設定は特に設定せずに作成しておきます。

ランタイムは`Node.js 14.x`を選択しました。
![スクリーンショット 2021-04-25 11.36.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/ef545a8a-2330-50b2-e973-fc3a72305675.png)

#API Gateway
以下、API Gatewayの設定です。今回はREST APIで作っています。

アクションのプルダウンからメソッド「POST」を作成します。Lambdaプロキシ統合を使用しています。リージョンと作成したLambda関数を指定して「保存」をクリックします。
メソッドレスポンスのHTTPステータスにはデフォルトで「200」しか設定されていませんので、「400」「500」も追加してあげましょう。
※OPTIONSメソッドが自動で作成されます。これはCORS設定で必要なメソッドのため消さずにこのままにしておきます。
CORS参考リンク： [REST API リソースの CORS を有効にする](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/how-to-cors.html)

###バイナリメディアタイプの設定
クライアント側からはCSVファイルを添付して、multipart/form-data形式でフォームデータを送信します。
上記までのAPI Gatewayの設定のままではバイナリデータを受け取れません。
別途設定してあげる必要があります。
![スクリーンショット 2021-04-25 12.04.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/2127af66-8556-a18e-bed4-633a709ce4dc.png)
「設定」をクリックしたら、「バイナリメディアタイプ」に`multipart/form-data`を入力します。
![スクリーンショット 2021-04-25 12.09.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/d9755882-f1a6-7a57-fff3-0ed63748aca9.png)

追加して、任意のステージにデプロイしたらAPI Gatewayの設定は完了です。
Lambda関数のコンソール画面で、作成したAPIエンドポイントが紐付いているのを確認してください。
![スクリーンショット 2021-04-25 12.40.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/618c0f98-62c5-5f4b-cd4e-dabf24474660.png)

#Lambda
multipart/form-data形式のパラメータを受け取り、CSVデータを取得するコードの記述例を載せます。

自前でmultipart/form-dataやCSVをパースしたりするのは大変です。
そこで以下ライブラリを使用しました。

- [lambda-multipart-parser](https://www.npmjs.com/package/lambda-multipart-parser)
- [csv-parse](https://www.npmjs.com/package/csv-parse)
- [encoding-japanese](https://www.npmjs.com/package/encoding-japanese)

lambda-multipart-parserは、イベントで受信したmultipart/form-dataのbodyをパースしてファイルデータとテキストデータを以下のようなJSON形式にしてくれます。

```javascript
{
    files: [
        {
            filename: 'test.pdf',
            content: <Buffer 25 50 6f 62 ... >,
            contentType: 'application/pdf',
            encoding: '7bit',
            fieldname: 'uploadFile1'
        }
    ],
    field1: 'VALUE1',
    field2: 'VALUE2',
}
```

ライブラリ「csv-parse」は、Windows, Apple, Linuxそれぞれの改行コードを自動認識してくれるので便利です。

エンコードライブラリを入れたのは、CSVファイルの文字コードに`UTF-8（BOM付・BOM無）`、`Shift-JIS`、`EUC-JP`等が存在するからです。
ライブラリの「csv-parse」にもエンコードするオプションがついていますが、日本語特有のEUC-JPには対応していない等、これだけに頼るのはちょっと心配です。日本語のあらゆる文字コードに対応した「encoding-japanese」で「csv-parse」で対応する文字コードに変換（今回はUTF-8）してあげてから、CSVデータをパースするようにします。

今回は、アップロードしたCSVの中身のデータをクライアント側にレスポンスとして返してあげます。

```javascript
'use strict'

const parser = require('lambda-multipart-parser');
const parse = require('csv-parse/lib/sync');
const Encoding = require('encoding-japanese');

exports.handler = async(event, context) => {
   // 返却するレスポンスのステータスコード初期値は500にしておく
   const res = {
        statusCode: 500,
        headers : { // API Gateway CORS有効時には以下のヘッダーリストが必要
            "Access-Control-Allow-Headers" : "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "OPTIONS,POST"
        }
    };

   try{
       console.log(`Function ${contxt.functionName} started.`);

       // multipart/form-data形式のパラメータをパース
        const req = await parser.parse(event);

        const params = {
            file:  req.files[0].content,
            text:  req.text     // file以外のパラメータがあればこのように取得する
        }

       console.log(`エンコードを開始します...`);
       const detected = Encoding.detect(params.file); // 文字コード自動検出
       console.log(`アップロードファイルの文字コードは${detected}です`);

       const convertedFile = Encoding.convert(params.file, 'UTF8', detected); // UTF8へ変換
       const data = Buffer.from(convertedFile); // 変換後に文字コード配列になっている為、Bufferに戻す
       console.log(`エンコードが完了しました`);

       console.log(`CSVデータのパースを開始...`);
       const records = parse(data, {
            delimiter: ",",          // カンマ区切り
            trim: true,              // フィールド値に空白がある場合に無視
            bom: true,               // UTF8の場合にBOM有無判定
            skip_empty_lines: true,  // 空行があった場合は、読込をスキップする
            from_line: 1             // 行の読み込み開始行を指定（今回は1行目固定）
        });

        const responseBody = {
            records: records,
            message: params.text
        };

        console.log(`Function ${context.functionName} end.`);

        res.statusCode = 200;
        res.body = JSON.stringify(responseBody);
        return res;

   }catch(err){
        console.error(err, `Function ${context.functionName} abend.`);

        res.body = JSON.stringify({ message: err.message });
        return res;
   }
}
```

###梱包ファイル

今回ライブラリを使用しているので、コード単体では動作しません。
モジュールを含めてzip化してからLambdaにアップロードします。

```
$ yarn init   （もしくは npm init）
initできたら、各ライブラリをyarn add もしくはnpm installでインストール
```

ディレクトリファイルを一覧化しました。（参考までに）

```
tree -L 1
.
├── index.js
├── node_modules
├── package.json
└── yarn.lock

1 directory, 3 files
```

作業しているディレクトリ配下でアップロード用にzip化するコマンドを入力してください。
（今回は動作を確認するだけなので、webpackは使用しません）

```
$ zip -r src.zip index.js node_modules/
```

同じ階層にzipファイルができているはずです。それをLambdaのコンソール画面でアップロードします。

#HTMLでアップロード画面作成
クライアント側からファイルをアップロードできるように画面を作ります。

今回はVue.jsとaxiosを使って、API GatewayのエンドポイントURLに向けてPOSTを投げます。あまりに画面がシンプル過ぎたので、ボタンと入力フォームだけ[elemental-ui](https://element.eleme.io/#/en-US)で中央寄せするスタイルを少しあてています。

```html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>CSV Uploader</title>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios@0.21.1/dist/axios.min.js"></script>
    <link rel="stylesheet" href="./style.css">
    <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
</head>
<body>
    <div id="app">
        <div class="wrapper">
            <form>
                <input type="file" id="file" ref="file" @change="handleFile" class="input"/><br>
                <el-input type="text" v-model="message" placeholder="edit me!"></el-input>
                <el-button type="primary" round @click="uploadFile" class="sendBtn">送信</el-button>
            </form>
        </div>
    </div>
    <script src="https://unpkg.com/element-ui/lib/index.js"></script>
    <script type="text/javascript">
        const vm = new Vue({
            el: "#app",
            data() {
                return {
                    file: '',
                    message: ''
                }
            },
            methods: {
                handleFile() {
                    this.file = this.$refs.file.files[0]; // アップロードファイルをセット
                },
                uploadFile() {
                    const formData = new FormData();
                        formData.append('file', this.file);
                        formData.append('text', this.message);
                        axios.post('API GatewayのエンドポイントURL',
                            formData, {
                                headers: {
                                    'Content-Type': 'multipart/form-data'
                                }
                            }).then(res => {
                                this.message = "";
                                console.log("Success!");
                                console.log(res.data.records);
                                console.log(res.data.message);
                        }).catch(err => {
                            console.log(`Failed... ${err}`);
                        })
                    }
                }
        })
    </script>
</body>
</html>
```

###アップロード画面
![スクリーンショット 2021-04-25 17.08.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/5f13c5cd-7d5c-7d7f-7567-311fefbb42cf.png)

###CSVファイルアップロード
こんな感じのランダムなデータで生成されたCSVファイルを用意しました。
ちなみにこのファイルの文字コードはShift-JISです。
![スクリーンショット 2021-04-25 17.18.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/29e82830-082f-f9f9-fe77-04dd6e8df15f.png)
メッセージ付きで送信してみます。
![スクリーンショット 2021-04-25 17.54.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/25f35724-720d-845e-6ae1-08f098157933.png)
レスポンスの中身をconsole.logで出しているので、Chromeのデベロッパーツールで見てみます。「Success!」というログのあとに沢山の配列データと、入力して送信したメッセージ「てすと」が表示されています👀

違うメッセージを入力してもう一回送信してみます。配列データの中身も見てみると
![スクリーンショット 2021-04-25 18.08.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/e2762f6a-5e62-30d6-5865-9a774eedd131.png)
CSVの中のデータが返ってきているのを確認できました^^

文字コードがちゃんと認識されていたか確認するには、Lambdaの方でログを出していたのでCloudWatchのログイベントを見てみます。
![スクリーンショット 2021-04-25 18.19.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559229/ab83a557-02bf-a6cb-f047-7eb90aa5b942.png)
Shift-JISのファイルがちゃんと、SJIS（Shift-JIS）として認識されていますね💡

#さいごに
今回は、Lambdaで取得したデータをそのままクライアント側へ返すだけでしたが、DBに登録したり、S3バケットにPutしてクライアントからダウンロードできるようにしたりと取得したあとにできることの幅は色々あります。

パースする箇所だけ変えれば、Excelからも同じようにデータを取得できます。

サーバーレスのいいところは、１機能をWebAPIとしてさくっと作れるところです。
今後もサーバーレスでできることを紹介していけたらと思います😃