🐔

fetch で multipart/form-data を送る時は Content-Type を指定してはいけない

2023/02/10に公開

概要

タイトルのまんま。以上。

ちょっと説明

とは言え、それだけではあんまりなので軽く説明をば。

JavaScript に fetch と言うメソッドがあるのは皆さんご存知の通りだが(Web ブラウザ環境の場合だが)、コイツは任意のリクエストボディを送ることが出来る。

で、任意と言うからには multipart/form-data 形式のリクエストボディも送ることが出来るのだが、その場合には⇓のようにリクエストボディとして FormData のインスタンスを渡す必要がある。

# 例 1 : 正しいやり方
const body = new FormData()
body.append('data1', 'value1')
body.append('data2', 'value2')
const response = fetch('/path', {
  method: 'POST',
  body,
})

この際、multipart/form-data 形式で送るからと言って Content-Type ヘッダを指定するとマトモに送信できなくなってしまう。

# 例 2 : 正しくないやり方
const body = new FormData()
body.append('data1', 'value1')
body.append('data2', 'value2')
const response = fetch('/path', {
  method: 'POST',
  headers: {
    'Content-Type': 'multipart/form-data', // ダメ。ゼッタイ。
  },
  body,
})

だから、fetchmultipart/form-data を送る時は Content-Type を指定してはいけない。

なぜなのか

実際の HTTP リクエストを見てみると、その理由がわかる(多分)。

正しいやり方の場合(Content-Type を指定しない)

まずは最初の正しいやり方(例 1)の場合の HTTP リクエスト。

POST /path HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Content-Length: 240
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1BBtEEYOY4gX4AY0
DNT: 1
Host: localhost
Origin: http://localhost
Referer: http://localhost/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
sec-ch-ua: "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"

------WebKitFormBoundary1BBtEEYOY4gX4AY0
Content-Disposition: form-data; name="data1"

value1
------WebKitFormBoundary1BBtEEYOY4gX4AY0
Content-Disposition: form-data; name="data2"

value2
------WebKitFormBoundary1BBtEEYOY4gX4AY0--

やたらと長いが、注目すべきは Content-Type ヘッダの値である。
ここが(知ってる人からすれば当たり前だろうが)単なる multipart/form-data ではなく、multipart/form-data; boundary=----WebKitFormBoundary1BBtEEYOY4gX4AY0 となっている。
当然、実際にリクエストボディも指定された boundary で区切られている。

正しくないやり方の場合(Content-Type を指定する)

次に正しくないやり方(例 2)の場合の HTTP リクエスト。

POST /path HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Content-Length: 240
Content-Type: multipart/form-data
DNT: 1
Host: localhost
Origin: http://localhost
Referer: http://localhost/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
sec-ch-ua: "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"

------WebKitFormBoundarygVFwBM7a2OFANjqX
Content-Disposition: form-data; name="data1"

value1
------WebKitFormBoundarygVFwBM7a2OFANjqX
Content-Disposition: form-data; name="data2"

value2
------WebKitFormBoundarygVFwBM7a2OFANjqX--

やはりやたらと長いが、見ての通り Content-Type ヘッダの値が単なる multipart/form-data となってしまっている。
リクエストボディはナゾの boundary で区切られているにもかかわらず、だ。

つまり、Content-Type ヘッダを指定してしまうと、multipart/form-data の場合に必要な boundary パラメータが付かなくなってしまうため、リクエストボディをうまく分割できなくなってしまうのだ。

multipart/form-data を指定した場合でも追加で ; boundary=... ってのを付けれくれりゃいいじゃん、と思わないことも無いが、実際そうはなっていないので諦めるしかない。

だったら boundary も指定すればいんじゃね?

だったら Content-Type に適当な boundary パラメータを付けたものを指定したらどうなのよ。
ということでやってみる。

