@azure-net/node-kit 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VALIDATION.md +59 -0
- package/dist/validation/Validator.d.ts +4 -0
- package/dist/validation/Validator.js +19 -0
- package/dist/validation/parsing/Conversions.d.ts +5 -0
- package/dist/validation/parsing/Conversions.js +64 -0
- package/dist/validation/parsing/Parse.d.ts +5 -0
- package/dist/validation/parsing/Parse.js +181 -0
- package/package.json +1 -1
package/VALIDATION.md
CHANGED
|
@@ -865,3 +865,62 @@ similar names do not guarantee interchangeable acceptance sets or error shapes.
|
|
|
865
865
|
[type tests](tests/validation.types.ts), [result tests](tests/validation-result.test.mjs),
|
|
866
866
|
[value tests](tests/validation-values.test.mjs), and
|
|
867
867
|
[string-format tests](tests/validation-strings.test.mjs).
|
|
868
|
+
|
|
869
|
+
## Parsing before validation
|
|
870
|
+
|
|
871
|
+
`parse(input, options?)` converts values using the built-in type descriptors, then
|
|
872
|
+
runs the ordinary validator and returns `ValidationResult<T>`. `parseAndThrow`
|
|
873
|
+
returns the validated `T` or throws `ValidationError`. Their asynchronous equivalents
|
|
874
|
+
are `parseAsync` and `parseAndThrowAsync`. Locale, context and cancellation options
|
|
875
|
+
are the same as for validation. Ordinary `validate` methods remain strict.
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
class CollectionQueryValidator extends Validator<{ page: number; ids: number[] }> {
|
|
879
|
+
protected rules() {
|
|
880
|
+
return {
|
|
881
|
+
page: [rules.number(), rules.integer(), rules.positive()],
|
|
882
|
+
ids: [rules.array([rules.number(), rules.integer()])]
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const query = CollectionQueryValidator.parseAndThrow({
|
|
888
|
+
page: '2',
|
|
889
|
+
ids: ['10', '20']
|
|
890
|
+
});
|
|
891
|
+
// { page: 2, ids: [10, 20] }
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
The caller assembles the structure (for example using Hono `req.query('page')`
|
|
895
|
+
and `req.queries('ids')`). Parsing never interprets URLSearchParams, bracket keys,
|
|
896
|
+
JSON strings or comma-separated values and never wraps scalars in arrays.
|
|
897
|
+
|
|
898
|
+
- Finite decimal numeric strings (including exponent notation) become numbers.
|
|
899
|
+
Empty/whitespace strings, hexadecimal, Infinity and unsafe integer results are
|
|
900
|
+
left unchanged. Floating-point values retain JavaScript number precision.
|
|
901
|
+
- Boolean conversion accepts exactly `"true"` and `"false"`, not truthiness or 0/1.
|
|
902
|
+
- Bigint accepts integer decimal strings and safe integer numbers.
|
|
903
|
+
- String conversion accepts finite numbers, booleans and bigints, never objects.
|
|
904
|
+
- Date conversion accepts valid ISO date/date-time strings; omitted timezone means
|
|
905
|
+
UTC. Invalid calendar dates are not normalized into a different date.
|
|
906
|
+
- Homogeneous primitive enums/literals select their primitive type. Membership is
|
|
907
|
+
still checked during validation. Mixed enums are not coerced.
|
|
908
|
+
- A structural type rule is required, e.g. `number()` plus `integer()`. Constraints
|
|
909
|
+
like `positive()` or `integer()` alone do not identify an unambiguous input type.
|
|
910
|
+
- Object, array, tuple and record values are traversed by their schemas. Record keys
|
|
911
|
+
stay strings. Nested validators and lazy schema descriptions are supported.
|
|
912
|
+
- Map/Set inputs must already be Map/Set instances. Converted keys/elements that
|
|
913
|
+
would collapse distinct entries cause that collection to remain unchanged.
|
|
914
|
+
- Ordinary unions and overlapping intersection branches are not guessed. A
|
|
915
|
+
discriminated union can parse the branch selected by the existing discriminator.
|
|
916
|
+
- Null/undefined remain unchanged; missing fields are not filled with defaults.
|
|
917
|
+
- Failed conversions leave the original value for ordinary validation to reject.
|
|
918
|
+
Custom callbacks run only in validation, against the converted data.
|
|
919
|
+
|
|
920
|
+
The parser uses copy-on-write: unchanged branches may share references with input,
|
|
921
|
+
changed branches are copied without invoking getters. It is not a deep-cloning or
|
|
922
|
+
sanitizing API. Unknown keys remain subject to the ordinary unknown-key policy.
|
|
923
|
+
Depth/node limits bound parsing; if exceeded, the conversion is discarded and the
|
|
924
|
+
original input is validated. Cycles are not expanded indefinitely. Primitive
|
|
925
|
+
conversion strings are bounded to 4096 characters. Cached plans contain schemas
|
|
926
|
+
only, never request values or contexts.
|
|
@@ -16,6 +16,10 @@ export declare abstract class Validator<T extends object, TContext = unknown> {
|
|
|
16
16
|
static validateAndThrow<T extends object, C = unknown>(this: ValidatorConstructor<T, C>, input: unknown, ...[options]: ValidationArguments<C>): T;
|
|
17
17
|
static validateAsync<T extends object, C = unknown>(this: ValidatorConstructor<T, C>, input: unknown, ...[options]: ValidationArguments<C>): Promise<ValidationResult<T>>;
|
|
18
18
|
static validateAndThrowAsync<T extends object, C = unknown>(this: ValidatorConstructor<T, C>, input: unknown, ...[options]: ValidationArguments<C>): Promise<T>;
|
|
19
|
+
static parse<T extends object, C = unknown>(this: ValidatorConstructor<T, C>, input: unknown, ...[options]: ValidationArguments<C>): ValidationResult<T>;
|
|
20
|
+
static parseAndThrow<T extends object, C = unknown>(this: ValidatorConstructor<T, C>, input: unknown, ...[options]: ValidationArguments<C>): T;
|
|
21
|
+
static parseAsync<T extends object, C = unknown>(this: ValidatorConstructor<T, C>, input: unknown, ...[options]: ValidationArguments<C>): Promise<ValidationResult<T>>;
|
|
22
|
+
static parseAndThrowAsync<T extends object, C = unknown>(this: ValidatorConstructor<T, C>, input: unknown, ...[options]: ValidationArguments<C>): Promise<T>;
|
|
19
23
|
/** Both Validator.create(options) and new Validator.create(options) return a base class. */
|
|
20
24
|
static readonly create: {
|
|
21
25
|
(options?: ValidatorCreateOptions): typeof Validator;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseInput } from "./parsing/Parse.js";
|
|
1
2
|
import { validateAsync, validateSync } from "./Engine.js";
|
|
2
3
|
import { ValidationError } from "./Result.js";
|
|
3
4
|
import { validatorDefinition } from "./Symbols.js";
|
|
@@ -83,6 +84,24 @@ export class Validator {
|
|
|
83
84
|
throw new ValidationError(result);
|
|
84
85
|
return result.data;
|
|
85
86
|
}
|
|
87
|
+
static parse(input, ...[options]) {
|
|
88
|
+
return validateSync(parseInput(input, Validator.schema(this), Validator.config(this)), Validator.schema(this), Validator.config(this), options ?? {});
|
|
89
|
+
}
|
|
90
|
+
static parseAndThrow(input, ...[options]) {
|
|
91
|
+
const result = validateSync(parseInput(input, Validator.schema(this), Validator.config(this)), Validator.schema(this), Validator.config(this), options ?? {});
|
|
92
|
+
if (!result.isValid)
|
|
93
|
+
throw new ValidationError(result);
|
|
94
|
+
return result.data;
|
|
95
|
+
}
|
|
96
|
+
static async parseAsync(input, ...[options]) {
|
|
97
|
+
return validateAsync(parseInput(input, Validator.schema(this), Validator.config(this)), Validator.schema(this), Validator.config(this), options ?? {});
|
|
98
|
+
}
|
|
99
|
+
static async parseAndThrowAsync(input, ...[options]) {
|
|
100
|
+
const result = await validateAsync(parseInput(input, Validator.schema(this), Validator.config(this)), Validator.schema(this), Validator.config(this), options ?? {});
|
|
101
|
+
if (!result.isValid)
|
|
102
|
+
throw new ValidationError(result);
|
|
103
|
+
return result.data;
|
|
104
|
+
}
|
|
86
105
|
/** Both Validator.create(options) and new Validator.create(options) return a base class. */
|
|
87
106
|
static create = function (options = {}) {
|
|
88
107
|
const configuration = settings(options);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BuiltinRule } from "../Types.js";
|
|
2
|
+
export type Scalar = "number" | "string" | "boolean" | "bigint" | "date";
|
|
3
|
+
/** Uses immutable built-in identifiers, never options.code or message keys. */
|
|
4
|
+
export declare function scalarType(rule: BuiltinRule): Scalar | undefined;
|
|
5
|
+
export declare function convert(value: unknown, type: Scalar): unknown;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DateHelper } from "../../tools/DateHelper.js";
|
|
2
|
+
const numberCodes = new Set(["number", "finite", "int32", "uint32", "float32"]);
|
|
3
|
+
const bigintCodes = new Set(["bigint", "int64", "uint64"]);
|
|
4
|
+
/** Uses immutable built-in identifiers, never options.code or message keys. */
|
|
5
|
+
export function scalarType(rule) {
|
|
6
|
+
if (rule.kind !== "test" || !rule.structural)
|
|
7
|
+
return undefined;
|
|
8
|
+
if (numberCodes.has(rule.code))
|
|
9
|
+
return "number";
|
|
10
|
+
if (bigintCodes.has(rule.code))
|
|
11
|
+
return "bigint";
|
|
12
|
+
if (["string", "boolean", "date"].includes(rule.code))
|
|
13
|
+
return rule.code;
|
|
14
|
+
const values = rule.code === "enum"
|
|
15
|
+
? rule.params.values
|
|
16
|
+
: rule.code === "literal"
|
|
17
|
+
? [rule.params.expected]
|
|
18
|
+
: undefined;
|
|
19
|
+
if (!values?.length)
|
|
20
|
+
return undefined;
|
|
21
|
+
const type = typeof values[0];
|
|
22
|
+
return ["number", "string", "boolean", "bigint"].includes(type) &&
|
|
23
|
+
values.every((value) => typeof value === type)
|
|
24
|
+
? type
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
const numeric = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
28
|
+
export function convert(value, type) {
|
|
29
|
+
if (value === null || value === undefined)
|
|
30
|
+
return value;
|
|
31
|
+
if (type === "string") {
|
|
32
|
+
return typeof value === "boolean" ||
|
|
33
|
+
typeof value === "bigint" ||
|
|
34
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
35
|
+
? String(value)
|
|
36
|
+
: value;
|
|
37
|
+
}
|
|
38
|
+
if (type === "bigint" && typeof value === "number")
|
|
39
|
+
return Number.isSafeInteger(value) ? BigInt(value) : value;
|
|
40
|
+
if (typeof value !== "string" || value.length > 4096)
|
|
41
|
+
return value;
|
|
42
|
+
if (type === "boolean")
|
|
43
|
+
return value === "true" ? true : value === "false" ? false : value;
|
|
44
|
+
if (type === "number") {
|
|
45
|
+
if (!numeric.test(value))
|
|
46
|
+
return value;
|
|
47
|
+
const number = Number(value);
|
|
48
|
+
return Number.isFinite(number) &&
|
|
49
|
+
(!Number.isInteger(number) || Number.isSafeInteger(number))
|
|
50
|
+
? number
|
|
51
|
+
: value;
|
|
52
|
+
}
|
|
53
|
+
if (type === "bigint")
|
|
54
|
+
return /^[+-]?\d+$/.test(value) ? BigInt(value) : value;
|
|
55
|
+
if (type === "date") {
|
|
56
|
+
if (value !== value.trim() || DateHelper.compare(value, value) !== 0)
|
|
57
|
+
return value;
|
|
58
|
+
const iso = value.replace(" ", "T");
|
|
59
|
+
return new Date(iso.includes("T") && !/(?:Z|[+-]\d{2}:\d{2})$/.test(iso)
|
|
60
|
+
? `${iso}Z`
|
|
61
|
+
: iso);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AnyRule, ValidatorOptions } from "../Types.js";
|
|
2
|
+
type Shape = Readonly<Record<string, readonly AnyRule[]>>;
|
|
3
|
+
/** Per-call state only; on exhaustion validation sees the original input and enforces its own limits. */
|
|
4
|
+
export declare function parseInput(input: unknown, shape: Shape, config: ValidatorOptions): unknown;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { convert, scalarType } from "./Conversions.js";
|
|
3
|
+
const plans = new WeakMap();
|
|
4
|
+
function planFor(rules) {
|
|
5
|
+
let plan = plans.get(rules);
|
|
6
|
+
if (plan)
|
|
7
|
+
return plan;
|
|
8
|
+
const descriptors = rules.filter((rule) => typeof rule !== "function");
|
|
9
|
+
const scalars = new Set(descriptors.map(scalarType).filter((type) => type !== undefined));
|
|
10
|
+
const children = descriptors.filter((rule) => !["test", "optional", "nullable"].includes(rule.kind));
|
|
11
|
+
plan = {
|
|
12
|
+
scalar: scalars.size === 1 ? [...scalars][0] : undefined,
|
|
13
|
+
children,
|
|
14
|
+
ambiguous: scalars.size > 1 || (scalars.size > 0 && children.length > 0),
|
|
15
|
+
};
|
|
16
|
+
plans.set(rules, plan);
|
|
17
|
+
return plan;
|
|
18
|
+
}
|
|
19
|
+
function record(value) {
|
|
20
|
+
if (!value || typeof value !== "object" || types.isProxy(value))
|
|
21
|
+
return false;
|
|
22
|
+
const prototype = Object.getPrototypeOf(value);
|
|
23
|
+
return prototype === null || prototype === Object.prototype;
|
|
24
|
+
}
|
|
25
|
+
/** Per-call state only; on exhaustion validation sees the original input and enforces its own limits. */
|
|
26
|
+
export function parseInput(input, shape, config) {
|
|
27
|
+
const maxDepth = config.maxDepth ?? 64;
|
|
28
|
+
const maxNodes = config.maxNodes ?? 10000;
|
|
29
|
+
let nodes = 0;
|
|
30
|
+
const exhausted = Symbol();
|
|
31
|
+
const active = new WeakSet();
|
|
32
|
+
function budget(depth) {
|
|
33
|
+
if (++nodes > maxNodes || depth > maxDepth)
|
|
34
|
+
throw exhausted;
|
|
35
|
+
}
|
|
36
|
+
function properties(value, select, depth) {
|
|
37
|
+
let copy;
|
|
38
|
+
for (const key of Object.keys(value)) {
|
|
39
|
+
const property = Object.getOwnPropertyDescriptor(value, key);
|
|
40
|
+
const rules = select(key);
|
|
41
|
+
if (!("value" in property) || !rules) {
|
|
42
|
+
budget(depth + 1);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const next = visit(property.value, rules, depth + 1);
|
|
46
|
+
if (Object.is(next, property.value))
|
|
47
|
+
continue;
|
|
48
|
+
if (!copy) {
|
|
49
|
+
// Preserve unknown fields, accessors and symbols without executing getters.
|
|
50
|
+
copy = Array.isArray(value)
|
|
51
|
+
? []
|
|
52
|
+
: Object.create(Object.getPrototypeOf(value));
|
|
53
|
+
for (const ownKey of Reflect.ownKeys(value)) {
|
|
54
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, ownKey);
|
|
55
|
+
Object.defineProperty(copy, ownKey, {
|
|
56
|
+
...descriptor,
|
|
57
|
+
...(ownKey === "length" && Array.isArray(value)
|
|
58
|
+
? { writable: true }
|
|
59
|
+
: { configurable: true }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
Object.defineProperty(copy, key, {
|
|
64
|
+
...property,
|
|
65
|
+
value: next,
|
|
66
|
+
configurable: true,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return copy ?? value;
|
|
70
|
+
}
|
|
71
|
+
function object(value, fields, depth) {
|
|
72
|
+
return record(value)
|
|
73
|
+
? properties(value, (key) => (Object.hasOwn(fields, key) ? fields[key] : undefined), depth)
|
|
74
|
+
: value;
|
|
75
|
+
}
|
|
76
|
+
function child(value, rule, depth) {
|
|
77
|
+
switch (rule.kind) {
|
|
78
|
+
case "object":
|
|
79
|
+
return object(value, rule.shape, depth);
|
|
80
|
+
case "array":
|
|
81
|
+
case "tuple":
|
|
82
|
+
return Array.isArray(value)
|
|
83
|
+
? properties(value, (key) => /^(0|[1-9]\d*)$/.test(key)
|
|
84
|
+
? rule.kind === "array"
|
|
85
|
+
? rule.items
|
|
86
|
+
: rule.tuple[Number(key)]
|
|
87
|
+
: undefined, depth)
|
|
88
|
+
: value;
|
|
89
|
+
case "record":
|
|
90
|
+
return record(value)
|
|
91
|
+
? properties(value, () => rule.items, depth)
|
|
92
|
+
: value;
|
|
93
|
+
case "lazy":
|
|
94
|
+
case "validator":
|
|
95
|
+
return visit(value, rule.resolve(), depth + 1, true);
|
|
96
|
+
case "union": {
|
|
97
|
+
// No branch guessing or user predicates during parsing.
|
|
98
|
+
if (!rule.discriminator || !record(value))
|
|
99
|
+
return value;
|
|
100
|
+
const prop = Object.getOwnPropertyDescriptor(value, rule.discriminator);
|
|
101
|
+
const key = prop && "value" in prop ? prop.value : undefined;
|
|
102
|
+
if (!["string", "number", "boolean"].includes(typeof key))
|
|
103
|
+
return value;
|
|
104
|
+
const rules = Object.hasOwn(rule.variants, String(key))
|
|
105
|
+
? rule.variants[String(key)]
|
|
106
|
+
: undefined;
|
|
107
|
+
return rules ? visit(value, rules, depth + 1, true) : value;
|
|
108
|
+
}
|
|
109
|
+
// Intersections may specify incompatible conversions. Let validation decide.
|
|
110
|
+
case "intersection":
|
|
111
|
+
return value;
|
|
112
|
+
case "map": {
|
|
113
|
+
if (!(value instanceof Map))
|
|
114
|
+
return value;
|
|
115
|
+
const entries = [];
|
|
116
|
+
const keys = new Set();
|
|
117
|
+
let changed = false;
|
|
118
|
+
for (const [key, item] of Map.prototype.entries.call(value)) {
|
|
119
|
+
const nextKey = visit(key, rule.keys, depth + 1);
|
|
120
|
+
const nextItem = visit(item, rule.items, depth + 1);
|
|
121
|
+
if (keys.has(nextKey))
|
|
122
|
+
return value;
|
|
123
|
+
keys.add(nextKey);
|
|
124
|
+
changed ||= !Object.is(key, nextKey) || !Object.is(item, nextItem);
|
|
125
|
+
entries.push([nextKey, nextItem]);
|
|
126
|
+
}
|
|
127
|
+
return changed ? new Map(entries) : value;
|
|
128
|
+
}
|
|
129
|
+
case "set": {
|
|
130
|
+
if (!(value instanceof Set))
|
|
131
|
+
return value;
|
|
132
|
+
const items = new Set();
|
|
133
|
+
let changed = false;
|
|
134
|
+
for (const item of Set.prototype.values.call(value)) {
|
|
135
|
+
const next = visit(item, rule.items, depth + 1);
|
|
136
|
+
if (items.has(next))
|
|
137
|
+
return value;
|
|
138
|
+
items.add(next);
|
|
139
|
+
changed ||= !Object.is(item, next);
|
|
140
|
+
}
|
|
141
|
+
return changed ? items : value;
|
|
142
|
+
}
|
|
143
|
+
default:
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function visit(value, rules, depth, sameNode = false) {
|
|
148
|
+
budget(depth);
|
|
149
|
+
if (value === null || value === undefined || types.isProxy(value))
|
|
150
|
+
return value;
|
|
151
|
+
const plan = planFor(rules);
|
|
152
|
+
if (plan.ambiguous)
|
|
153
|
+
return value;
|
|
154
|
+
if (plan.scalar)
|
|
155
|
+
return convert(value, plan.scalar);
|
|
156
|
+
const tracked = typeof value === "object";
|
|
157
|
+
if (tracked && !sameNode && active.has(value))
|
|
158
|
+
return value;
|
|
159
|
+
if (tracked && !sameNode)
|
|
160
|
+
active.add(value);
|
|
161
|
+
try {
|
|
162
|
+
let result = value;
|
|
163
|
+
for (const rule of plan.children)
|
|
164
|
+
result = child(result, rule, depth);
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
if (tracked && !sameNode)
|
|
169
|
+
active.delete(value);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
budget(0);
|
|
174
|
+
return object(input, shape, 0);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error === exhausted)
|
|
178
|
+
return input;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|