フォームデータのエンコードと入力検証
日本語・空白・記号を含む値を二形式へ変換し、本文と区切りを確認します。続いて必須項目・メールアドレス・数値範囲・パターンを変更し、ブラウザの検証結果とFormDataに入る値を対応させます。
説明
フォームの値はHTTP本文へ符号化して送ります。URLエンコード形式とmultipart形式では区切りやバイト列が異なり、ファイルは名前・メディア型・内容も持ちます。一方、値を本文に変換する処理と、その値が条件を満たすかの検証は別です。
日本語・空白・記号を含む値を二形式へ変換し、本文と区切りを確認します。続いて必須項目・メールアドレス・数値範囲・パターンを変更し、ブラウザの検証結果とFormDataに入る値を対応させます。
1送信前の氏名とメモ
日本語の氏名と記号を含むメモを持つ文書を用意します。
createFormDocumentはHTMLを別の文書としてブラウザ内に開く関数です。docはその文書、formは中のフォーム要素を指します。
(globalThis.doc=createFormDocument独立したフォームを作成('<!doctype html><html lang="ja"><body><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p></form></body></html>'),globalThis.form=doc.getElementById("enroll"),[...new FormData(form)]符号化前の値)氏名には日本語の間に空白があり、メモには&と+があります。HTML中の&はDOMの値では&です。HTTP本文へ変換する前に、元の値を確認します。
2URLエンコード形式にする
FormDataの文字列項目をURLSearchParamsへ渡します。
(globalThis.encoded=new URLSearchParamsフォーム形式の名前と値を符号化(new FormData(form)).toString()送信する文字列,encoded)日本語はUTF-8のバイトを%で表し、空白は+、元の&は%26、元の+は%2Bになります。項目間の&や名前と値の=と、値の中にある文字を混同しないように符号化します。
3本文のバイト列を確認
符号化した本文の先頭を16進数で表示し、復号後の値も取得します。
({bytes:[...new TextEncoder().encode(encoded)送信文字列をバイトへ].slice(0,24)先頭24バイト.map(v=>v.toString(16).padStart(2,"0")).join(" "),decoded:[...new URLSearchParams(encoded)元の値へ解析]})送信する文字列はname=などのASCII文字と%表記で構成されます。%E3の部分もバイトe3そのものではなく、%、E、3という文字です。URLSearchParamsで解析すると元の日本語・空白・記号へ戻ります。
4ファイルをFormDataへ追加
メモの二行をtext/plainのFileとして追加します。
(globalThis.data=new FormData(form),data.append("attachment"ファイル項目を追加,new File(["line1\nline2\n"],"memo.txt",{type:"text/plain"})名前・形式・内容を持つファイル),{name:data.get("attachment").name,type:data.get("attachment").type,size:data.get("attachment").size})ファイル名はmemo.txt、メディア型はtext/plain、内容は12バイトです。Fileは文字列のファイル名だけではなく内容も保持します。ファイルを含むフォームの送信には通常multipart/form-dataを使います。
5multipartのヘッダーと本文
Requestでmultipart本文を構成します。自動生成される境界文字列は表示時だけBOUNDARYに置き換えます。
(globalThis.request=new RequestFormDataから要求本文を構成("https://example.test/enroll",{method:"POST",body:data}),globalThis.raw=await request.clone().text()構成された本文を取得,globalThis.boundary=request.headers.get("Content-Type").split("boundary=")[1],{type:request.headers.get("Content-Type").replace(boundary,"BOUNDARY"),body:raw.replaceAll(boundary,"BOUNDARY")境界のランダム値だけ表示を正規化})各部分は--BOUNDARYで区切られ、Content-Dispositionのnameが項目名です。ファイルの部分にはfilenameとContent-Typeもあります。最後は--BOUNDARY--で閉じます。ヘッダー中のboundaryと本文の区切りは必ず一致させる必要があります。
6区切りとファイル内容の改行
各部分の先頭と末尾、ファイル内容の改行を文字コードで確認します。
({partPrefix:[...raw.slice(0,2)境界の先頭].map(c=>c.charCodeAt(0)),headerSeparator:raw.includes("\r\n\r\n")部分ヘッダーの終端,fileBytes:[...await data.get("attachment").bytes()ファイル内容の実バイト],ending:raw.slice(-boundary.length-6).replace(boundary,"BOUNDARY")})境界の先頭--は45、45です。各部分のヘッダーと内容はCRLF二組で分かれます。一方、ファイル内容の改行は元のLF=10のままです。multipartの区切りの改行と、ファイル自身に入っているバイトは別に扱います。
7必須のメールアドレス
required付きemail入力欄を追加し、空欄と不完全なアドレスを確認します。
(form.insertAdjacentHTML("beforeend",'<p id="email"><label>メール <input type="email" name="email" required空欄を許可しない></label></p>'),globalThis.email=doc.querySelector('input[name="email"]'),globalThis.empty=email.validity.valueMissing必須値の欠落,email.value="a@",{emptyValueMissing:empty,typeMismatch:email.validity.typeMismatch型の条件に合わない値,valid:email.checkValidity()検証結果})空欄ではvalueMissing、不完全なa@ではtypeMismatchがtrueです。checkValidityはfalseになります。requiredの有無と、値がその入力型に適合するかは別の条件です。
8検証失敗でもFormDataは作れる
現在の不正なメールアドレスをFormDataから取得した後、正しい形式へ変更します。
({before:new FormData(form).get("email")不正な値も取り出せる,wasValid:form.checkValidity()フォームの制約を検査,after:(email.value="a@example.test"形式を修正,email.checkValidity())})FormDataにはa@が含まれ、フォームの検証結果はfalseです。FormDataの構成自体は入力検証を代行しません。正しい形式へ変えるとtrueになりますが、そのメールアドレスが実在・到達可能であることを確かめたわけではありません。
9数値の範囲と刻み幅
数量にmin=1、max=5、step=2を設定し、4・7・3を順に入れます。
(form.insertAdjacentHTML("beforeend",'<p id="quantity"><label>数量 <input type="number" name="qty" min="1" max="5" step="2"範囲と刻み></label></p>'),globalThis.qty=doc.querySelector('input[name="qty"]'),qty.value="4",globalThis.stepError=qty.validity.stepMismatch刻みに合わない値,qty.value="7",globalThis.rangeError=qty.validity.rangeOverflow,qty.value="3",{stepError,rangeError,valid:qty.checkValidity(),number:qty.valueAsNumber数値として取得,sent:new FormData(form).get("qty")})基準1から2刻みなので許される値は1・3・5です。4はstepMismatch、7はrangeOverflowとなり、3は有効です。valueAsNumberは数値3ですが、フォームの送信項目は文字列"3"です。
10patternで形式を指定
参加コードを大文字二字と数字三字に限定します。
(form.insertAdjacentHTML("beforeend",'<p id="code"><label>参加コード <input name="code" pattern="[A-Z]{2}[0-9]{3}"大文字二字と数字三字 required></label></p>'),globalThis.code=doc.querySelector('input[name="code"]'),code.value="ab123",globalThis.bad=code.validity.patternMismatch形式の不一致,code.value="AB123",{lowercaseMismatch:bad,uppercaseValid:code.checkValidity()修正後の検証})ab123は条件に合わず、AB123は有効です。patternは入力全体が指定パターンに合うことを求めます。空欄を禁止するにはrequiredも必要で、patternだけで必須項目になるわけではありません。
11独自のエラーは明示的に解除
参加コードが使用済みという独自のエラーを設定し、値を変更します。
(code.setCustomValidity独自の検証エラーを設定("この参加コードは使用済みです"),code.value="CD456"値を変更,{message:code.validationMessage,customError:code.validity.customError独自エラーが残る,valid:code.checkValidity()})値をCD456へ変えても独自のエラーは残り、falseになります。setCustomValidityで設定したメッセージは、値の変更だけでは自動的に解除されません。
12エラーを解除して送信値を確認
再検証後に独自エラーを空文字で解除し、フォーム全体を確認します。
(code.setCustomValidity("")独自エラーを解除,{valid:form.checkValidity()現在の全制約を確認,entries:[...new FormData(form)]最終的な送信値})すべての制約を満たし、現在の値が取得できます。ブラウザの検証は入力時の案内に役立ちますが、受信側も入力を検証する必要があります。クライアントで本文を組み立て直すことや、検証を経由せず要求を送ることが可能なためです。
まとめ
URLエンコード形式とmultipartでは区切り方が異なり、ファイルでは名前・型・内容のバイトを扱います。
FormDataを作ることと、値が制約に合うかの検証は別です。
ブラウザの型・範囲・pattern・独自エラーを正しく扱い、受信側でも検証します。
操作履歴と解説を振り返れます。
ブラウザ / HTML・DOM
(globalThis.doc=createFormDocument('<!doctype html><html lang="ja"><body><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p></form></body></html>'),globalThis.form=doc.getElementById("enroll"),[...new FormData(form)])[
[
"name",
"あ い"
],
[
"note",
"A&B+C"
]
]<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p></form></body></html>(globalThis.encoded=new URLSearchParams(new FormData(form)).toString(),encoded)"name=%E3%81%82+%E3%81%84¬e=A%26B%2BC"<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p></form></body></html>
({bytes:[...new TextEncoder().encode(encoded)].slice(0,24).map(v=>v.toString(16).padStart(2,"0")).join(" "),decoded:[...new URLSearchParams(encoded)]}){
"bytes": "6e 61 6d 65 3d 25 45 33 25 38 31 25 38 32 2b 25 45 33 25 38 31 25 38 34",
"decoded": [
[
"name",
"あ い"
],
[
"note",
"A&B+C"
]
]
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p></form></body></html>(globalThis.data=new FormData(form),data.append("attachment",new File(["line1\nline2\n"],"memo.txt",{type:"text/plain"})),{name:data.get("attachment").name,type:data.get("attachment").type,size:data.get("attachment").size}){
"name": "memo.txt",
"type": "text/plain",
"size": 12
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p></form></body></html>(globalThis.request=new Request("https://example.test/enroll",{method:"POST",body:data}),globalThis.raw=await request.clone().text(),globalThis.boundary=request.headers.get("Content-Type").split("boundary=")[1],{type:request.headers.get("Content-Type").replace(boundary,"BOUNDARY"),body:raw.replaceAll(boundary,"BOUNDARY")}){
"type": "multipart/form-data; boundary=BOUNDARY",
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nあ い\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nA&B+C\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"memo.txt\"\r\nContent-Type: text/plain\r\n\r\nline1\nline2\n\r\n--BOUNDARY--\r\n"
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p></form></body></html>({partPrefix:[...raw.slice(0,2)].map(c=>c.charCodeAt(0)),headerSeparator:raw.includes("\r\n\r\n"),fileBytes:[...await data.get("attachment").bytes()],ending:raw.slice(-boundary.length-6).replace(boundary,"BOUNDARY")}){
"partPrefix": [
45,
45
],
"headerSeparator": true,
"fileBytes": [
108,
105,
110,
101,
49,
10,
108,
105,
110,
101,
50,
10
],
"ending": "--BOUNDARY--\r\n"
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p></form></body></html>(form.insertAdjacentHTML("beforeend",'<p id="email"><label>メール <input type="email" name="email" required></label></p>'),globalThis.email=doc.querySelector('input[name="email"]'),globalThis.empty=email.validity.valueMissing,email.value="a@",{emptyValueMissing:empty,typeMismatch:email.validity.typeMismatch,valid:email.checkValidity()}){
"emptyValueMissing": true,
"typeMismatch": true,
"valid": false
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p><p id="email"><label>メール <input type="email" name="email" required="" value="a@"></label></p></form></body></html>({before:new FormData(form).get("email"),wasValid:form.checkValidity(),after:(email.value="a@example.test",email.checkValidity())}){
"before": "a@",
"wasValid": false,
"after": true
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p><p id="email"><label>メール <input type="email" name="email" required="" value="a@example.test"></label></p></form></body></html>(form.insertAdjacentHTML("beforeend",'<p id="quantity"><label>数量 <input type="number" name="qty" min="1" max="5" step="2"></label></p>'),globalThis.qty=doc.querySelector('input[name="qty"]'),qty.value="4",globalThis.stepError=qty.validity.stepMismatch,qty.value="7",globalThis.rangeError=qty.validity.rangeOverflow,qty.value="3",{stepError,rangeError,valid:qty.checkValidity(),number:qty.valueAsNumber,sent:new FormData(form).get("qty")}){
"stepError": true,
"rangeError": true,
"valid": true,
"number": 3,
"sent": "3"
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p><p id="email"><label>メール <input type="email" name="email" required="" value="a@example.test"></label></p><p id="quantity"><label>数量 <input type="number" name="qty" min="1" max="5" step="2" value="3"></label></p></form></body></html>(form.insertAdjacentHTML("beforeend",'<p id="code"><label>参加コード <input name="code" pattern="[A-Z]{2}[0-9]{3}" required></label></p>'),globalThis.code=doc.querySelector('input[name="code"]'),code.value="ab123",globalThis.bad=code.validity.patternMismatch,code.value="AB123",{lowercaseMismatch:bad,uppercaseValid:code.checkValidity()}){
"lowercaseMismatch": true,
"uppercaseValid": true
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p><p id="email"><label>メール <input type="email" name="email" required="" value="a@example.test"></label></p><p id="quantity"><label>数量 <input type="number" name="qty" min="1" max="5" step="2" value="3"></label></p><p id="code"><label>参加コード <input name="code" pattern="[A-Z]{2}[0-9]{3}" required="" value="AB123"></label></p></form></body></html>(code.setCustomValidity("この参加コードは使用済みです"),code.value="CD456",{message:code.validationMessage,customError:code.validity.customError,valid:code.checkValidity()}){
"message": "この参加コードは使用済みです",
"customError": true,
"valid": false
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p><p id="email"><label>メール <input type="email" name="email" required="" value="a@example.test"></label></p><p id="quantity"><label>数量 <input type="number" name="qty" min="1" max="5" step="2" value="3"></label></p><p id="code"><label>参加コード <input name="code" pattern="[A-Z]{2}[0-9]{3}" required="" value="CD456"></label></p></form></body></html>(code.setCustomValidity(""),{valid:form.checkValidity(),entries:[...new FormData(form)]}){
"valid": true,
"entries": [
[
"name",
"あ い"
],
[
"note",
"A&B+C"
],
[
"email",
"a@example.test"
],
[
"qty",
"3"
],
[
"code",
"CD456"
]
]
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}</style></head><body inert><h1>申込みデータ</h1><form id="enroll"><p><label>氏名 <input name="name" value="あ い"></label></p><p><label>メモ <textarea name="note">A&B+C</textarea></label></p><p id="email"><label>メール <input type="email" name="email" required="" value="a@example.test"></label></p><p id="quantity"><label>数量 <input type="number" name="qty" min="1" max="5" step="2" value="3"></label></p><p id="code"><label>参加コード <input name="code" pattern="[A-Z]{2}[0-9]{3}" required="" value="CD456"></label></p></form></body></html>演習完了