◆ クエリパラメータの値をチェック

GET リクエストの URL の ? 以降のクエリパラメータや application/x-www-form-urlencoded で POST されたデータは URLSearchParams で扱うことが多いかと思います
このデータは基本ユーザが入力するものなので 想定してるデータが来るとは限りません
パラメータをチェックするときに毎回 if とか地道にやってたのですが 規模が大きくなると チェック専用のライブラリが欲しくなってきたので作ることにしました

コードは長いのでこの記事の最後か Gist を見てください
この記事のメインは使い方になります

使い方

こういう感じで使います

import * as qv from "./query-validator.js"

const validate = qv.validator({A: "string", B: "number"})
validate(new URLSearchParams("A=xxx&B=10"))
// { valid: true, fixed_value: { A: 'xxx', B: 10 }, messages: {} }

validator 関数に設定を渡して返ってくる関数が実際にチェックを行うための関数になります
これに URLSearchParams を渡すと条件に一致しているかや 一致しない場合にはエラーメッセージを取得できます
また クエリパラメータはすべて文字列なので適切な型に修正した値も取得できます

さっきのはシンプルな例でしたが 少し複雑な例を書いてみます

import * as qv from "./query-validator.js"

const validate = qv.validator(
{
A: "string",
B: { name: "string" },
C: { name: "or", subs: ["string", "null"] },
D: { name: "ref", target: "A", sub: { name: "=", value: "1" } },
E: {
name: "cond",
when: { name: "ref", target: "A", sub: { name: "=", value: "txt" } },
then: { name: "in", values: ["a", "b"] },
else: "number",
},
F: {
name: "cond",
when: { name: "ref", target: "A", sub: { name: "=", value: "1" } },
then: { name: "=", value: "a" },
else: {
name: "cond",
when: { name: "ref", target: "A", sub: { name: "=", value: "2" } },
then: { name: "=", value: "b" },
else: {
name: "cond",
when: { name: "ref", target: "A", sub: { name: "=", value: "3" } },
then: { name: "=", value: "c" },
else: { name: "=", value: "" },
},
},
},
G: { name: "ex1" },
},
new class extends qv.Definition {
ex1(values, option) {
const value = qv.requires(values)
if (value.valid === false) return value

const valid = value.startsWith("p") && value.endsWith("q")
return valid ? qv.ok(value.slice(1, -1)) : qv.error("pで始まってqで終わる必要があります")
}
}()
)
console.log(validate(new URLSearchParams("A=1&B=abc&E=3&F=a&G=p123q")))
// { valid: true, fixed_value: { A: '1', B: 'abc', C: null, D: '1', E: 3, F: 'a', G: '123' }, messages: {} }

qv.register()
console.log(new URLSearchParams("A=xxx&B=10").validate({A: "string", B: "number"}))
// { valid: true, fixed_value: { A: 'xxx', B: 10 }, messages: {} }

validate 関数

validator に設定を渡して返ってくる関数です
これに URLSearchParams を渡します
文字列を直接渡しても内部で URLSearchParams に変換されます

他には getAll メソッドを持っていて文字列でキーを渡せば配列で値を取得できるインターフェースのあるオブジェクトなら動きます

validator 関数

この関数に設定を渡すことで チェックを行う関数を得られます
引数は 2 つです

1 つめは どういうデータなら正しいのかを示すオブジェクトです
処理は書かず条件の設定のみを記述します
処理はデフォルトでは Definition クラスのメソッドで用意されています
これに沿って記述します

2 つめは 1 つめのオブジェクトの設定に基づいて行う処理を設定します
デフォルトでは Definition クラスのインスタンスが設定されます
カスタマイズするなら Definition を継承したクラスでメソッドを追加したり上書きして そのインスタンスを渡します
デフォルトのものがいらないなら Definition を継承する必要すらありません
オブジェクトリテラルで必要なメソッドだけ作るも良いです
全体として Definition を変えたいなら Definition.prototype.*** を直接変更も可能です

register

URLSearchParams に validate メソッドを追加します

validator(a, b)(param)



register()
param.validate(a, b)

と同じになります

チェック処理