# 例 3 : boundary も指定したらどう?
const body = new FormData()
body.append('data1', 'value1')
body.append('data2', 'value2')
const response = fetch('/path', {
  method: 'POST',
  headers: {
    'Content-Type': 'multipart/form-data; boundary=----WebKitFormBoundary1BBtEEYOY4gX4AY0',
  },
  body,
})

結果は⇓の通り。

POST /path HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Content-Length: 240
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1BBtEEYOY4gX4AY0
DNT: 1
Host: localhost
Origin: http://localhost
Referer: http://localhost/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
sec-ch-ua: "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"


------WebKitFormBoundaryW7nFsht2yqGqtWiy
Content-Disposition: form-data; name="data1"

value1
------WebKitFormBoundaryW7nFsht2yqGqtWiy
Content-Disposition: form-data; name="data2"

value2
------WebKitFormBoundaryW7nFsht2yqGqtWiy--

つまりダメ。
こちらが指定した boundary は無視されてリクエストボディには全然違う boundary が指定されてしまうようだ。
これではリクエストボディを分割することが出来ない。

結論

fetchmultipart/form-data を送る時は Content-Type を指定してはいけない。(大事な事なので 2 度言いました)

ちなみに上記は Chrome でやった結果だが、Firefox でやっても同じような結果になるはずだ。

それってどこに書いてあるの?

さて、fetchmultipart/form-data を送る時は Content-Type を指定してはいけない(3 度目)と言う事は分かったが、そうするとそれは一体何に基づいた挙動なのかが気になる。
気になるよね?じっちゃんの名にかけて!

で、ちょっとググってみた結果見つけたのがこれ
コイツには fetch 関連の挙動が細かく規定されている。

結構長めな文書だが、一生懸命探して見つけましたよ、その箇所。
5.4. Request class のコンストラクタ(new Request(input, init))の処理ステップ 37。

  1. If init["body"] exists and is non-null, then:
    1. Let bodyWithType be the result of extracting init["body"], with keepalive set to request’s keepalive.
    2. Set initBody to bodyWithType’s body.
    3. Let type be bodyWithType’s type.
    4. If type is non-null and this’s headers’s header list does not contain Content-Type, then append (Content-Type, type) to this’s headers.

要は、body が指定されていて、かつ、headersContent-Type が指定されてなければ、body から抽出(extract)した type なるものを Content-Type として headers に追加する、と言う事だ。

じゃあどうやって body から type なるものを抽出しているかと言うと、5.2. BodyInit unions の To extract 云々 の処理ステップ 10 に書いてある。

  1. Switch on object:
    • Blob
      ...略...
    • FormData
      ...略...
      Set type to multipart/form-data; boundary=, followed by the multipart/form-data boundary string generated by the multipart/form-data encoding algorithm.

要は、bodyFormData 型だった場合、typemultipart/form-data; boundary= + 適当に生成された bounday になる、と言う事だ。

謎は全て解けた!

何か FormData 以外もあるんだけど…

ここでちゃんとリンク先のドキュメントを読んだ方は気づいたと思うが、実は Content-Type を指定しなかった場合に自動で設定されるのは FormData だけじゃなかったようだ。
もしかしてこれって常識?

Content-Type ヘッダに設定される値
Blob type プロパティが空じゃなければ、それ
URLSearchParams application/x-www-form-urlencoded;charset=UTF-8
scalar value string text/plain;charset=UTF-8

ただし、fetch のパラメータで Content-Type ヘッダを指定してしまえばそちらが優先されるので注意。

せっかくなので試してみる。

Blob

Blob 型の場合、type プロパティが空じゃなければそれを使ってくれる。
例えば⇓の例では application/jsonBlob を作っているので…

# 例  4: Blob の type を application/json にしてみる
const body = new Blob(
  ['{"data1":"value1","data2":"value2"}'],
  {"type":"application/json"}
)
const response = fetch('/path', {
  method: 'POST',
  body,
})

HTTP リクエストの Content-Typeapplication/json になる。

POST /path HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Content-Length: 35
Content-Type: application/json
DNT: 1
Host: localhost
Origin: http://localhost
Referer: http://localhost/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
sec-ch-ua: "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"

