anyvali 0.3.1 → 0.3.4
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/CHANGELOG.md +67 -44
- package/README.md +370 -370
- package/dist/parse/coerce.d.ts.map +1 -1
- package/dist/parse/coerce.js +14 -0
- package/dist/parse/coerce.js.map +1 -1
- package/dist/schemas/number.d.ts.map +1 -1
- package/dist/schemas/number.js +15 -0
- package/dist/schemas/number.js.map +1 -1
- package/dist/schemas/optional.d.ts.map +1 -1
- package/dist/schemas/optional.js +4 -3
- package/dist/schemas/optional.js.map +1 -1
- package/package.json +40 -40
- package/sdk/js/CHANGELOG.md +13 -13
- package/src/format/validators.ts +71 -71
- package/src/index.ts +285 -285
- package/src/infer.ts +12 -12
- package/src/interchange/importer.ts +285 -285
- package/src/issue-codes.ts +19 -19
- package/src/parse/coerce.ts +15 -0
- package/src/schemas/base.ts +322 -322
- package/src/schemas/intersection.ts +81 -81
- package/src/schemas/number.ts +17 -0
- package/src/schemas/object.ts +203 -203
- package/src/schemas/optional.ts +4 -3
- package/src/schemas/record.ts +55 -55
- package/src/schemas/string.ts +192 -192
- package/src/schemas/union.ts +53 -53
- package/src/types.ts +239 -239
- package/tests/unit/collections.test.ts +99 -99
- package/tests/unit/date-format.test.ts +18 -18
- package/tests/unit/default-mutation.test.ts +32 -32
- package/tests/unit/defaults.test.ts +70 -1
- package/tests/unit/inference.test.ts +306 -306
- package/tests/unit/interchange.test.ts +191 -191
- package/tests/unit/object.test.ts +208 -208
- package/tests/unit/security-recursion.test.ts +105 -105
- package/tests/unit/security.test.ts +1067 -945
- package/tests/unit/shared-ref-falsepos.test.ts +33 -33
- package/tests/unit/string-pattern-redos.test.ts +46 -46
- package/tests/unit/string.test.ts +147 -147
package/src/schemas/base.ts
CHANGED
|
@@ -1,322 +1,322 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ParseResult,
|
|
3
|
-
ValidationIssue,
|
|
4
|
-
ParseContext,
|
|
5
|
-
SchemaNode,
|
|
6
|
-
AnyValiDocument,
|
|
7
|
-
ExportMode,
|
|
8
|
-
CoercionConfig,
|
|
9
|
-
MetadataOptions,
|
|
10
|
-
DescribeOptions,
|
|
11
|
-
} from "../types.js";
|
|
12
|
-
import { ValidationError } from "../errors.js";
|
|
13
|
-
import { applyCoercion } from "../parse/coerce.js";
|
|
14
|
-
import { ISSUE_CODES } from "../issue-codes.js";
|
|
15
|
-
|
|
16
|
-
const ANYVALI_VERSION = "1.0";
|
|
17
|
-
const SCHEMA_VERSION = "1.1";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Maximum validation recursion depth. Bounds recursion through recursive
|
|
21
|
-
* `$ref` schemas (and deeply nested data) so a malicious payload cannot
|
|
22
|
-
* exhaust the call stack and crash the process (DoS). Far above any
|
|
23
|
-
* legitimate schema nesting.
|
|
24
|
-
*/
|
|
25
|
-
export const MAX_DEPTH = 512;
|
|
26
|
-
|
|
27
|
-
const RESERVED_METADATA_KEYS = new Set([
|
|
28
|
-
'title', 'description', 'deprecated', 'deprecatedMessage',
|
|
29
|
-
'notStable', 'since', 'sensitive', 'readonly', 'writeonly', 'examples',
|
|
30
|
-
]);
|
|
31
|
-
|
|
32
|
-
/** Sentinel for "value not present" */
|
|
33
|
-
export const ABSENT = Symbol.for("anyvali.absent");
|
|
34
|
-
export type Absent = typeof ABSENT;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Deep-clone a default value so mutable defaults are isolated per parse.
|
|
38
|
-
* Uses structuredClone when the value is structured-cloneable; falls back to
|
|
39
|
-
* returning the value as-is for things structuredClone cannot handle
|
|
40
|
-
* (e.g. functions), which are not portable defaults anyway.
|
|
41
|
-
*/
|
|
42
|
-
function cloneDefault<T>(value: T): T {
|
|
43
|
-
if (value === null || typeof value !== "object") return value;
|
|
44
|
-
try {
|
|
45
|
-
return structuredClone(value);
|
|
46
|
-
} catch {
|
|
47
|
-
return value;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export abstract class BaseSchema<TInput = unknown, TOutput = TInput> {
|
|
52
|
-
/** Type-level brand for Infer<T>. Never assigned at runtime. */
|
|
53
|
-
declare readonly _output: TOutput;
|
|
54
|
-
/** @internal */ _defaultValue: TOutput | Absent = ABSENT;
|
|
55
|
-
/** @internal */ _coercionConfig: CoercionConfig | undefined = undefined;
|
|
56
|
-
/** @internal */ _isPortable: boolean = true;
|
|
57
|
-
/** @internal */ _metadata: Record<string, unknown> | undefined = undefined;
|
|
58
|
-
|
|
59
|
-
// ---------- public API ----------
|
|
60
|
-
|
|
61
|
-
parse(input: unknown): TOutput {
|
|
62
|
-
const result = this.safeParse(input);
|
|
63
|
-
if (result.success) return result.data;
|
|
64
|
-
throw new ValidationError(result.issues);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
safeParse(input: unknown): ParseResult<TOutput> {
|
|
68
|
-
const ctx: ParseContext = { path: [], issues: [] };
|
|
69
|
-
let output: unknown;
|
|
70
|
-
try {
|
|
71
|
-
output = this._runPipeline(input, ctx);
|
|
72
|
-
} catch (err) {
|
|
73
|
-
// Backstop: the depth guard bounds recursion, but if any path resets
|
|
74
|
-
// the context and exhausts the stack, convert it to a clean issue so
|
|
75
|
-
// safeParse never throws (DoS / contract guarantee).
|
|
76
|
-
if (err instanceof RangeError) {
|
|
77
|
-
return {
|
|
78
|
-
success: false,
|
|
79
|
-
issues: [
|
|
80
|
-
{
|
|
81
|
-
code: ISSUE_CODES.TOO_DEEP,
|
|
82
|
-
message: "Maximum validation depth exceeded",
|
|
83
|
-
path: [],
|
|
84
|
-
expected: "bounded nesting",
|
|
85
|
-
received: "too deep",
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
throw err;
|
|
91
|
-
}
|
|
92
|
-
if (ctx.issues.length > 0) {
|
|
93
|
-
return { success: false, issues: ctx.issues };
|
|
94
|
-
}
|
|
95
|
-
return { success: true, data: output as TOutput };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Internal: run the 5-step pipeline */
|
|
99
|
-
_runPipeline(input: unknown, ctx: ParseContext): unknown {
|
|
100
|
-
// Depth guard: bound recursion (recursive $ref + deep data) so a crafted
|
|
101
|
-
// payload cannot exhaust the call stack. Return a clean issue instead of
|
|
102
|
-
// letting safeParse throw a RangeError.
|
|
103
|
-
const depth = (ctx.depth ?? 0) + 1;
|
|
104
|
-
if (depth > MAX_DEPTH) {
|
|
105
|
-
ctx.issues.push({
|
|
106
|
-
code: ISSUE_CODES.TOO_DEEP,
|
|
107
|
-
message: `Maximum validation depth of ${MAX_DEPTH} exceeded`,
|
|
108
|
-
path: [...ctx.path],
|
|
109
|
-
expected: `<= ${MAX_DEPTH} levels`,
|
|
110
|
-
received: "too deep",
|
|
111
|
-
});
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
ctx.depth = depth;
|
|
115
|
-
try {
|
|
116
|
-
return this._runPipelineInner(input, ctx);
|
|
117
|
-
} finally {
|
|
118
|
-
ctx.depth = depth - 1;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private _runPipelineInner(input: unknown, ctx: ParseContext): unknown {
|
|
123
|
-
// Step 1: detect presence
|
|
124
|
-
const isAbsent = input === undefined || input === ABSENT;
|
|
125
|
-
|
|
126
|
-
let value: unknown = input;
|
|
127
|
-
|
|
128
|
-
// Step 2: coercion (only for present values)
|
|
129
|
-
if (!isAbsent && this._coercionConfig) {
|
|
130
|
-
const coerced = applyCoercion(
|
|
131
|
-
value,
|
|
132
|
-
this._coercionConfig,
|
|
133
|
-
this._getCoercionTarget()
|
|
134
|
-
);
|
|
135
|
-
if (coerced.success) {
|
|
136
|
-
value = coerced.value;
|
|
137
|
-
} else {
|
|
138
|
-
ctx.issues.push({
|
|
139
|
-
code: ISSUE_CODES.COERCION_FAILED,
|
|
140
|
-
message: coerced.message,
|
|
141
|
-
path: [...ctx.path],
|
|
142
|
-
expected: this._getCoercionTarget(),
|
|
143
|
-
received: String(input),
|
|
144
|
-
});
|
|
145
|
-
return undefined;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Step 3: default materialization (only for absent values)
|
|
150
|
-
let usedDefault = false;
|
|
151
|
-
if (isAbsent && this._defaultValue !== ABSENT) {
|
|
152
|
-
// Deep-clone so mutable defaults (arrays/objects) are not shared across
|
|
153
|
-
// parses. Pass-through schemas (any/unknown) return the value by
|
|
154
|
-
// reference, so without cloning a mutation would corrupt the default.
|
|
155
|
-
value = cloneDefault(this._defaultValue);
|
|
156
|
-
usedDefault = true;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Step 4: validate
|
|
160
|
-
const issuesBefore = ctx.issues.length;
|
|
161
|
-
const result = this._validate(value, ctx);
|
|
162
|
-
|
|
163
|
-
// If default was materialized and validation failed, remap issues to default_invalid
|
|
164
|
-
if (usedDefault && ctx.issues.length > issuesBefore) {
|
|
165
|
-
for (let i = issuesBefore; i < ctx.issues.length; i++) {
|
|
166
|
-
const issue = ctx.issues[i];
|
|
167
|
-
ctx.issues[i] = {
|
|
168
|
-
...issue,
|
|
169
|
-
code: ISSUE_CODES.DEFAULT_INVALID,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return result;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** Override in subclasses to provide the coercion target type name */
|
|
178
|
-
_getCoercionTarget(): string {
|
|
179
|
-
return "unknown";
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
abstract _validate(input: unknown, ctx: ParseContext): unknown;
|
|
183
|
-
|
|
184
|
-
abstract _toNode(): SchemaNode;
|
|
185
|
-
|
|
186
|
-
// optional() and nullable() are provided via standalone functions
|
|
187
|
-
// to avoid circular imports. See index.ts.
|
|
188
|
-
|
|
189
|
-
default(value: TOutput): this {
|
|
190
|
-
const clone = this._clone();
|
|
191
|
-
clone._defaultValue = value;
|
|
192
|
-
return clone;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
coerce(options: CoercionConfig = {}): this {
|
|
196
|
-
const clone = this._clone();
|
|
197
|
-
clone._coercionConfig = { ...options };
|
|
198
|
-
return clone;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
describe(description: string, opts?: DescribeOptions): this {
|
|
202
|
-
const reservedMeta = this._validateDescribeOpts(description, opts);
|
|
203
|
-
const clone = this._clone();
|
|
204
|
-
clone._metadata = { ...(clone._metadata || {}), ...reservedMeta };
|
|
205
|
-
return clone;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
metadata(meta: Record<string, unknown>, opts?: MetadataOptions): this {
|
|
209
|
-
// Validate no reserved keys
|
|
210
|
-
for (const key of Object.keys(meta)) {
|
|
211
|
-
if (RESERVED_METADATA_KEYS.has(key)) {
|
|
212
|
-
throw new Error(`metadata(): "${key}" is a reserved key. Use describe() instead.`);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
const clone = this._clone();
|
|
216
|
-
if (opts?.replace) {
|
|
217
|
-
// Replace mode: keep only reserved keys from existing, add new non-reserved
|
|
218
|
-
const existing = clone._metadata || {};
|
|
219
|
-
const reserved: Record<string, unknown> = {};
|
|
220
|
-
for (const [k, v] of Object.entries(existing)) {
|
|
221
|
-
if (RESERVED_METADATA_KEYS.has(k)) {
|
|
222
|
-
reserved[k] = v;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
clone._metadata = { ...reserved, ...meta };
|
|
226
|
-
} else {
|
|
227
|
-
// Merge mode (default): shallow merge
|
|
228
|
-
clone._metadata = { ...(clone._metadata || {}), ...meta };
|
|
229
|
-
}
|
|
230
|
-
return clone;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export(mode: ExportMode = "portable"): AnyValiDocument {
|
|
234
|
-
if (mode === "portable" && !this._isPortable) {
|
|
235
|
-
throw new Error(
|
|
236
|
-
"Cannot export in portable mode: schema contains non-portable features"
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
const node = this._toNode();
|
|
240
|
-
return {
|
|
241
|
-
anyvaliVersion: ANYVALI_VERSION,
|
|
242
|
-
schemaVersion: SCHEMA_VERSION,
|
|
243
|
-
root: node,
|
|
244
|
-
definitions: {},
|
|
245
|
-
extensions: {},
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ---------- internal helpers ----------
|
|
250
|
-
|
|
251
|
-
private _validateDescribeOpts(description: string, opts?: DescribeOptions): Record<string, unknown> {
|
|
252
|
-
const meta: Record<string, unknown> = { description };
|
|
253
|
-
|
|
254
|
-
if (typeof description !== 'string') {
|
|
255
|
-
throw new Error('describe(): description must be a string');
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (opts) {
|
|
259
|
-
if (opts.title !== undefined) {
|
|
260
|
-
if (typeof opts.title !== 'string') throw new Error('describe(): title must be a string');
|
|
261
|
-
meta.title = opts.title;
|
|
262
|
-
}
|
|
263
|
-
if (opts.deprecated !== undefined) {
|
|
264
|
-
if (typeof opts.deprecated !== 'boolean') throw new Error('describe(): deprecated must be a boolean');
|
|
265
|
-
meta.deprecated = opts.deprecated;
|
|
266
|
-
}
|
|
267
|
-
if (opts.deprecatedMessage !== undefined) {
|
|
268
|
-
if (typeof opts.deprecatedMessage !== 'string') throw new Error('describe(): deprecatedMessage must be a string');
|
|
269
|
-
if (!opts.deprecated) throw new Error('describe(): deprecatedMessage requires deprecated to be true');
|
|
270
|
-
meta.deprecatedMessage = opts.deprecatedMessage;
|
|
271
|
-
}
|
|
272
|
-
if (opts.notStable !== undefined) {
|
|
273
|
-
if (typeof opts.notStable !== 'boolean') throw new Error('describe(): notStable must be a boolean');
|
|
274
|
-
meta.notStable = opts.notStable;
|
|
275
|
-
}
|
|
276
|
-
if (opts.since !== undefined) {
|
|
277
|
-
if (typeof opts.since !== 'string') throw new Error('describe(): since must be a string');
|
|
278
|
-
meta.since = opts.since;
|
|
279
|
-
}
|
|
280
|
-
if (opts.sensitive !== undefined) {
|
|
281
|
-
if (typeof opts.sensitive !== 'boolean') throw new Error('describe(): sensitive must be a boolean');
|
|
282
|
-
meta.sensitive = opts.sensitive;
|
|
283
|
-
}
|
|
284
|
-
if (opts.readonly !== undefined) {
|
|
285
|
-
if (typeof opts.readonly !== 'boolean') throw new Error('describe(): readonly must be a boolean');
|
|
286
|
-
meta.readonly = opts.readonly;
|
|
287
|
-
}
|
|
288
|
-
if (opts.writeonly !== undefined) {
|
|
289
|
-
if (typeof opts.writeonly !== 'boolean') throw new Error('describe(): writeonly must be a boolean');
|
|
290
|
-
meta.writeonly = opts.writeonly;
|
|
291
|
-
}
|
|
292
|
-
if (opts.readonly && opts.writeonly) {
|
|
293
|
-
throw new Error('describe(): readonly and writeonly cannot both be true');
|
|
294
|
-
}
|
|
295
|
-
if (opts.examples !== undefined) {
|
|
296
|
-
if (!Array.isArray(opts.examples)) throw new Error('describe(): examples must be an array');
|
|
297
|
-
meta.examples = opts.examples;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return meta;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
protected _clone(): this {
|
|
305
|
-
const clone = Object.create(Object.getPrototypeOf(this));
|
|
306
|
-
Object.assign(clone, this);
|
|
307
|
-
return clone as this;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
protected _addDefault(node: SchemaNode): SchemaNode {
|
|
311
|
-
if (this._defaultValue !== ABSENT) {
|
|
312
|
-
node.default = this._defaultValue;
|
|
313
|
-
}
|
|
314
|
-
if (this._coercionConfig) {
|
|
315
|
-
node.coerce = { ...this._coercionConfig };
|
|
316
|
-
}
|
|
317
|
-
if (this._metadata && Object.keys(this._metadata).length > 0) {
|
|
318
|
-
node.metadata = { ...this._metadata };
|
|
319
|
-
}
|
|
320
|
-
return node;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
ParseResult,
|
|
3
|
+
ValidationIssue,
|
|
4
|
+
ParseContext,
|
|
5
|
+
SchemaNode,
|
|
6
|
+
AnyValiDocument,
|
|
7
|
+
ExportMode,
|
|
8
|
+
CoercionConfig,
|
|
9
|
+
MetadataOptions,
|
|
10
|
+
DescribeOptions,
|
|
11
|
+
} from "../types.js";
|
|
12
|
+
import { ValidationError } from "../errors.js";
|
|
13
|
+
import { applyCoercion } from "../parse/coerce.js";
|
|
14
|
+
import { ISSUE_CODES } from "../issue-codes.js";
|
|
15
|
+
|
|
16
|
+
const ANYVALI_VERSION = "1.0";
|
|
17
|
+
const SCHEMA_VERSION = "1.1";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Maximum validation recursion depth. Bounds recursion through recursive
|
|
21
|
+
* `$ref` schemas (and deeply nested data) so a malicious payload cannot
|
|
22
|
+
* exhaust the call stack and crash the process (DoS). Far above any
|
|
23
|
+
* legitimate schema nesting.
|
|
24
|
+
*/
|
|
25
|
+
export const MAX_DEPTH = 512;
|
|
26
|
+
|
|
27
|
+
const RESERVED_METADATA_KEYS = new Set([
|
|
28
|
+
'title', 'description', 'deprecated', 'deprecatedMessage',
|
|
29
|
+
'notStable', 'since', 'sensitive', 'readonly', 'writeonly', 'examples',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** Sentinel for "value not present" */
|
|
33
|
+
export const ABSENT = Symbol.for("anyvali.absent");
|
|
34
|
+
export type Absent = typeof ABSENT;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deep-clone a default value so mutable defaults are isolated per parse.
|
|
38
|
+
* Uses structuredClone when the value is structured-cloneable; falls back to
|
|
39
|
+
* returning the value as-is for things structuredClone cannot handle
|
|
40
|
+
* (e.g. functions), which are not portable defaults anyway.
|
|
41
|
+
*/
|
|
42
|
+
function cloneDefault<T>(value: T): T {
|
|
43
|
+
if (value === null || typeof value !== "object") return value;
|
|
44
|
+
try {
|
|
45
|
+
return structuredClone(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export abstract class BaseSchema<TInput = unknown, TOutput = TInput> {
|
|
52
|
+
/** Type-level brand for Infer<T>. Never assigned at runtime. */
|
|
53
|
+
declare readonly _output: TOutput;
|
|
54
|
+
/** @internal */ _defaultValue: TOutput | Absent = ABSENT;
|
|
55
|
+
/** @internal */ _coercionConfig: CoercionConfig | undefined = undefined;
|
|
56
|
+
/** @internal */ _isPortable: boolean = true;
|
|
57
|
+
/** @internal */ _metadata: Record<string, unknown> | undefined = undefined;
|
|
58
|
+
|
|
59
|
+
// ---------- public API ----------
|
|
60
|
+
|
|
61
|
+
parse(input: unknown): TOutput {
|
|
62
|
+
const result = this.safeParse(input);
|
|
63
|
+
if (result.success) return result.data;
|
|
64
|
+
throw new ValidationError(result.issues);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
safeParse(input: unknown): ParseResult<TOutput> {
|
|
68
|
+
const ctx: ParseContext = { path: [], issues: [] };
|
|
69
|
+
let output: unknown;
|
|
70
|
+
try {
|
|
71
|
+
output = this._runPipeline(input, ctx);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// Backstop: the depth guard bounds recursion, but if any path resets
|
|
74
|
+
// the context and exhausts the stack, convert it to a clean issue so
|
|
75
|
+
// safeParse never throws (DoS / contract guarantee).
|
|
76
|
+
if (err instanceof RangeError) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
issues: [
|
|
80
|
+
{
|
|
81
|
+
code: ISSUE_CODES.TOO_DEEP,
|
|
82
|
+
message: "Maximum validation depth exceeded",
|
|
83
|
+
path: [],
|
|
84
|
+
expected: "bounded nesting",
|
|
85
|
+
received: "too deep",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
if (ctx.issues.length > 0) {
|
|
93
|
+
return { success: false, issues: ctx.issues };
|
|
94
|
+
}
|
|
95
|
+
return { success: true, data: output as TOutput };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Internal: run the 5-step pipeline */
|
|
99
|
+
_runPipeline(input: unknown, ctx: ParseContext): unknown {
|
|
100
|
+
// Depth guard: bound recursion (recursive $ref + deep data) so a crafted
|
|
101
|
+
// payload cannot exhaust the call stack. Return a clean issue instead of
|
|
102
|
+
// letting safeParse throw a RangeError.
|
|
103
|
+
const depth = (ctx.depth ?? 0) + 1;
|
|
104
|
+
if (depth > MAX_DEPTH) {
|
|
105
|
+
ctx.issues.push({
|
|
106
|
+
code: ISSUE_CODES.TOO_DEEP,
|
|
107
|
+
message: `Maximum validation depth of ${MAX_DEPTH} exceeded`,
|
|
108
|
+
path: [...ctx.path],
|
|
109
|
+
expected: `<= ${MAX_DEPTH} levels`,
|
|
110
|
+
received: "too deep",
|
|
111
|
+
});
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
ctx.depth = depth;
|
|
115
|
+
try {
|
|
116
|
+
return this._runPipelineInner(input, ctx);
|
|
117
|
+
} finally {
|
|
118
|
+
ctx.depth = depth - 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private _runPipelineInner(input: unknown, ctx: ParseContext): unknown {
|
|
123
|
+
// Step 1: detect presence
|
|
124
|
+
const isAbsent = input === undefined || input === ABSENT;
|
|
125
|
+
|
|
126
|
+
let value: unknown = input;
|
|
127
|
+
|
|
128
|
+
// Step 2: coercion (only for present values)
|
|
129
|
+
if (!isAbsent && this._coercionConfig) {
|
|
130
|
+
const coerced = applyCoercion(
|
|
131
|
+
value,
|
|
132
|
+
this._coercionConfig,
|
|
133
|
+
this._getCoercionTarget()
|
|
134
|
+
);
|
|
135
|
+
if (coerced.success) {
|
|
136
|
+
value = coerced.value;
|
|
137
|
+
} else {
|
|
138
|
+
ctx.issues.push({
|
|
139
|
+
code: ISSUE_CODES.COERCION_FAILED,
|
|
140
|
+
message: coerced.message,
|
|
141
|
+
path: [...ctx.path],
|
|
142
|
+
expected: this._getCoercionTarget(),
|
|
143
|
+
received: String(input),
|
|
144
|
+
});
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Step 3: default materialization (only for absent values)
|
|
150
|
+
let usedDefault = false;
|
|
151
|
+
if (isAbsent && this._defaultValue !== ABSENT) {
|
|
152
|
+
// Deep-clone so mutable defaults (arrays/objects) are not shared across
|
|
153
|
+
// parses. Pass-through schemas (any/unknown) return the value by
|
|
154
|
+
// reference, so without cloning a mutation would corrupt the default.
|
|
155
|
+
value = cloneDefault(this._defaultValue);
|
|
156
|
+
usedDefault = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Step 4: validate
|
|
160
|
+
const issuesBefore = ctx.issues.length;
|
|
161
|
+
const result = this._validate(value, ctx);
|
|
162
|
+
|
|
163
|
+
// If default was materialized and validation failed, remap issues to default_invalid
|
|
164
|
+
if (usedDefault && ctx.issues.length > issuesBefore) {
|
|
165
|
+
for (let i = issuesBefore; i < ctx.issues.length; i++) {
|
|
166
|
+
const issue = ctx.issues[i];
|
|
167
|
+
ctx.issues[i] = {
|
|
168
|
+
...issue,
|
|
169
|
+
code: ISSUE_CODES.DEFAULT_INVALID,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Override in subclasses to provide the coercion target type name */
|
|
178
|
+
_getCoercionTarget(): string {
|
|
179
|
+
return "unknown";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
abstract _validate(input: unknown, ctx: ParseContext): unknown;
|
|
183
|
+
|
|
184
|
+
abstract _toNode(): SchemaNode;
|
|
185
|
+
|
|
186
|
+
// optional() and nullable() are provided via standalone functions
|
|
187
|
+
// to avoid circular imports. See index.ts.
|
|
188
|
+
|
|
189
|
+
default(value: TOutput): this {
|
|
190
|
+
const clone = this._clone();
|
|
191
|
+
clone._defaultValue = value;
|
|
192
|
+
return clone;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
coerce(options: CoercionConfig = {}): this {
|
|
196
|
+
const clone = this._clone();
|
|
197
|
+
clone._coercionConfig = { ...options };
|
|
198
|
+
return clone;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe(description: string, opts?: DescribeOptions): this {
|
|
202
|
+
const reservedMeta = this._validateDescribeOpts(description, opts);
|
|
203
|
+
const clone = this._clone();
|
|
204
|
+
clone._metadata = { ...(clone._metadata || {}), ...reservedMeta };
|
|
205
|
+
return clone;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
metadata(meta: Record<string, unknown>, opts?: MetadataOptions): this {
|
|
209
|
+
// Validate no reserved keys
|
|
210
|
+
for (const key of Object.keys(meta)) {
|
|
211
|
+
if (RESERVED_METADATA_KEYS.has(key)) {
|
|
212
|
+
throw new Error(`metadata(): "${key}" is a reserved key. Use describe() instead.`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const clone = this._clone();
|
|
216
|
+
if (opts?.replace) {
|
|
217
|
+
// Replace mode: keep only reserved keys from existing, add new non-reserved
|
|
218
|
+
const existing = clone._metadata || {};
|
|
219
|
+
const reserved: Record<string, unknown> = {};
|
|
220
|
+
for (const [k, v] of Object.entries(existing)) {
|
|
221
|
+
if (RESERVED_METADATA_KEYS.has(k)) {
|
|
222
|
+
reserved[k] = v;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
clone._metadata = { ...reserved, ...meta };
|
|
226
|
+
} else {
|
|
227
|
+
// Merge mode (default): shallow merge
|
|
228
|
+
clone._metadata = { ...(clone._metadata || {}), ...meta };
|
|
229
|
+
}
|
|
230
|
+
return clone;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export(mode: ExportMode = "portable"): AnyValiDocument {
|
|
234
|
+
if (mode === "portable" && !this._isPortable) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
"Cannot export in portable mode: schema contains non-portable features"
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const node = this._toNode();
|
|
240
|
+
return {
|
|
241
|
+
anyvaliVersion: ANYVALI_VERSION,
|
|
242
|
+
schemaVersion: SCHEMA_VERSION,
|
|
243
|
+
root: node,
|
|
244
|
+
definitions: {},
|
|
245
|
+
extensions: {},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------- internal helpers ----------
|
|
250
|
+
|
|
251
|
+
private _validateDescribeOpts(description: string, opts?: DescribeOptions): Record<string, unknown> {
|
|
252
|
+
const meta: Record<string, unknown> = { description };
|
|
253
|
+
|
|
254
|
+
if (typeof description !== 'string') {
|
|
255
|
+
throw new Error('describe(): description must be a string');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (opts) {
|
|
259
|
+
if (opts.title !== undefined) {
|
|
260
|
+
if (typeof opts.title !== 'string') throw new Error('describe(): title must be a string');
|
|
261
|
+
meta.title = opts.title;
|
|
262
|
+
}
|
|
263
|
+
if (opts.deprecated !== undefined) {
|
|
264
|
+
if (typeof opts.deprecated !== 'boolean') throw new Error('describe(): deprecated must be a boolean');
|
|
265
|
+
meta.deprecated = opts.deprecated;
|
|
266
|
+
}
|
|
267
|
+
if (opts.deprecatedMessage !== undefined) {
|
|
268
|
+
if (typeof opts.deprecatedMessage !== 'string') throw new Error('describe(): deprecatedMessage must be a string');
|
|
269
|
+
if (!opts.deprecated) throw new Error('describe(): deprecatedMessage requires deprecated to be true');
|
|
270
|
+
meta.deprecatedMessage = opts.deprecatedMessage;
|
|
271
|
+
}
|
|
272
|
+
if (opts.notStable !== undefined) {
|
|
273
|
+
if (typeof opts.notStable !== 'boolean') throw new Error('describe(): notStable must be a boolean');
|
|
274
|
+
meta.notStable = opts.notStable;
|
|
275
|
+
}
|
|
276
|
+
if (opts.since !== undefined) {
|
|
277
|
+
if (typeof opts.since !== 'string') throw new Error('describe(): since must be a string');
|
|
278
|
+
meta.since = opts.since;
|
|
279
|
+
}
|
|
280
|
+
if (opts.sensitive !== undefined) {
|
|
281
|
+
if (typeof opts.sensitive !== 'boolean') throw new Error('describe(): sensitive must be a boolean');
|
|
282
|
+
meta.sensitive = opts.sensitive;
|
|
283
|
+
}
|
|
284
|
+
if (opts.readonly !== undefined) {
|
|
285
|
+
if (typeof opts.readonly !== 'boolean') throw new Error('describe(): readonly must be a boolean');
|
|
286
|
+
meta.readonly = opts.readonly;
|
|
287
|
+
}
|
|
288
|
+
if (opts.writeonly !== undefined) {
|
|
289
|
+
if (typeof opts.writeonly !== 'boolean') throw new Error('describe(): writeonly must be a boolean');
|
|
290
|
+
meta.writeonly = opts.writeonly;
|
|
291
|
+
}
|
|
292
|
+
if (opts.readonly && opts.writeonly) {
|
|
293
|
+
throw new Error('describe(): readonly and writeonly cannot both be true');
|
|
294
|
+
}
|
|
295
|
+
if (opts.examples !== undefined) {
|
|
296
|
+
if (!Array.isArray(opts.examples)) throw new Error('describe(): examples must be an array');
|
|
297
|
+
meta.examples = opts.examples;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return meta;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
protected _clone(): this {
|
|
305
|
+
const clone = Object.create(Object.getPrototypeOf(this));
|
|
306
|
+
Object.assign(clone, this);
|
|
307
|
+
return clone as this;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
protected _addDefault(node: SchemaNode): SchemaNode {
|
|
311
|
+
if (this._defaultValue !== ABSENT) {
|
|
312
|
+
node.default = this._defaultValue;
|
|
313
|
+
}
|
|
314
|
+
if (this._coercionConfig) {
|
|
315
|
+
node.coerce = { ...this._coercionConfig };
|
|
316
|
+
}
|
|
317
|
+
if (this._metadata && Object.keys(this._metadata).length > 0) {
|
|
318
|
+
node.metadata = { ...this._metadata };
|
|
319
|
+
}
|
|
320
|
+
return node;
|
|
321
|
+
}
|
|
322
|
+
}
|