validator 関数の 1 つめに渡すオブジェクトについてです
キーがクエリパラメータのキーになっていて バリューにはそのパラメータの値がどうあるべきかを設定します

{
A: "string",
B: { name: "string" }
}

これは A というパラメータ名に対して文字列であることを要求します
B は A と同じ意味です
本来オブジェクトのバリューにはオブジェクトを指定する必要があります
しかし そのオブジェクトで name プロパティのみを指定するなら 直接 name プロパティを文字列でバリューに指定してしまえます

name プロパティは Definition のメソッド名に対応します
{C: "foo"} と設定して validator の 2 つ目の引数では {foo(){ }} と指定すれば 自分で処理を設定できます

string は文字列であることを要求しますが クエリパラメータは文字列なので基本なんでもおっけいになります
number は数値である必要があるので "123" はおっけいですが "abc" はだめです

{D: {name: "string", min: 10, max: 30}}

のように他のプロパティで詳しく条件を指定できます

null

URLSearchParams にキー自体がない場合は文字列ではなく null となるので string と設定しても条件を満たせていません
string は正確書くと「キーが存在すれば」なんでもおっけいです

キーがないことは null で指定します
単に null を指定すると キーがあってはいけないという条件にできます

数値だけどキーがなくてもいい という場合は 「null または number」 の指定が必要です

or

複数の条件でどれかに当てはまればおっけいとしたいなら or を使います

{A: {name: "or", subs: ["number", "null"]}}

subs に配列として他の条件を設定します
文字列でもオブジェクトでも可です

同様に複数の条件を満たす必要がある場合は and を使います

毎回指定が面倒ですが ただのオブジェクトなので

const nullor = x => ({name: "or", subs: [x, "null"]})

みたいなヘルパーを作れば

{A: nullor("number")}

のような指定ができます

multiple

URLSearchParams では一つのキーに複数の値をセットできます
number や string では最初のキーしか見ませんが 複数に対応させるなら multiple を使います

{A: {name: "multiple", sub: "number", mode: "all"}}

sub にはそれぞれにたいしてチェックする条件
mode には全部に一致しないといけないなら all を どれかに一致すればいいなら any を指定します

cond

クエリパラメータでありがちなのが 他との組み合わせです
A が auto なら B は不要で A が custom なら B に文字列が必要など 他のパラメータの意味が変わるモードみたいな意味のパラメータが存在します

そういうときには cond を使います

{
C: "string",
D: {
name: "cond",
when: { name: "ref", target: "C", sub: { name: "=", value: "txt" } },
then: { name: "in", values: ["a", "b"] },
else: "number",
},
}

この例では C はキーが存在すれば何でもよくて D は C が txt という文字列なら a または b のどちらか そうではないなら数値となります

when プロパティに条件を設定してそれが一致するなら then の条件を使って そうでないなら else の条件を使います
elseif は書けるなら多数書けるべきですがオブジェクトである都合で同じキーを設定できないので今のところサポートしてません

こういう switch とか考えてるので 必要が出てくれば作ります

{
E: {
name: "switch",
cases: [
{when: {...}, then: {...}},
{when: {...}, then: {...}},
{when: {...}, then: {...}},
],
default: {...},
},
}

現時点では else に cond を書いてネストしてください

{
F: {
name: "cond",
when: { name: "ref", target: "A", sub: { name: "=", value: "1" } },
then: { name: "=", value: "a" },
else: {
name: "cond",
when: { name: "ref", target: "A", sub: { name: "=", value: "2" } },
then: { name: "=", value: "b" },
else: {
name: "cond",
when: { name: "ref", target: "A", sub: { name: "=", value: "3" } },
then: { name: "=", value: "c" },
else: { name: "=", value: "" },
},
},
},
}

ちなみに = は value プロパティの値に一致すればおっけいで一致しなければエラーです
values プロパティに設定した複数の値からどれかに一致すれば良い in というのもあります

ref は自分のパラメータ以外を参照します
target プロパティにキー(パラメータの名前)を設定します
sub プロパティに条件を記述します
基本単体で使う意味はなく cond の条件部分専用です

カスタム条件

ここまではデフォルトで用意されてる条件を使っていました
自分で処理を書きたい場合はこんな感じでできます

