一覧WB-07-2 · フォームと入力

フォームデータのエンコードと入力検証

日本語・空白・記号を含む値を二形式へ変換し、本文と区切りを確認します。続いて必須項目・メールアドレス・数値範囲・パターンを変更し、ブラウザの検証結果と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&amp;B+C</textarea></label></p></form></body></html>'),globalThis.form=doc.getElementById("enroll"),[...new FormData(form)]符号化前の値)

氏名には日本語の間に空白があり、メモには&と+があります。HTML中の&amp;は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

1
(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&amp;B+C</textarea></label></p></form></body></html>'),globalThis.form=doc.getElementById("enroll"),[...new FormData(form)])
[
  [
    "name",
    "あ い"
  ],
  [
    "note",
    "A&B+C"
  ]
]
2
(globalThis.encoded=new URLSearchParams(new FormData(form)).toString(),encoded)
"name=%E3%81%82+%E3%81%84&note=A%26B%2BC"
3
({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"
    ]
  ]
}
4
(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
}
5
(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"
}
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")})
{
  "partPrefix": [
    45,
    45
  ],
  "headerSeparator": true,
  "fileBytes": [
    108,
    105,
    110,
    101,
    49,
    10,
    108,
    105,
    110,
    101,
    50,
    10
  ],
  "ending": "--BOUNDARY--\r\n"
}
7
(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
}
8
({before:new FormData(form).get("email"),wasValid:form.checkValidity(),after:(email.value="a@example.test",email.checkValidity())})
{
  "before": "a@",
  "wasValid": false,
  "after": true
}
9
(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"
}
10
(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
}
11
(code.setCustomValidity("この参加コードは使用済みです"),code.value="CD456",{message:code.validationMessage,customError:code.validity.customError,valid:code.checkValidity()})
{
  "message": "この参加コードは使用済みです",
  "customError": true,
  "valid": false
}
12
(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"
    ]
  ]
}

演習完了