fetch で multipart/form-data を送る時は Content-Type を指定してはいけない
概要
タイトルのまんま。以上。
ちょっと説明
とは言え、それだけではあんまりなので軽く説明をば。
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,
})
だから、fetch
で multipart/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
が指定されてしまうようだ。
これではリクエストボディを分割することが出来ない。
結論
fetch
で multipart/form-data
を送る時は Content-Type
を指定してはいけない。(大事な事なので 2 度言いました)
ちなみに上記は Chrome でやった結果だが、Firefox でやっても同じような結果になるはずだ。
それってどこに書いてあるの?
さて、fetch
で multipart/form-data
を送る時は Content-Type
を指定してはいけない(3 度目)と言う事は分かったが、そうするとそれは一体何に基づいた挙動なのかが気になる。
気になるよね?じっちゃんの名にかけて!
で、ちょっとググってみた結果見つけたのがこれ。
コイツには fetch
関連の挙動が細かく規定されている。
結構長めな文書だが、一生懸命探して見つけましたよ、その箇所。
5.4. Request class のコンストラクタ(new Request(
input
,
init
)
)の処理ステップ 37。
- If init["body"] exists and is non-null, then:
- Let bodyWithType be the result of extracting init["body"], with keepalive set to request’s keepalive.
- Set initBody to bodyWithType’s body.
- Let type be bodyWithType’s type.
- 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
が指定されていて、かつ、headers
に Content-Type
が指定されてなければ、body
から抽出(extract)した type なるものを Content-Type
として headers
に追加する、と言う事だ。
じゃあどうやって body
から type なるものを抽出しているかと言うと、5.2. BodyInit unions の To extract 云々 の処理ステップ 10 に書いてある。
- Switch on object:
- Blob
...略...- FormData
...略...
Set type tomultipart/form-data; boundary=
, followed by the multipart/form-data boundary string generated by the multipart/form-data encoding algorithm.
要は、body
が FormData
型だった場合、type は multipart/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/json
の Blob
を作っているので…
# 例 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-Type
は application/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
の一部としてじゃなくて単体で投げる場合には(そんなことする?)、その自動設定された type
を Content-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
オブジェクトを作成してその headers
の Content-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
が設定されている事が確認できた!
結論
fetch
で multipart/form-data
を送る時は Content-Type
を指定してはいけない。(4 度目)
そして、fetch
って結構奥が深いな。(てきとー)
Discussion