{"data1":"value1","data2":"value2"}

ちなみに File なんかも Blob の一種だから type プロパティがある。
例えば <input type="file"> でユーザがファイルを選択すると、そのファイルの mime type が type に自動設定される。(拡張子で決まるんじゃないかと思う)
なので、そのファイルを fetch で投げる際に FormData の一部としてじゃなくて単体で投げる場合には(そんなことする?)、その自動設定された typeContent-Type にすることも可能、と言う事だ。

<div><input type="file" id="file1"></div>
<div><button onclick="send()">送れ</button></div>
<script>
function send() {
  const body = document.getElementById('file1').files[0]
  const response = fetch('/path', {
    method: 'POST',
    body,
  })
}
</script>

URLSearchParams

URLSearchParams 型の場合、問答無用で application/x-www-form-urlencoded;charset=UTF-8 になる。

# 例 5: URLSearchParams を使ってみる
const body = new URLSearchParams({
  "data1":"value1",
  "data2":"value2",
})
const response = fetch('/path', {
  method: 'POST',
  body,
})

結果は⇓の通り。

POST /path HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Content-Length: 25
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
DNT: 1
Host: localhost
Origin: http://localhost
Referer: http://localhost/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
sec-ch-ua: "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"

data1=value1&data2=value2

てか、URLSearchParams 便利だな。(今まで知らなかったザコ勢)

scalar value string

scalar value string って何やねん、と言うと
A scalar value string is a string whose code points are all scalar values.
(scalar value string 言うんは全てのコードポイントが scalar value の文字列の事やで)
だそうだ。
じゃあ scalar value って何やねん、と言うと
A scalar value is a code point that is not a surrogate.
(scalar value 言うんはサロゲートじゃないコードポイントの事やで)
だそうだ。
ちなみにサロゲートってのは
A surrogate is a code point that is in the range U+D800 to U+DFFF, inclusive.
(サロゲート言うんは U+D800 から U+DFFF の範囲のコードポイントやで)
である。

つまり、ペアになっていないサロゲートはダメって事だ。
まぁ、簡単に言えば普通の文字列であればいいって事だな。

# 例 6: 普通の文字列を使ってみる
const body = "🍣🍺"
const response = fetch('/path', {
  method: 'POST',
  body,
})

結果は⇓の通り。

POST /path HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Content-Length: 8
Content-Type: text/plain;charset=UTF-8
DNT: 1
Host: localhost
Origin: http://localhost
Referer: http://localhost/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
sec-ch-ua: "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"

🍣🍺

ところで、試しに scalar value string じゃない文字列である "\ud800" を渡してみたんだが、Chrome、Firefox 共に text/plain;charset=UTF-8 を設定しやがった。
この挙動、仕様から乖離してるよな?
まぁこれによって困ることはないとは思うが…

Request のコンストラクタって事は…

さて、鋭い方は既にお気づきだと思うが、これらの Content-Type に関する記載は Request のコンストラクタの処理に書かれていたものだった。
つまり、Content-Type ヘッダの自動設定は Request オブジェクトを作る際に実行されているのだ。

てことは、もし Content-Type が何になっているのかを見たければ(見たいことある?)、わざわざ fetch で投げて結果を見なくても Request オブジェクトを作成してその headersContent-Type に何が設定されているのか(あるいは何も設定されていないのか)を見ればを見れば良いはずである。

てことで、やってみる。

const body = new FormData()
body.append('data1', 'value1')
body.append('data2', 'value2')
const req = new Request('/path', {
  method: 'POST',
  body,
})
console.log(req.headers.get('Content-Type'))

結果は⇓の通り。

multipart/form-data; boundary=----WebKitFormBoundaryOqdyAq4tT6TBuDiY

ちゃんと Content-Type が設定されている事が確認できた!

結論

fetchmultipart/form-data を送る時は Content-Type を指定してはいけない。(4 度目)
そして、fetch って結構奥が深いな。(てきとー)

Discussion