Vue.js + Vue2-DropzoneでS3の署名付きURLへファイルアップロード

この記事は[うるる Advent Calendar 2019](https://adventar.org/calendars/4548) 18日目の記事です。

# はじめに
[株式会社うるる](https://www.uluru.biz/)にお世話になっている、フリーランスエンジニアの福田と申します。
[昨日の記事](https://qiita.com/cider-sk/items/4e887bfc1e2b2abc5d35)で、**S3の署名付きURLを用いてファイルアップロードを行う**方法が紹介されていました。
そこで、「じゃあフロントエンドから送るにはどうすりゃいいの？」という記事を書いてみたいと思います。

# 要約
- Vue.jsでS3の署名付きURLを用いてファイルアップロードを行う方法の紹介です
- [Vue2-Dropzone](https://rowanwins.github.io/vue-dropzone/docs/dist/index.html#/installation)を使用します
- [CodeSandbox](https://codesandbox.io/s/vue)を使って動かしながら読み進められるように書いてます

# スタート！
#### CodeSandbox上でVue2-DropzoneをInstallする
- 上記のリンクからCodeSandboxへアクセス
- **Dependencies**の**Add Dependency**ボタンをクリック
- Vue2-dropzoneを検索して追加

これでVue2-Dropzoneを使用できるようになりました。

#### とりあえず動くようにする
[Vue2-dropzoneのサイトのガイド](https://rowanwins.github.io/vue-dropzone/docs/dist/#/installation)に沿って修正してみます。
App.vueを、こういう風に修正しましょう。

```
<template>
  <div id="app">
    <vue-dropzone ref="myVueDropzone" id="dropzone" :options="dropzoneOptions"></vue-dropzone>
  </div>
</template>

<script>
import vue2Dropzone from 'vue2-dropzone'
import 'vue2-dropzone/dist/vue2Dropzone.min.css'

export default {
  name: "App",
  components: {
    vueDropzone: vue2Dropzone
  },
  data: function () {
    return {
      dropzoneOptions: {
          url: 'https://httpbin.org/post',
          thumbnailWidth: 150,
          maxFilesize: 0.5,
          headers: { "My-Awesome-Header": "header value" }
      }
    }
  }
};
</script>
```

**Drop files here to upload**と書かれた四角い領域が表示されましたか？
そこをクリックするか、ファイルをドラッグアンドドロップすればサムネイルが表示されます。
これでUIは完成です。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243375/94f0f480-95d6-7d99-63eb-8d17baec5bd4.png">

### Fileオブジェクトを送信できるようにする
次はFileオブジェクトを送信できるようにしましょう。
Vue2-DropzoneはFormデータとして画像データを送信しますが、署名付きURLでS3へ送る場合はFileオブジェクトを送信する必要があります。
その方法を説明します。

Vue2-Dropzoneには様々なイベントが存在します。
ファイル送信時に発火する`sending`イベントにメソッドイベントハンドラを定義すると、引数としてFileオブジェクトとXHRオブジェクトを受け取ることができます。
そのXHRオブジェクトのBodyにFileオブジェクトをセットすればOKです。
また、署名付きURLへオブジェクトを送信する場合はPUTメソッドで送信する必要があるので、その設定も変えます。

こちらのissuesを参考にしました。
https://github.com/enyo/dropzone/issues/33#issuecomment-150659202

修正箇所を抜粋したものがこちら。

```HTML
<!-- method に PUT を明示 -->
<!-- @vdropzone-sending を追加 -->
<vue-dropzone
  ref="myVueDropzone"
  id="dropzone"
  method="PUT"
  :options="dropzoneOptions"
  @vdropzone-sending="sending"
></vue-dropzone>
```

```JavaScript
export default {
  …
  methods: {
    sending(file, xhr) {
      const _send = xhr.send;
      xhr.send = function() {
        // Fileオブジェクトを送信するようにする
        _send.call(xhr, file);
      };
    }
  }
}
```

### ファイルの送信先を動的に変える
通常、ファイルのアップロード先は`options`の`url`で指定したURIになるのですが、これを署名付きURLに変えます。
DropzoneのQueueにあるファイルが処理されるタイミングで`processing`イベントが発火するので、その時に`options`の`url`を変えてやります。
以下、修正箇所の抜粋です。

```HTML
<!-- @vdropzone-processing を追加 -->
<vue-dropzone
  ref="myVueDropzone"
  id="dropzone"
  method="PUT"
  :options="dropzoneOptions"
  @vdropzone-sending="sending"
  @vdropzone-processing="processing"
></vue-dropzone>
```

```JavaScript
export default {
  …
  methods: {
    …
    processing(file) {
      // 署名付きURLを取得
      // httpbin はHTTPリクエストをテストできるサービス
      const uploadUrl = "https://httpbin.org/put"
      // 実際はこんな感じになると思います
      // const uploadUrl = await axios.get('/api/signed_url')

      // Dropzoneの送信先を変える
      this.$refs.myVueDropzone.dropzone.options.url = uploadUrl
    }
  }
}
```

<details><summary>全体的にはこのようになっていると思います。</summary><div>

```
<template>
  <div id="app">
    <vue-dropzone
      ref="myVueDropzone"
      id="dropzone"
      method="PUT"
      :options="dropzoneOptions"
      @vdropzone-sending="sending"
      @vdropzone-processing="processing"
    ></vue-dropzone>
  </div>
</template>

<script>
import vue2Dropzone from "vue2-dropzone";
import "vue2-dropzone/dist/vue2Dropzone.min.css";

export default {
  name: "App",
  components: {
    vueDropzone: vue2Dropzone
  },
  data: function() {
    return {
      dropzoneOptions: {
        url: "https://httpbin.org/post",
        method: "PUT",
        thumbnailWidth: 150,
        maxFilesize: 0.5,
        headers: { "My-Awesome-Header": "header value" }
      }
    };
  },
  methods: {
    sending(file, xhr) {
      const _send = xhr.send;
      xhr.send = function() {
        // Fileオブジェクトを送信するようにする
        _send.call(xhr, file);
      };
    },
    processing(file) {
      // 署名付きURLを取得
      // httpbin はHTTPリクエストをテストできるサービス
      const uploadUrl = "https://httpbin.org/put";
      // 実際はこんな感じになると思います
      // const uploadUrl = await axios.get('/api/signed_url')

      // Dropzoneの送信先を変える
      this.$refs.myVueDropzone.dropzone.options.url = uploadUrl;
    }
  }
};
</script>
```
</div></details>

アップロードしてみた結果がこちら。
`processing`メソッド内でセットした`https://httpbin.org/put`にPUTメソッドで送信されていればOKです！
<img width="700" alt="mojikyo45_640-2.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243375/d77c28ea-0ce2-a394-48b4-3ade96a2f3a4.png">


### 事前に署名付きURLを取得する
ファイル送信前に署名付きURLを取得しておけば、API呼び出しのターンアラウンドタイムを節約できます。
Dropzoneはファイルの追加のタイミングでイベントを発火するので、イベントハンドラを定義して署名付きURLを取得します。
取得したURLはFileオブジェクトに退避しておいて、ファイル送信時に使用します。

```HTML
<!-- @vdropzone-file-added を追加 -->
<vue-dropzone
  ref="myVueDropzone"
  id="dropzone"
  method="PUT"
  :options="dropzoneOptions"
  @vdropzone-sending="sending"
  @vdropzone-processing="processing"
  @vdropzone-file-added="fileAdded"
></vue-dropzone>
```

```JavaScript
export default {
  …
  methods: {
    …
    fileAdded(file) {
      // ↓↓↓ここはprocessingから移植↓↓↓
      // Fileオブジェクトに署名付きURLのプロパティを追加
      file.uploadUrl = "https://httpbin.org/put"
      // 実際はこんな感じになると思います
      // file.uploadUrl = await axios.get('/api/signed_url')
      // ↑↑↑ここはprocessingから移植↑↑↑
    },
    processing(file) {
      // Fileオブジェクトに退避しておいた署名付きURLでファイル送信先を上書き
      this.$refs.myVueDropzone.dropzone.options.url = file.uploadUrl
    }
  }
}
```


<details><summary>全体的にはこのようになっていると思います。</summary><div>

```
<template>
  <div id="app">
    <vue-dropzone
      ref="myVueDropzone"
      id="dropzone"
      method="PUT"
      :options="dropzoneOptions"
      @vdropzone-sending="sending"
      @vdropzone-processing="processing"
      @vdropzone-file-added="fileAdded"
    ></vue-dropzone>
  </div>
</template>

<script>
import vue2Dropzone from "vue2-dropzone";
import "vue2-dropzone/dist/vue2Dropzone.min.css";

export default {
  name: "App",
  components: {
    vueDropzone: vue2Dropzone
  },
  data: function() {
    return {
      dropzoneOptions: {
        url: "https://httpbin.org/post",
        method: "PUT",
        thumbnailWidth: 150,
        maxFilesize: 0.5,
        headers: { "My-Awesome-Header": "header value" }
      }
    };
  },
  methods: {
    sending(file, xhr) {
      const _send = xhr.send;
      xhr.send = function() {
        // Fileオブジェクトを送信するようにする
        _send.call(xhr, file);
      };
    },
    fileAdded(file) {
      // ↓↓↓ここはprocessingから移植↓↓↓
      // Fileオブジェクトに署名付きURLのプロパティを追加
      file.uploadUrl = "https://httpbin.org/put";
      // 実際はこんな感じになると思います
      // file.uploadUrl = await axios.get('/api/signed_url')
      // ↑↑↑ここはprocessingから移植↑↑↑
    },
    processing(file) {
      // Fileオブジェクトに退避しておいた署名付きURLでファイル送信先を上書き
      this.$refs.myVueDropzone.dropzone.options.url = file.uploadUrl;
    }
  }
};
</script>
```
</div></details>

先程と同様の結果になっていればOKです！

# 余談ですが、こんなことも
#### サムネイルをVue.jsのコンポーネントにする
サムネイル生成時に発火する`vdropzone-thumbnail`イベントを利用すれば、dataUrlを含んだFileオブジェクトを取得できます。
それをコンポーネントの`props`に渡してサムネイルを描画してやれば実現可能です。
コンポーネントなのでスタイルは自由に決められますし、様々なイベントを設定することもできます。

#### サムネイルのドラッグアンドドロップ
サムネイルをコンポーネント化することができれば、DnDも実現可能です。
Vue.jsのDnDのライブラリは色々あると思いますが、それらと組み合わせることでリッチな画像アップローダーを簡単に作れます。

この辺の話は別の機会に記事に纏められればと思います。

# 終わり
Advent Calendar 18日目でした。
明日19日目は tatsukoni さんによる記事を乞うご期待！
https://adventar.org/calendars/4548