Node.jsからmultipart/form-dataでデータをPOSTする

お久しぶりです。最近Node.jsばかり書いているwakです。

さて、最近こういうことがありました。

  1. Slackにスニペットをアップロードするするスクリプトを書きたい
  2. Slack botトークンをスクリプトには埋め込みたくない
  3. そうだ! Lambdaの中にトークンを書くことにしよう!

というわけで、こういう流れになりました。

  1. スクリプトはLambdaにパラメーターを渡して実行する
  2. LambdaはSlackのエンドポイントをHTTP POSTで叩いてスニペットをアップロードする

つまり、Node.jsからPOSTでファイルアップロードを行いたいのですが、ちょっと探した範囲では 素の https.request でこれを行っているサンプルが見つかりませんでした。各種ライブラリが何をしているか分からないのも気持ちが悪いので、自分で書いたコードを残しておきます。

f:id:nurenezumi:20171102092624j:plain

猫パンチを送信しようとしている猫

やりたいこと

export token="xoxb-000000000000-xxxxxxxxxxxxxxxxxxxxxxxx"
export channel="channel_name"
export filename="filename"
curl -F file=@test.zip -F channels=$channel -F token=$token https://slack.com/api/files.upload -F title=$filename

と同じデータを送信したい。内容はバイナリかもしれない。

結論

コードは次の通りです。

"use strict"

const https = require("https");

let binaryArray = [0x00, 0x40, 0x01, 0x41, 0x02, 0x42, 0x03, 0x43]; // ファイルの内容のバイト配列
let buffer = Buffer.from(binaryArray); // バイト配列をそのままBufferに
let content = buffer.toString("ascii"); // 1バイトずつstringへ変換

let options = {
    host: "slack.com",
    port: 443,
    path: "/api/files.upload",
    method: "POST",
    headers: {
        "Content-Type": "multipart/form-data; boundary=------------------------deadbeefdeadbeef"
    }
};

let req = https.request(options, res => {
  res.setEncoding("utf8");
  res.on("data", (chunk) => {
    // レスポンスを受け取って何か処理をしたいならここでやる
  });
});
req.on("error", (e) => {
  console.error(e.message);
});
req.write(`--------------------------deadbeefdeadbeef\r
Content-Disposition: form-data; name="file"; filename="filename.dat"\r
Content-Type: application/octet-stream\r
\r
${content}\r
--------------------------deadbeefdeadbeef\r
Content-Disposition: form-data; name="channels"\r
\r
channel_name\r
--------------------------deadbeefdeadbeef\r
Content-Disposition: form-data; name="token"\r
\r
xoxb-000000000000-xxxxxxxxxxxxxxxxxxxxxxxx\r
--------------------------deadbeefdeadbeef\r
Content-Disposition: form-data; name="title"\r
\r
filename\r
--------------------------deadbeefdeadbeef--\r
`);
req.end();

ここではあえてパラメーターの名前や値を直接ベタ書きしていますが、動的にやるのも別に難しくないでしょう。簡単ですね。

なお、

  • テンプレートリテラルの改行コードはプラットフォームによらずLF(\n)に統一される
  • multipartでデータを送信する際に使用する改行コードはCRLF(\r\n)とされている

という違いから、面倒だとは思いながらも各行の末尾に \r を書いています*1replace(/\n/g, "\r\n") などとやって一括置換をかけると content の中身まで置換される可能性があるのでやめましょう。

説明

上で示したように

curl -F file=@test.zip -F channels=$channel -F token=$token https://slack.com/api/files.upload -F title=$filename

こんなPOSTを送信すると、サーバーには以下に示すようなデータが送信されます。

ヘッダ部

重要な値として Content-Type があります(というか重要なのはこれだけです)。

Content-Type: multipart/form-data; boundary=------------------------deadbeefdeadbeef

これは、ボディ部にmultipartで複数のデータ(ファイルの内容、その他のパラメーター)が送信されること、そしてそれぞれの区切り目(境界区切子)が何かを示しています(RFC 2046)。 boundary の書式にはややこしい制約があるようなのですが、ハイフンをたくさん並べた後ろにランダムな英数字*2を書けば間違いはありません。

ボディ部

前半はファイル名とその内容、後半はその他のパラメーターです。それぞれの値は「ハイフン2個 + boundary の値 + CRLF」で区切ることとされているので、よく見ると boundary の値とはハイフンの数が違います。また、一番最後にはハイフンを2個付けます。

--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="file"; filename="test.zip"
Content-Type: application/octet-stream

<バイナリデータ>
--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="channels"

channel_name
--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="token"

xoxb-000000000000-xxxxxxxxxxxxxxxxxxxxxxxx
--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="title"

filename
--------------------------deadbeefdeadbeef--

テキストファイルの場合は text/plain にできます。文字エンコーディングはコード中で指定している通り(この場合はUTF-8)です。

--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain

テキストデータ

オチ

Lambdaの環境には curl コマンドも入っているので、ファイルを /tmp あたりに作ってそのまま実行すれば済んだりします。ただしコンテナは使い回される可能性が高いため、作成したファイルは必ず削除しておきましょう。

*1:実際にはSlackはLFのみでも受け付けてくれましたが

*2:ファイルやパラメーターの内容とかぶらないように