Gyazo API を Node.js の Buffer から直に使おうとしたらハマった話

結論: Gyazo API のアップロードは Content-Type が不要だがなぜか fileName については暗黙的に要求する

以前の記事でも話した通り、私はパーマネントな画像をすぐに展開できるストレージとして日常的に Gyazo を使っています。

日常的なスクリーンショットでは Gyazo の Desktop App で事足りていたのですが、ちょっと個人的な用事で Bot に画像を添付したいモチベーションが出てきて。

Bot 自体はステートレスに管理したく、また投稿先も Slack なのか何なのかまだ決めていないのもあって、とりあえず Node.js から Gyazo API を叩いてアップロードすることにしました。

ただ5分くらいで終わるかと思ったら、なんやかんや 20 分くらいかかってしまったのでハマりメモとして残しておきます。

Gyazo API について

Gyazo には、Twitter アプリみたいに OAuth 認証のアプリケーションを作成し、認証によってトークンを払い出して利用できる API が存在します。

https://gyazo.com/api?lang=ja

これで色んなサービスの裏で Gyazo を動かすことができて結構便利みたいです。本格的なアプリケーションへの組み込みを想定して作られている中申し訳ない気持ちになりつつ、今回は OAuth はすっとばしてアプリケーション管理画面で発行できる自分用のトークンを利用しました。

Image from Gyazo

Node.js で使うなら自前実装が良さそう

発行もできたのでサクッと自前で実装しようと思ったのですが、ファイルアップロードと HTTP 通信って JSON でやりとりできないので mutipart/form-data が必要だったりなんやりして面倒なので、その辺りにコストかけないためにオフィシャルにライブラリでもないかな。と思っていたら、今は Scrapbox の方を開発しているっぽい shokai さんのラッパーがあったのでちょっと見てみることに。

https://github.com/shokai/node-gyazo-api

パット見よさそうで、まだ使えそうではあるものの、最終コミットが 2016 年であんまりメンテされてなかったり、型定義がなかったり、アップロードが Buffer から直を想定していなかったり、最近の API 全般には対応していなかったり割と現代的に使うにはつらそうな要素が多かったので、今使うなら自前実装してやるほうが丸そうでした。

Gyazo API は暗黙的に fileName を要求する

というわけで自前で実装することにしたのですが、 string | Bufferfs.ReadStream で挙動が違う現象が起きました。

具体的には以下の string | Buffer でのコードは 400 | 500 となり、 fs でのコードは 200 が返却されます。

まずは 400 のコード

// string | Buffer のもの
import FormData from 'form-data'

if (!process.env.GYAZO_ACCESS_TOKEN) {
  process.exit(1)
}
const GYAZO_ACCESS_TOKEN = `${process.env.GYAZO_ACCESS_TOKEN}`
// ...
async function run() {
  // ...
  const ss = await page.screenshot() // Puppeteer で `string | Buffer` を受け取る
  const form = new FormData()

  form.append('access_token', GYAZO_ACCESS_TOKEN)
  form.append('imagedata', ss)

  // これがエラーになる
  const res = await axios.post<UploadAPIResponse>(
    'https://upload.gyazo.com/api/upload',
    form,
    {
      headers: form.getHeaders()
    }
  )
  // ...
}

そして 200 のコード

// string | Buffer のもの
import { promises as fs, createReadStream } from 'fs
import FormData from 'form-data'

if (!process.env.GYAZO_ACCESS_TOKEN) {
  process.exit(1)
}
const GYAZO_ACCESS_TOKEN = `${process.env.GYAZO_ACCESS_TOKEN}`
// ...
async function run() {
  // ...
  const ss = await page.screenshot() // Puppeteer で `string | Buffer` を受け取る
  await fs.writeFile('ss.png', ss) // ファイルに保存
  const form = new FormData()

  form.append('access_token', GYAZO_ACCESS_TOKEN)
  form.append('imagedata', createReadStream(ss))

  // これは成功する
  const res = await axios.post<UploadAPIResponse>(
    'https://upload.gyazo.com/api/upload',
    form,
    {
      headers: form.getHeaders()
    }
  )
  // ...
}

最終的に吸い出せるバイナリは同じはずなのに、前者だけが 400 や 500 が返却される状態です。

Gyazo API が 500 は仕方ないとしても、 400 の場合でさえ原因を何も書かないというかなりワイルドな仕様になっていることも影響し、とりあえず fs であって Buffer でないものを探すことにしました。

Image from Gyazo

fs であって Buffer でないものとして、一番大きなものは Content-Type です。Gyazo のオフィシャルドキュメントに書かれていない情報ではありますが、まぁ API ドキュメントにも「画像データ」と書かれているあたりから、 Content-Type を見ていてもおかしくはありません。

Image from Gyazo

試しにこんな感じでデータを定義してみました。すごい動きそうなコードですが、動きません。

form.append('imagedata', ss, {
  contentType: 'image/png',
  knownLength: ss.length
})

なので一緒にファイル名も渡してやると、無事成功しました。ファイル名と Content-Type が必須というのはファイルとしての要件を満たす実装として妥当な気がします。

form.append('imagedata', ss, {
  filename: 'ss.png',
  contentType: 'image/png',
  knownLength: ss.length
})

一応ついでにファイル名だけでも上げてみます。

form.append('imagedata', ss, {
  filename: 'ss.png',
  knownLength: ss.length
})

Image from Gyazo

嘘でしょ。

わかったこと

  • Gyazo はアップロード API で暗黙的に fileName を参照している
    • そのため、バイナリに fileName が存在しない場合はエラーとなり、 500 が返却される
    • 存在しない場合は Bad Request として返却して欲しい
  • 逆に Gyazo はアップロード API で Content-Type を見ていない
    • ss.png という名前で全然画像じゃないバイナリをあげるのは悪意があるのでやってはいない
    • 多分バイナリデータから画像ファイルかどうかは見ているとは思うので問題はないと思うが……
  • Gyazo API は異常系に対してすべて無言を貫き通す
    • 400 くらいは reason があってもよくないか
  • 微妙に直感と反する挙動なので利用の際は注意しましょう

という感じでした。とりあえずハマりは解消されてよかった。