validator(
{
G: { name: "start_end", start: "[", end: "]" },
},
new class extends Definition {
start_end(values, option){
const value = requires(values)
if (value.valid === false) return value

const valid = value.startsWith(option.start) && value.endsWith(option.end)
return valid
? ok(value.slice(option.start.length, -option.end.length))
: error(`"${option.start}" で始まって "${option.end}" で終わる必要があります`)
}
}
)

name に書いたものを validator の 2 つ目の引数のメソッド名にします

引数の values はそのパラメータのキーに対する値の配列です
キーがないなら空配列です
1 つ値があれば 1 つの要素 3 つの値があれば 3 つの要素となります
値がない場合は null に任せて 複数は multiple に任せるのなら 1 つ目の要素だけチェックで良いです
反対にここですべてチェックしてしまっても大丈夫です

option には条件を書いたオブジェクトが渡されます
option.name がメソッド名と対応します
option.xxx で他のオプションを参照できます

また 3 つ目の引数で info を受け取れます
validator に渡した引数やチェック対象の URLSearchParams のデータなどを参照できます
使うことはほぼ無いかと思います
使うとすれば cond や ref みたいに内部で別の条件にマッチするかを実行したいときです

const { result } = run(info.param.getAll(option.target), option.sub, info)

こう書けば sub に設定した条件で target のパラメータをチェックできます
その条件中でさらに別条件をチェック可能にするため run の 3 つ目には info を必ず渡してください

run/ok/error/requires はカスタム条件を作るときのヘルパーなので デフォルトのみで使うならこれらは無視して大丈夫です

また 今回みたいな デフォルトの number などを全く使わないのなら継承する必要はないので

{
start_end(values, option){
const value = qv.requires(values)
if (value.valid === false) return value

const valid = value.startsWith(option.start) && value.endsWith(option.end)
return valid
? qv.ok(value.slice(option.start.length, -option.end.length))
: qv.error(`"${option.start}" で始まって "${option.end}" で終わる必要があります`)
}
}

みたいなプレーンなオブジェクトを渡しても良いです

ソースコード

最後にソースコードを貼って終わりにします
とりあえず作ってみた の段階なので バグがある可能性も十分にありますし 何度か使ってみていろいろ変更する可能性があります
ブログ用に HTML 化したソースコードは変更がめんどうなので修正は Gist の方のみ反映します

複雑そうに見えて結構単純なので Definition の各処理を除いたコア部分は 50 行程です

const validator = (spec, def) => {
def = def ? (typeof def === "function" ? new def() : def) : new Definition()
return function(param) {
if (typeof param === "string") param = new URLSearchParams(param)

const messages = {}
const result = {}
let all_valid = true
for (const [param_key, param_spec] of Object.entries(spec)) {
const values = param.getAll(param_key)
const {
result: { valid, message, fixed_value },
def_name,
} = run(values, param_spec, { key: param_key, param, spec, def }) || {}

if (valid) {
result[param_key] = fixed_value
} else {
result[param_key] = values
all_valid = false
}
if (message) messages[param_key] = `[${def_name}] ${message}`
}
return { valid: all_valid, fixed_value: result, messages }
}
}

const normalizeParamSpec = sp => {
const normalized = typeof sp === "string" ? { name: sp } : sp

if (typeof normalized.name !== "string") {
throw new Error("definition name is required and must be specified as a string")
}

return normalized
}
const run = (values, sub, info) => {
const sp = normalizeParamSpec(sub)
const def_name = sp.name
if (typeof info.def[def_name] !== "function") {
throw new Error(`definition "${def_name}" is not found`)
}
const result = info.def[def_name](values, sp, info)
return { result, def_name }
}

const ok = fixed_value => ({ valid: true, message: null, fixed_value })
const error = message => ({ valid: false, message })
const requires = values => (values.length ? values[0] : error("値が存在しません"))

const register = () => {
URLSearchParams.prototype.validate = function(spec, def) {
return validator(spec, def)(this)
}
}

