@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 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-net/node-kit",
3
- "version": "0.1.2",
3
+ "version": "0.2.2",
4
4
  "description": "Framework-independent Node.js toolkit for the Azure-net ecosystem",
5
5
  "type": "module",
6
6
  "license": "MIT",