Google Apps Script（GAS）から S3 にあるファイルを取得する

# はじめに

業務で s3 からデータを取得してスプレッドシート に吐き出すというタスクを行なっていた際、Google Apps Script（gas） で aws sdk for js v3(aws-sdk) が使えず s3 からのデータ取得するコードをスクラッチで実装する必要があったのでやり方をまとめます。

コードだけ知りたい方は[こちら](#コード)をどうぞ。

# 試したこと

## aws-sdk

下記の aws-sdk を試しました。

[https://docs.aws.amazon.com/ja_jp/AWSJavaScriptSDK/v3/latest/index.html](https://docs.aws.amazon.com/ja_jp/AWSJavaScriptSDK/v3/latest/index.html)

具体的には、以下の手順を踏みました

- node_modules として、aws-sdk をインストール
- webpack を使ってビルド
- ビルドしたコードを gas にアップロード

gas のエディターから関数を実行すると、以下のようなエラーがでました。

```plaintext
ReferenceError: URL is not defined
```

ビルドされたコードの中を見ると以下のような部分でエラーが発生していました。

```jsx
var parseUrl = function (url) {
    var _a = new URL(url) // ReferenceError: URL is not defined
    // ...
};
```

つまり、gas では [URL API](https://developer.mozilla.org/ja/docs/Web/API/URL) は使えないということです。gas は js の文法で書けるものの、js ではないので今回のように js では使えて gas では使えない機能がわりとあります。

## AWS Signature Version 4 を使って api 経由で取得

[こちら](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/RESTAPI.html)の記事にあるよに、api 経由でデータを取得するために、host や authorization などをヘッダーに指定してリクエストを送ります。

リクエスト時に、どんな値をヘッダーに指定するかを取り決めたものが AWS Signature Version 4 です。主に authorization ヘッダーの作成の仕様を定義しています。こちらについては下記の記事でまとめています。

[https://qiita.com/masachoco/items/3904053b7e0910e91218](https://qiita.com/masachoco/items/3904053b7e0910e91218)

よし、この方法でできる！と思いきや、うまくいきません。

理由と指定は、gas で http リクエストを使う際に使用する [UrlFetchApp の fetch モジュール](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app#fetch(String,Object))で、host ヘッダーを指定できないためです。以下のようなエラーが出ます。

```jsx
// Exception: Attribute provided with invalid value: Header:Host
UrlFetchApp.fetch("http://www.google.com/", {headers: {host: "www.google.com"}})

```

これはおそらく gas 側の仕様で host ヘッダーを指定できないようにしていると思われます。他のヘッダーは問題なく指定できました。

AWS Signature Version 4 では、リクエストに host ヘッダーを必ず指定する必要があるためこの方法も gas では使えません。

[https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sig-v4-header-based-auth.html](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sig-v4-header-based-auth.html)

# 解決策

どうしたものかと途方に暮れている時下記のドキュメントを発見しました。

[https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/RESTAuthentication.html](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/RESTAuthentication.html)

こちらは、AWS Signature Version 2 になります。こちらの方法なら、host ヘッダーを指定する必要がなく、 authorization ヘッダーに署名を含めるだけで良いです。

この方法なら！

と試したところ、gas から s3 のデータを取得できました！

やり方を以降のセクションでまとめます。

## gas から AWS Signature Version 2 の署名を使って s3 からデータを取得する

### 準備

#### iam ユーザの作成

`AmazonS3ReadOnlyAccess` ポリシーのアタッチされたユーザーを作成してください。この時に発行される、`access_key_id`と `secret_access_key` をメモしておいてください。後で使います。

#### バケットを作成

東京リージョン(ap-northeast-1)にバケットを作成してください。この時のバケット名をメモしておいて下さい。後で使います。

#### バケットにファイルを保存

先ほど作成したバケットにファイルを保存します。ファイル名やディレクトリ名は何でも構いません。本記事では、以下の内容のテキストファイルをバケット直下に test.txt という名前で保存します。

```
s3 fetchObject test
```

### 環境変数の設定

スクリプトプロパティーに以下の環境変数を設定します。先ほどメモしておいた値を使います。

```
AWS_ACCESS_KEY=<access_key_id>
AWS_SECRET_KEY=<secret_access_key>
BUCKET=<bucket名>
REGION=ap-northeast-1
```

スクリプトプロパティーの設定は、旧エディターもしくは下記のスクリプトを用いて設定できます。

```jsx
PropertiesService
	.getScriptProperties()
	.setProperty(<Key>, <Value>)
```

### コード

```jsx
const AWS_ACCESS_KEY = getScriptProperty("AWS_ACCESS_KEY")
const AWS_SECRET_KEY = getScriptProperty("AWS_SECRET_KEY")
const BUCKET = getScriptProperty("BUCKET")
const REGION = getScriptProperty("REGION")
const ENDPOINT = `https://s3.${REGION}.amazonaws.com/${BUCKET}/`

class S3Service {
  constructor() {
    this.requestDate = new Date()
  }

  fetchObject(filepath) {
    this.setRequestDate()
    const url = this.getUrl(filepath)
    const opt = this.getOption({
      httpMethod: "GET",
      canonicalizedResource: this.canonicalizedResource(filepath),
    })
    const res = UrlFetchApp.fetch(url, opt)
    return res
  }

  getUrl(filepath) {
    return ENDPOINT + filepath
  }

  getOption(params) {
    return {
      method: params.httpMethod.toLowerCase(),
      headers: {
        Authorization: this.authorization(params),
        Date: this.requestDate.toUTCString(),
      },
      muteHttpExceptions: true,
    }
  }

  setRequestDate() {
    this.requestDate = new Date()
  }

  authorization(params) {
    return `AWS ${AWS_ACCESS_KEY}:${this.signature(params)}`
  }

  signature(params) {
    const sign = this.stringToSign(params)
    const hash = Utilities.computeHmacSignature(
      Utilities.MacAlgorithm.HMAC_SHA_1,
      sign,
      AWS_SECRET_KEY
    )
    return Utilities.base64Encode(hash)
  }

  stringToSign(params) {
    const signs = []
    signs.push(params.httpMethod || "")
    signs.push(params.contentMd5 || "")
    signs.push(params.contentType || "")
    signs.push(this.requestDate.toUTCString() || "")
    signs.push(
      (params.canonicalizedAmzHeaders || "") + (params.canonicalizedResource || "")
    )
    return signs.join("\n")
  }

  canonicalizedResource(filepath) {
    return "/" + BUCKET + "/" + filepath
  }
}

function getScriptProperty(key) {
  const scriptProperties = PropertiesService.getScriptProperties()
  return scriptProperties.getProperty(key)
}

function fetchObject() {
  const s3 = new S3Service()
  const res = s3.fetchObject("test.txt")
  Logger.log(res)
}
```

エディターから `fetchObject` を実行して以下のように表示されれば成功です

```
s3 fetchObject test
```