class Definition {
null(values, option) {
const valid = values.length === 0
return valid ? ok(null) : error("キーが存在します")
}
multiple(values, option, info) {
switch (option.mode) {
case "all": {
if (option.allow_null && values.length === 0) {
return ok(values)
}
const fixed_values = []
for (const value of values) {
const { result, def_name } = run([value], option.sub, info)
if (!result.valid) {
return error(`エラーになる値が含まれています - [${def_name}] ${result.message}`)
}
fixed_values.push(result.fixed_value)
}
return ok(fixed_values)
}
case "any": {
if (option.allow_null && values.length === 0) {
return ok(values)
}
const messages = []
for (const value of values) {
const { result, def_name } = run([value], option.sub, info)
if (result.valid) {
return ok(result.fixed_value)
}
messages.push(`[${def_name}] ${result.message}`)
}
return error(`すべての値がエラーになりました - ${messages.join(", ")}`)
}
default: {
return error("不正なモードです")
}
}
}
"="(values, option) {
const value = requires(values)
if (value.valid === false) return value

const valid = value === option.value
return valid ? ok(value) : error("値が一致しません")
}
in(values, option) {
const value = requires(values)
if (value.valid === false) return value

const valid = option.values.includes(value)
return valid ? ok(value) : error("値がリストに存在しません")
}
boolean(values, option) {
const value = requires(values)
if (value.valid === false) return value

const valid = ["true", "false"].includes(value)
return valid ? ok(value === "true") : error("boolean型として不正な値です")
}
num_boolean(values, option) {
const value = requires(values)
if (value.valid === false) return value

const on_error_return = error("boolean型として不正な値です")
if (typeof value !== "string") {
return on_error_return
} else if (option.mode === "01") {
const valid = ["0", "1"].includes(value)
return valid ? ok(value === "1") : on_error_return
} else if (option.mode === "zero-other") {
return ok(value !== "0")
} else {
// mode=int
const valid = value && !!value.match(/^[0-9]+$/)
return valid ? ok(!!+value) : on_error_return
}
}
number(values, option) {
const value = requires(values)
if (value.valid === false) return value

const fixed_value = +value
const is_number = value === String(fixed_value)
if (!is_number) return error("数値として不正です")
if (!option.allow_nan && isNaN(fixed_value)) return error("NaN許可されていません")
if (option.integer && ~~fixed_value !== fixed_value) return error("整数値として不正です")
if (option.min && option.min > fixed_value) return error("最小値を下回ります")
if (option.max && option.max < fixed_value) return error("最大値を上回ります")
return ok(fixed_value)
}
string(values, option) {
const value = requires(values)
if (value.valid === false) return value

if (option.min && option.min > value.length) return error("最小文字数を下回ります")
if (option.max && option.max < value.length) return error("最大文字数を上回ります")
if (option.regexp instanceof RegExp && !option.regexp.test(value)) return error("正規表現にマッチしません")
return ok(value)
}
or(values, option, info) {
if (!option.subs || !option.subs.length) {
throw new Error("'or' definition requires 'subs' property")
}
const messages = []
for (const sub of option.subs) {
const { result, def_name } = run(values, sub, info)
if (result.valid) {
return ok(result.fixed_value)
}
messages.push(`[${def_name}] ${result.message}`)
}
return error(`すべての条件がエラーになりました - ${messages.join(", ")}`)
}
and(values, option, info) {
if (!option.subs || !option.subs.length) {
throw new Error("'or' definition requires 'subs' property")
}
const fixed_values = []
for (const sub of option.subs) {
const { result, def_name } = run(values, sub, info)
if (!result.valid) {
return error(`エラーになる条件が含まれています - [${def_name}] ${result.message}`)
}
fixed_values.push(result.fixed_value)
}
return ok(fixed_values)
}
ref(values, option, info) {
if (!option.target || !option.sub) {
throw new Error("'ref' definition requires 'target' and 'sub' properties.")
}
const { result } = run(info.param.getAll(option.target), option.sub, info)
return result
}
cond(values, option, info) {
if (!option.when || !option.then || !option.else) {
throw new Error("'cond' definition requires 'when', 'then', and 'else' properties.")
}

const { result } = run(values, option.when, info)
const sub = result.valid ? option.then : option.else
{
const { result } = run(values, sub, info)
return result
}
}
}

export { validator, register, run, ok, error, requires, Definition }