@amritk/generate-validators 0.11.12 → 0.12.0
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/AI.md +5 -2
- package/README.md +105 -23
- package/dist/generators/assert-generatable-refs.d.ts +19 -0
- package/dist/generators/assert-generatable-refs.js +12 -0
- package/dist/generators/assert-unevaluated-generatable.d.ts +30 -0
- package/dist/generators/assert-unevaluated-generatable.js +72 -0
- package/dist/generators/build-schema.d.ts +17 -1
- package/dist/generators/build-schema.js +2 -2
- package/dist/generators/collect-emitted-refs.d.ts +26 -0
- package/dist/generators/collect-emitted-refs.js +62 -0
- package/dist/generators/collect-validator-imports.js +2 -38
- package/dist/generators/generate-files.js +1 -1
- package/dist/generators/generate-validator-function.d.ts +5 -2
- package/dist/generators/generate-validator-function.js +313 -125
- package/dist/generators/unevaluated-match.d.ts +29 -0
- package/dist/generators/unevaluated-match.js +209 -0
- package/package.json +5 -3
package/AI.md
CHANGED
|
@@ -37,7 +37,10 @@ const files = await buildValidatorSchema(schema, 'Document')
|
|
|
37
37
|
5. **`format` emits no check.** It stays an annotation, like the interpreter's
|
|
38
38
|
default — but *not* like the interpreter run with `{ formats: 'all' }`
|
|
39
39
|
(`@amritk/lint`, `createApi({ formats })`), which rejects strings a generated
|
|
40
|
-
validator accepts. `unevaluatedProperties`/`unevaluatedItems`
|
|
41
|
-
|
|
40
|
+
validator accepts. `unevaluatedProperties`/`unevaluatedItems` *are* generated;
|
|
41
|
+
four shapes still refuse (coverage through a `$dynamicRef`, an unresolvable or
|
|
42
|
+
cyclic `$ref` at the same instance location, a walk deeper than eight
|
|
43
|
+
applicators, a node under `additionalItems`), and generation **throws** for
|
|
44
|
+
those rather than widening the verdict.
|
|
42
45
|
|
|
43
46
|
Only the `.` entry. Install: `bun add @amritk/generate-validators`.
|
package/README.md
CHANGED
|
@@ -84,16 +84,40 @@ if (!result.valid) {
|
|
|
84
84
|
|
|
85
85
|
## API
|
|
86
86
|
|
|
87
|
-
### `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?)`
|
|
87
|
+
### `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?)`
|
|
88
88
|
|
|
89
89
|
| Parameter | Type | Default | Description |
|
|
90
90
|
|:---|:---|:---|:---|
|
|
91
91
|
| `rootSchema` | `JSONSchema` | — | The root schema to traverse. `$ref` and `$dynamicRef` are resolved recursively. Draft-07 schemas are upgraded to 2020-12 automatically. |
|
|
92
92
|
| `rootTypeName` | `string` | — | Name used for the root type (e.g. `"Document"`). |
|
|
93
93
|
| `typeSuffix` | `string` | `''` | Suffix appended to every `$ref`-derived type name (`'Object'` turns `Contact` into `ContactObject`). The root type name is unaffected. |
|
|
94
|
+
| `schemas` | `Record<string, unknown>` | — | Documents you have **already loaded**, keyed by the absolute URI a `$ref` names them by. See below. |
|
|
94
95
|
|
|
95
96
|
Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
|
|
96
97
|
|
|
98
|
+
#### Referencing another document
|
|
99
|
+
|
|
100
|
+
A `$ref` to a URI is resolvable once you hand over the document behind it:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
const files = await buildValidatorSchema({ $ref: 'https://example.com/user.json' }, 'Document', '', {
|
|
104
|
+
'https://example.com/user.json': userSchema,
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Each registered document becomes a resource of the generated document: its `$id`,
|
|
109
|
+
its `$anchor`s and `$dynamicAnchor`s and its own embedded resources all resolve, a
|
|
110
|
+
`$ref` from one registered document into another resolves, and each definition
|
|
111
|
+
reached gets a file and a type like any other. A document with no `$id` resolves
|
|
112
|
+
its relative `$ref`s against the URI you registered it under; one whose `$id`
|
|
113
|
+
disagrees answers to both.
|
|
114
|
+
|
|
115
|
+
Nothing is fetched — you cannot pass a URL, only a document — so generation stays
|
|
116
|
+
a pure function of its inputs. Loading is yours to do, or
|
|
117
|
+
[`@amritk/resolve-refs`](../resolve-refs)'. Registering more than the schema uses
|
|
118
|
+
costs nothing: only the documents actually reached are emitted. A `$ref` to a URI
|
|
119
|
+
nobody registered still stops the build, with a message naming the ref.
|
|
120
|
+
|
|
97
121
|
---
|
|
98
122
|
|
|
99
123
|
## Semantics
|
|
@@ -107,23 +131,81 @@ per-item work (a bare `string[]` is free; a closed object with several fields is
|
|
|
107
131
|
meaningfully slower), which is why array-heavy schemas validate more slowly than
|
|
108
132
|
scalar/object ones.
|
|
109
133
|
|
|
134
|
+
**A schema needs no `type` for its keywords to be enforced.** `{ minLength: 2 }`,
|
|
135
|
+
`{ required: ['a'] }` and `{ uniqueItems: true }` each emit their check behind a
|
|
136
|
+
runtime test for the family they constrain, so they reject a bad string / object /
|
|
137
|
+
array and *ignore* every other kind of value — which is what JSON Schema means by
|
|
138
|
+
a type-less constraint, and what the interpreter does. One consequence is worth
|
|
139
|
+
knowing: object keywords no longer imply `type: 'object'`, so `validateX` accepts
|
|
140
|
+
`42` against `{ properties: { … } }`, while the emitted TypeScript type still
|
|
141
|
+
describes the object case (as `FromSchema` does in `@amritk/runtime-validators`).
|
|
142
|
+
The verdict is the contract and it matches the interpreter exactly; for that one
|
|
143
|
+
shape `isX` is a weaker type guard than the type it names. Declare a `type` — as
|
|
144
|
+
almost every real schema does — and the guard is exact again.
|
|
145
|
+
|
|
110
146
|
**`format` emits no check.** JSON Schema treats `format` as an annotation, and so
|
|
111
147
|
does this generator: `{ type: 'string', format: 'uuid' }` produces the `typeof`
|
|
112
148
|
check and nothing more. That matches the interpreter's default, but *not* the
|
|
113
149
|
interpreter run with `{ formats: 'all' }` — as `@amritk/lint` and
|
|
114
150
|
`createApi({ formats })` do — so a generated validator accepts strings those
|
|
115
|
-
reject.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
151
|
+
reject.
|
|
152
|
+
|
|
153
|
+
**`unevaluatedProperties` / `unevaluatedItems` are generated**, not refused. Each
|
|
154
|
+
emits a flat expression computing what the interpreter computes as annotations: per
|
|
155
|
+
key or index, a boolean that is true when some keyword evaluated it. Keywords that
|
|
156
|
+
must succeed for the value to be valid at all (`allOf` members, a `$ref` target, a
|
|
157
|
+
satisfied `contains`) count unconditionally — sound, because the test is one
|
|
158
|
+
conjunct of a validator that also asserts them — while conditional applicators
|
|
159
|
+
(`anyOf` / `oneOf` branches, `if` / `then` / `else`, `dependentSchemas`) carry their
|
|
160
|
+
condition, hoisted out of the per-key loop. Four shapes still refuse, each named as
|
|
161
|
+
a shape rather than as a keyword: coverage running through a `$dynamicRef`, an
|
|
162
|
+
unresolvable or cyclic `$ref` at the same instance location, a walk deeper than
|
|
163
|
+
eight applicators, and a node under `additionalItems`.
|
|
164
|
+
|
|
165
|
+
One edge worth calling out: **`NaN` fails a constrained number but satisfies an
|
|
166
|
+
unconstrained one.** Every bound is emitted as the negated *pass* condition
|
|
167
|
+
(`!(x >= minimum)`, not `x < minimum`), and `NaN` compares `false` against every
|
|
168
|
+
operator, so it fails the bound — and `multipleOf` rejects it on both branches of
|
|
169
|
+
the shared `@amritk/helpers/multiple-of-check`. A bare `{ type: 'number' }` with
|
|
170
|
+
no constraint still accepts it, as Ajv does. This matches
|
|
171
|
+
`@amritk/runtime-validators` exactly, and the match is pinned value-by-value in
|
|
172
|
+
`interpreter-parity.test.ts` rather than asserted here. `NaN` never appears in
|
|
173
|
+
parsed JSON, so this only matters for values built in memory.
|
|
174
|
+
|
|
175
|
+
### Conformance, measured
|
|
176
|
+
|
|
177
|
+
The semantics above are measured, not asserted.
|
|
178
|
+
`src/generators/conformance.test.ts` generates a validator for each schema in the
|
|
179
|
+
official [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
|
|
180
|
+
(the required Draft 2020-12 tests — 1281 cases), compiles and links the emitted
|
|
181
|
+
files in memory, and runs the suite's instances through the real generated code:
|
|
182
|
+
|
|
183
|
+
**1271 / 1281 cases pass (99.2%).**
|
|
184
|
+
|
|
185
|
+
The suite's `remotes/` documents and the 2020-12 dialect metaschema are supplied
|
|
186
|
+
through the `schemas` option, which is how the suite intends a validator that does
|
|
187
|
+
no I/O to answer the retrieval step. Everything else — applying the base URIs,
|
|
188
|
+
walking anchors across documents, naming and emitting a file per definition — the
|
|
189
|
+
generator still has to do.
|
|
190
|
+
|
|
191
|
+
Of the 10 that do not pass: five `$dynamicRef`s whose binding depends on the
|
|
192
|
+
evaluation path (a generator emits one function per definition, shared by every
|
|
193
|
+
path that reaches it, so it cannot bind per path), two definitions in different
|
|
194
|
+
embedded resources that reduce to one filename, two `$id`-scoped in-document
|
|
195
|
+
pointers, and `$vocabulary`. Nothing on the list is a keyword that silently
|
|
196
|
+
returns the wrong answer.
|
|
197
|
+
|
|
198
|
+
Every case is named in
|
|
199
|
+
`src/generators/conformance-expected-failures.test-utils.ts` with its reason, and
|
|
200
|
+
the test fails if a case moves in *either* direction — a regression breaks the
|
|
201
|
+
build, and so does a case that starts passing without its entry being removed.
|
|
202
|
+
|
|
203
|
+
If you need one of those refusals to be an answer instead, validate with
|
|
204
|
+
[`@amritk/runtime-validators`](../runtime-validators), which passes the same corpus
|
|
205
|
+
in full (**1281/1281**), at the cost of interpreting the schema at runtime.
|
|
206
|
+
The corpus is vendored under
|
|
207
|
+
[`fixtures/json-schema-test-suite`](../../fixtures/json-schema-test-suite); none
|
|
208
|
+
of it is published.
|
|
127
209
|
|
|
128
210
|
---
|
|
129
211
|
|
|
@@ -143,21 +225,21 @@ Measured on Bun 1.3 (Linux x64), validating valid input at steady state:
|
|
|
143
225
|
|
|
144
226
|
| schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
|
|
145
227
|
|:--|--:|--:|--:|--:|--:|
|
|
146
|
-
| small (4 fields) | **~
|
|
147
|
-
| order (nested + array) | **~
|
|
148
|
-
| assert-loose | **~
|
|
149
|
-
| assert-strict | **~
|
|
228
|
+
| small (4 fields) | **~49M** ops/s | ~6.4M ops/s | ~11M ops/s | ~5.7M ops/s | ~2.4M ops/s |
|
|
229
|
+
| order (nested + array) | **~11M** ops/s | ~2.5M ops/s | ~4M ops/s | ~2.4M ops/s | ~0.52M ops/s |
|
|
230
|
+
| assert-loose | **~177M** ops/s | ~162M ops/s | ~46M ops/s | ~70M ops/s | ~3.9M ops/s |
|
|
231
|
+
| assert-strict | **~164M** ops/s | ~146M ops/s | ~20M ops/s | ~44M ops/s | ~1.5M ops/s |
|
|
150
232
|
|
|
151
233
|
The `assert-loose` / `assert-strict` rows are the exact shape used by
|
|
152
234
|
[`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
|
|
153
|
-
(seven scalar roots plus a nested object): the boolean guard
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
235
|
+
(seven scalar roots plus a nested object): the boolean guard keeps mjst ahead of
|
|
236
|
+
typia on both, by ~10% on `assert-loose` and ~12% on `assert-strict` (with
|
|
237
|
+
`additionalProperties: false`) — close enough that the two can trade the lead
|
|
238
|
+
run-to-run. (typia and TypeBox still win the *invalid* path, where they bail on
|
|
157
239
|
the first error rather than collecting a full error list.)
|
|
158
240
|
|
|
159
|
-
Preparing a validator costs ~0.3–0.
|
|
160
|
-
TypeBox `TypeCompiler` compile, versus ~
|
|
241
|
+
Preparing a validator costs ~0.3–0.7 ms for mjst codegen and ~0.04–0.3 ms for a
|
|
242
|
+
TypeBox `TypeCompiler` compile, versus ~7–11 ms for an Ajv compile. Every library
|
|
161
243
|
agrees on every verdict; parity is asserted before timing.
|
|
162
244
|
|
|
163
245
|
One caveat on the first two rows: their schemas declare `format` (`uuid`,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throws when a schema would make the emitter call a validator that no file
|
|
3
|
+
* defines.
|
|
4
|
+
*
|
|
5
|
+
* The walker refuses an unresolvable `#`/HTTP `$ref` already, so those never get
|
|
6
|
+
* this far. A ref written against an `$id` does: the walker's queue skips it
|
|
7
|
+
* (nothing in the document is keyed by `int.json`), the import collector skips it
|
|
8
|
+
* for the same reason — and the emitter, which only ever looks at the ref
|
|
9
|
+
* *string*, happily writes `validateIntJson(input, _path)` anyway. The result is
|
|
10
|
+
* generated TypeScript that does not compile, which is a loud failure in the
|
|
11
|
+
* wrong place: it lands in the consumer's build, long after the schema that
|
|
12
|
+
* caused it is out of sight.
|
|
13
|
+
*
|
|
14
|
+
* Stopping here instead puts the failure next to its cause and names the ref, the
|
|
15
|
+
* same answer the resolvable-but-unsupported paths already give. Resolving these
|
|
16
|
+
* refs properly means tracking `$id` base URIs through the document, which is a
|
|
17
|
+
* separate feature — until it exists, refusing is the honest report.
|
|
18
|
+
*/
|
|
19
|
+
export declare const assertGeneratableRefs: (schema: unknown, typeName: string) => void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { collectEmittedRefs } from "./collect-emitted-refs.js";
|
|
2
|
+
const isGeneratableRef = (ref) => ref.startsWith("#") || ref.startsWith("http://") || ref.startsWith("https://");
|
|
3
|
+
const assertGeneratableRefs = (schema, typeName) => {
|
|
4
|
+
for (const ref of collectEmittedRefs(schema)) {
|
|
5
|
+
if (isGeneratableRef(ref))
|
|
6
|
+
continue;
|
|
7
|
+
throw new Error(`[${typeName}] unresolvable $ref "${ref}": its target is not in this document, and generation reads no files, so the validator it would call is never emitted. Inline the other document first with @amritk/resolve-refs, rewrite the ref as an in-document pointer (e.g. "#/$defs/name") or an absolute http(s) URI keyed in "$defs", or validate this schema with the runtime interpreter, which takes documents you already have via its \`schemas\` option.`);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
export {
|
|
11
|
+
assertGeneratableRefs
|
|
12
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
import { type UnevaluatedMatchFn } from './unevaluated-match.js';
|
|
3
|
+
/**
|
|
4
|
+
* The message a node gets when its coverage cannot be worked out inline. Shared
|
|
5
|
+
* with the emitter so the gate and the code path it guards say the same thing.
|
|
6
|
+
*/
|
|
7
|
+
export declare const UNPROVABLE_COVERAGE_MESSAGE: (keyword: string) => string;
|
|
8
|
+
/**
|
|
9
|
+
* Throws when a schema uses `unevaluatedProperties` / `unevaluatedItems` in a way
|
|
10
|
+
* the generated code cannot honour.
|
|
11
|
+
*
|
|
12
|
+
* Both keywords police whatever a value's *other* keywords left untouched, which
|
|
13
|
+
* the interpreter answers with annotations gathered during its walk. Generated
|
|
14
|
+
* code has no walk, so `unevaluated-match.ts` reconstructs the same answer as an
|
|
15
|
+
* expression — and where it cannot (a `$ref` that resolves to nothing or back to
|
|
16
|
+
* itself, a `$dynamicRef` whose target is only known at runtime, or a position
|
|
17
|
+
* this generator does not enforce at all), generation stops here.
|
|
18
|
+
*
|
|
19
|
+
* That is deliberately a refusal *per shape* rather than per keyword: the vast
|
|
20
|
+
* majority of `unevaluated*` schemas are expressible and are now generated. What
|
|
21
|
+
* is left is the handful the flat form genuinely cannot see through, and for
|
|
22
|
+
* those a build error is the honest report — far better than a validator that
|
|
23
|
+
* accepts documents the interpreter rejects.
|
|
24
|
+
*
|
|
25
|
+
* `$defs` / `definitions` are skipped: a referenced definition is generated as
|
|
26
|
+
* its own file and gated there, with its own fresh annotation scope (which is
|
|
27
|
+
* also what the spec says a `$ref` target gets), and an unreferenced one is never
|
|
28
|
+
* applied to anything.
|
|
29
|
+
*/
|
|
30
|
+
export declare const assertUnevaluatedGeneratable: (schema: JSONSchema, typeName: string, rootSchema: Record<string, unknown> | undefined, match: UnevaluatedMatchFn) => void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { unevaluatedItemsExpr, unevaluatedPropertiesExpr } from "./unevaluated-match.js";
|
|
2
|
+
const UNPROVABLE_COVERAGE_MESSAGE = (keyword) => `unsupported "${keyword}": working out which keys the schema already evaluated means reading through an applicator this generator cannot follow inline \u2014 an unresolvable or cyclic "$ref", or a "$dynamicRef", whose target is only known at validation time. Validate this schema with \`@amritk/runtime-validators\` instead.`;
|
|
3
|
+
const SINGLE_SUBSCHEMA_KEYS = [
|
|
4
|
+
"additionalProperties",
|
|
5
|
+
"propertyNames",
|
|
6
|
+
"contains",
|
|
7
|
+
"not",
|
|
8
|
+
"if",
|
|
9
|
+
"then",
|
|
10
|
+
"else",
|
|
11
|
+
"unevaluatedProperties",
|
|
12
|
+
"unevaluatedItems"
|
|
13
|
+
];
|
|
14
|
+
const SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
|
15
|
+
const SUBSCHEMA_MAP_KEYS = ["properties", "patternProperties", "dependentSchemas", "dependencies"];
|
|
16
|
+
const UNENFORCED_SUBSCHEMA_KEYS = ["additionalItems"];
|
|
17
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
const check = (node, typeName, rootSchema, match, enforced) => {
|
|
19
|
+
const record = node;
|
|
20
|
+
for (const keyword of ["unevaluatedProperties", "unevaluatedItems"]) {
|
|
21
|
+
if (!(keyword in record) || record[keyword] === true)
|
|
22
|
+
continue;
|
|
23
|
+
if (!enforced) {
|
|
24
|
+
throw new Error(`[${typeName}] unsupported "${keyword}": it sits under "additionalItems", a subschema position this generator does not enforce, so the check would never run. Validate this schema with \`@amritk/runtime-validators\` instead.`);
|
|
25
|
+
}
|
|
26
|
+
const expression = keyword === "unevaluatedProperties" ? unevaluatedPropertiesExpr("_probe", node, rootSchema, 0, match) : unevaluatedItemsExpr("_probe", node, rootSchema, 0, match);
|
|
27
|
+
if (expression === null)
|
|
28
|
+
throw new Error(`[${typeName}] ${UNPROVABLE_COVERAGE_MESSAGE(keyword)}`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const assertUnevaluatedGeneratable = (schema, typeName, rootSchema, match) => {
|
|
32
|
+
const visit = (node, enforced) => {
|
|
33
|
+
if (!isRecord(node))
|
|
34
|
+
return;
|
|
35
|
+
if ("unevaluatedProperties" in node || "unevaluatedItems" in node) {
|
|
36
|
+
check(node, typeName, rootSchema, match, enforced);
|
|
37
|
+
}
|
|
38
|
+
for (const key of SINGLE_SUBSCHEMA_KEYS) {
|
|
39
|
+
if (key in node)
|
|
40
|
+
visit(node[key], enforced);
|
|
41
|
+
}
|
|
42
|
+
const items = node["items"];
|
|
43
|
+
if (Array.isArray(items))
|
|
44
|
+
for (const entry of items)
|
|
45
|
+
visit(entry, enforced);
|
|
46
|
+
else if (items !== void 0)
|
|
47
|
+
visit(items, enforced);
|
|
48
|
+
for (const key of SUBSCHEMA_LIST_KEYS) {
|
|
49
|
+
const list = node[key];
|
|
50
|
+
if (Array.isArray(list))
|
|
51
|
+
for (const entry of list)
|
|
52
|
+
visit(entry, enforced);
|
|
53
|
+
}
|
|
54
|
+
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
55
|
+
const map = node[key];
|
|
56
|
+
if (!isRecord(map))
|
|
57
|
+
continue;
|
|
58
|
+
for (const entry of Object.values(map))
|
|
59
|
+
if (!Array.isArray(entry))
|
|
60
|
+
visit(entry, enforced);
|
|
61
|
+
}
|
|
62
|
+
for (const key of UNENFORCED_SUBSCHEMA_KEYS) {
|
|
63
|
+
if (key in node)
|
|
64
|
+
visit(node[key], false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
visit(schema, true);
|
|
68
|
+
};
|
|
69
|
+
export {
|
|
70
|
+
UNPROVABLE_COVERAGE_MESSAGE,
|
|
71
|
+
assertUnevaluatedGeneratable
|
|
72
|
+
};
|
|
@@ -20,12 +20,28 @@ export type GeneratedFile = {
|
|
|
20
20
|
*
|
|
21
21
|
* @param rootSchema - The root JSON Schema to build from
|
|
22
22
|
* @param rootTypeName - The name for the root type (e.g. "Document")
|
|
23
|
+
* @param typeSuffix - Suffix appended to every type name derived from a `$ref`
|
|
24
|
+
* (e.g. `'Object'` → `ContactObject`). Defaults to `''`. The root type name is
|
|
25
|
+
* used verbatim and is not affected.
|
|
26
|
+
* @param schemas - Other schema documents you have **already loaded**, keyed by
|
|
27
|
+
* the absolute URI a `$ref` names them by. Supplying them makes those URIs
|
|
28
|
+
* resolvable, so the schema can reference a document that is not itself — each
|
|
29
|
+
* one becomes a resource of the generated document, with its own `$id`,
|
|
30
|
+
* anchors and nested resources all resolvable. Nothing is fetched here:
|
|
31
|
+
* loading is yours to do (or `@amritk/resolve-refs`'), and a `$ref` to a URI
|
|
32
|
+
* nobody registered still stops generation. Only the documents actually
|
|
33
|
+
* referenced get files.
|
|
23
34
|
* @returns An array of generated TypeScript files
|
|
24
35
|
*
|
|
25
36
|
* @example
|
|
26
37
|
* ```typescript
|
|
27
38
|
* const files = await buildValidatorSchema(schema, 'Document')
|
|
28
39
|
* // files → [{ filename: 'document.ts', content: '...' }, { filename: 'info.ts', ... }, ...]
|
|
40
|
+
*
|
|
41
|
+
* // Referencing a document you loaded yourself:
|
|
42
|
+
* const withRemote = await buildValidatorSchema({ $ref: 'https://example.com/user.json' }, 'Document', '', {
|
|
43
|
+
* 'https://example.com/user.json': userSchema,
|
|
44
|
+
* })
|
|
29
45
|
* ```
|
|
30
46
|
*/
|
|
31
|
-
export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string) => Promise<GeneratedFile[]>;
|
|
47
|
+
export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string, schemas?: Readonly<Record<string, unknown>>) => Promise<GeneratedFile[]>;
|
|
@@ -75,9 +75,9 @@ export const allUnique = (arr: readonly unknown[]): boolean => {
|
|
|
75
75
|
return true
|
|
76
76
|
}
|
|
77
77
|
`;
|
|
78
|
-
const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "") => {
|
|
78
|
+
const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas) => {
|
|
79
79
|
const files = [];
|
|
80
|
-
walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
80
|
+
walkRefGraph(rootSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
|
|
81
81
|
if (node.filename === "validation-result" || node.filename === "index")
|
|
82
82
|
return;
|
|
83
83
|
const content = generateValidatorFile(node.schema, node.typeName, {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively walks a schema and yields every `$ref` the validator emitter turns
|
|
3
|
+
* into a `validateX(...)` call, in traversal order (duplicates included — the
|
|
4
|
+
* callers dedupe on what they key by).
|
|
5
|
+
*
|
|
6
|
+
* The emitter recurses into far more than properties/items/additionalProperties
|
|
7
|
+
* and the top-level combinators: it also delegates for `patternProperties`,
|
|
8
|
+
* `propertyNames`, `if`/`then`/`else`, `contains`, `prefixItems`,
|
|
9
|
+
* `dependentSchemas`, `not`, the `unevaluated*` subschemas, and objects nested
|
|
10
|
+
* inside any combinator branch. A `$ref` reached by *any* of those paths becomes
|
|
11
|
+
* a call in the output, so both the import collector and the generatability check
|
|
12
|
+
* have to see exactly this set — which is why the traversal lives here rather
|
|
13
|
+
* than in either of them.
|
|
14
|
+
*
|
|
15
|
+
* `$defs` / `definitions` are deliberately skipped: they are split into their own
|
|
16
|
+
* generated files rather than inlined into this one.
|
|
17
|
+
*
|
|
18
|
+
* Pass `rootSchema` to also pick up the refs an `unevaluated*` keyword reaches.
|
|
19
|
+
* Those are the one case where a call can appear in *this* file for a branch
|
|
20
|
+
* written in *another* definition: working out what a node's `$ref` target
|
|
21
|
+
* already evaluated means reading through that target's own `anyOf` / `oneOf` /
|
|
22
|
+
* `if`, and each of those branches is emitted here as a condition. Without the
|
|
23
|
+
* root document there is nothing to resolve the target against, so the extra
|
|
24
|
+
* refs are simply not collected.
|
|
25
|
+
*/
|
|
26
|
+
export declare const collectEmittedRefs: (value: unknown, refs?: string[], rootSchema?: Record<string, unknown>) => string[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { unevaluatedItemsExpr, unevaluatedPropertiesExpr } from "./unevaluated-match.js";
|
|
2
|
+
const collectEmittedRefs = (value, refs = [], rootSchema) => {
|
|
3
|
+
if (typeof value !== "object" || value === null)
|
|
4
|
+
return refs;
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
for (const item of value)
|
|
7
|
+
collectEmittedRefs(item, refs, rootSchema);
|
|
8
|
+
return refs;
|
|
9
|
+
}
|
|
10
|
+
const schema = value;
|
|
11
|
+
if (rootSchema !== void 0 && ("unevaluatedProperties" in schema || "unevaluatedItems" in schema)) {
|
|
12
|
+
collectCoverageRefs(schema, refs, rootSchema);
|
|
13
|
+
}
|
|
14
|
+
if (typeof schema["$ref"] === "string") {
|
|
15
|
+
refs.push(schema["$ref"]);
|
|
16
|
+
return refs;
|
|
17
|
+
}
|
|
18
|
+
for (const mapKey of ["properties", "patternProperties", "dependentSchemas", "dependencies"]) {
|
|
19
|
+
const map = schema[mapKey];
|
|
20
|
+
if (typeof map === "object" && map !== null && !Array.isArray(map)) {
|
|
21
|
+
for (const sub of Object.values(map))
|
|
22
|
+
collectEmittedRefs(sub, refs, rootSchema);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const key of [
|
|
26
|
+
"items",
|
|
27
|
+
"additionalProperties",
|
|
28
|
+
"propertyNames",
|
|
29
|
+
"contains",
|
|
30
|
+
"if",
|
|
31
|
+
"then",
|
|
32
|
+
"else",
|
|
33
|
+
"not",
|
|
34
|
+
// The `unevaluated*` subschemas are validated against the leftover keys /
|
|
35
|
+
// indices, so a `$ref` inside one becomes a `validateX(...)` call like any
|
|
36
|
+
// other — and without it the generated file would call an import it never asked for.
|
|
37
|
+
"unevaluatedProperties",
|
|
38
|
+
"unevaluatedItems"
|
|
39
|
+
]) {
|
|
40
|
+
if (key in schema)
|
|
41
|
+
collectEmittedRefs(schema[key], refs, rootSchema);
|
|
42
|
+
}
|
|
43
|
+
for (const key of ["oneOf", "anyOf", "allOf", "prefixItems"]) {
|
|
44
|
+
const list = schema[key];
|
|
45
|
+
if (Array.isArray(list)) {
|
|
46
|
+
for (const sub of list)
|
|
47
|
+
collectEmittedRefs(sub, refs, rootSchema);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return refs;
|
|
51
|
+
};
|
|
52
|
+
const collectCoverageRefs = (schema, refs, rootSchema) => {
|
|
53
|
+
const record = (_accessor, sub) => {
|
|
54
|
+
collectEmittedRefs(sub, refs);
|
|
55
|
+
return "true";
|
|
56
|
+
};
|
|
57
|
+
unevaluatedPropertiesExpr("_refs", schema, rootSchema, 0, record);
|
|
58
|
+
unevaluatedItemsExpr("_refs", schema, rootSchema, 0, record);
|
|
59
|
+
};
|
|
60
|
+
export {
|
|
61
|
+
collectEmittedRefs
|
|
62
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { refToFilename } from "@amritk/helpers/ref-to-filename";
|
|
2
2
|
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
3
3
|
import { resolveRef } from "@amritk/helpers/resolve-ref";
|
|
4
|
-
import {
|
|
4
|
+
import { collectEmittedRefs } from "./collect-emitted-refs.js";
|
|
5
5
|
const buildImport = (ref, suffix) => {
|
|
6
6
|
const filename = refToFilename(ref);
|
|
7
7
|
const typeName = refToName(ref, suffix);
|
|
@@ -12,47 +12,11 @@ const canonicalFilename = (ref) => {
|
|
|
12
12
|
const base = ref.endsWith("-or-reference") ? ref.replace("-or-reference", "") : ref;
|
|
13
13
|
return refToFilename(base);
|
|
14
14
|
};
|
|
15
|
-
const collectDirectRefs = (value, refs = []) => {
|
|
16
|
-
if (typeof value !== "object" || value === null)
|
|
17
|
-
return refs;
|
|
18
|
-
if (Array.isArray(value)) {
|
|
19
|
-
for (const item of value)
|
|
20
|
-
collectDirectRefs(item, refs);
|
|
21
|
-
return refs;
|
|
22
|
-
}
|
|
23
|
-
const schema = value;
|
|
24
|
-
if (hasRef(schema)) {
|
|
25
|
-
refs.push(schema.$ref);
|
|
26
|
-
return refs;
|
|
27
|
-
}
|
|
28
|
-
const subSchemaMaps = ["properties", "patternProperties", "dependentSchemas", "dependencies"];
|
|
29
|
-
for (const mapKey of subSchemaMaps) {
|
|
30
|
-
const map = schema[mapKey];
|
|
31
|
-
if (typeof map === "object" && map !== null && !Array.isArray(map)) {
|
|
32
|
-
for (const sub of Object.values(map))
|
|
33
|
-
collectDirectRefs(sub, refs);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
const singleSubSchemas = ["items", "additionalProperties", "propertyNames", "contains", "if", "then", "else", "not"];
|
|
37
|
-
for (const key of singleSubSchemas) {
|
|
38
|
-
if (key in schema)
|
|
39
|
-
collectDirectRefs(schema[key], refs);
|
|
40
|
-
}
|
|
41
|
-
const arraySubSchemas = ["oneOf", "anyOf", "allOf", "prefixItems"];
|
|
42
|
-
for (const key of arraySubSchemas) {
|
|
43
|
-
const arr = schema[key];
|
|
44
|
-
if (Array.isArray(arr)) {
|
|
45
|
-
for (const sub of arr)
|
|
46
|
-
collectDirectRefs(sub, refs);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return refs;
|
|
50
|
-
};
|
|
51
15
|
const collectValidatorImports = (schema, options) => {
|
|
52
16
|
const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : null;
|
|
53
17
|
const rootSchema = options?.rootSchema;
|
|
54
18
|
const typeSuffix = options?.typeSuffix ?? "";
|
|
55
|
-
const refs =
|
|
19
|
+
const refs = collectEmittedRefs(schema, [], rootSchema);
|
|
56
20
|
const seen = /* @__PURE__ */ new Set();
|
|
57
21
|
const imports = [];
|
|
58
22
|
for (const ref of refs) {
|
|
@@ -12,7 +12,7 @@ const generateValidatorFile = (schema, typeName, options) => {
|
|
|
12
12
|
typeSuffix,
|
|
13
13
|
...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {}
|
|
14
14
|
});
|
|
15
|
-
const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix);
|
|
15
|
+
const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix, options?.rootSchema);
|
|
16
16
|
const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix);
|
|
17
17
|
let result = `import type { ValidationResult, ValidationError } from './validation-result.js'
|
|
18
18
|
`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
2
|
/**
|
|
3
|
-
* Generates the exported boolean
|
|
3
|
+
* Generates the exported boolean guard `isTypeName`.
|
|
4
4
|
*
|
|
5
5
|
* Unlike `validateTypeName` (which returns rich `ValidationResult` errors), this
|
|
6
6
|
* is a single flat boolean predicate — no error array, no cold-path call — so V8
|
|
@@ -8,6 +8,9 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
8
8
|
* compiled checker. It returns the *same verdict* as the validator. When the
|
|
9
9
|
* schema carries anything the flat form can't mirror exactly, it falls back to
|
|
10
10
|
* `validateTypeName(input) === true`, which is always correct.
|
|
11
|
+
*
|
|
12
|
+
* The signature is `input is TypeName` whenever that narrowing is sound, and a
|
|
13
|
+
* plain `boolean` when it is not — see {@link typeDescribesEveryAcceptedValue}.
|
|
11
14
|
*/
|
|
12
15
|
export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string, _suffix?: string) => string;
|
|
13
16
|
/**
|
|
@@ -32,4 +35,4 @@ export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string
|
|
|
32
35
|
* // }
|
|
33
36
|
* ```
|
|
34
37
|
*/
|
|
35
|
-
export declare const generateValidatorFunction: (schema: JSONSchema, typeName: string, suffix?: string) => string;
|
|
38
|
+
export declare const generateValidatorFunction: (schema: JSONSchema, typeName: string, suffix?: string, rootSchema?: Record<string, unknown>) => string;
|