@webda/content-mapper 0.0.0-stage → 4.0.0-beta.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/LICENSE +166 -0
- package/lib/coercions.d.ts +22 -0
- package/lib/coercions.d.ts.map +1 -0
- package/lib/coercions.js +10 -0
- package/lib/coercions.js.map +1 -0
- package/lib/context.d.ts +56 -0
- package/lib/context.d.ts.map +1 -0
- package/lib/context.js +111 -0
- package/lib/context.js.map +1 -0
- package/lib/defaults.d.ts +31 -0
- package/lib/defaults.d.ts.map +1 -0
- package/lib/defaults.js +33 -0
- package/lib/defaults.js.map +1 -0
- package/lib/generators/accessors.d.ts +15 -0
- package/lib/generators/accessors.d.ts.map +1 -0
- package/lib/generators/accessors.js +567 -0
- package/lib/generators/accessors.js.map +1 -0
- package/lib/generators/behaviors.d.ts +21 -0
- package/lib/generators/behaviors.d.ts.map +1 -0
- package/lib/generators/behaviors.js +292 -0
- package/lib/generators/behaviors.js.map +1 -0
- package/lib/generators/imports.d.ts +86 -0
- package/lib/generators/imports.d.ts.map +1 -0
- package/lib/generators/imports.js +205 -0
- package/lib/generators/imports.js.map +1 -0
- package/lib/generators/loadparameters.d.ts +7 -0
- package/lib/generators/loadparameters.d.ts.map +1 -0
- package/lib/generators/loadparameters.js +175 -0
- package/lib/generators/loadparameters.js.map +1 -0
- package/lib/generators/qlvalidator.d.ts +53 -0
- package/lib/generators/qlvalidator.d.ts.map +1 -0
- package/lib/generators/qlvalidator.js +238 -0
- package/lib/generators/qlvalidator.js.map +1 -0
- package/lib/index.d.ts +53 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +36 -0
- package/lib/index.js.map +1 -0
- package/lib/module-discovery.d.ts +211 -0
- package/lib/module-discovery.d.ts.map +1 -0
- package/lib/module-discovery.js +662 -0
- package/lib/module-discovery.js.map +1 -0
- package/lib/module.d.ts +213 -0
- package/lib/module.d.ts.map +1 -0
- package/lib/module.js +1243 -0
- package/lib/module.js.map +1 -0
- package/lib/plan.d.ts +101 -0
- package/lib/plan.d.ts.map +1 -0
- package/lib/plan.js +61 -0
- package/lib/plan.js.map +1 -0
- package/lib/property-order.d.ts +37 -0
- package/lib/property-order.d.ts.map +1 -0
- package/lib/property-order.js +141 -0
- package/lib/property-order.js.map +1 -0
- package/lib/schema/action.d.ts +46 -0
- package/lib/schema/action.d.ts.map +1 -0
- package/lib/schema/action.js +190 -0
- package/lib/schema/action.js.map +1 -0
- package/lib/schema/annotations.d.ts +63 -0
- package/lib/schema/annotations.d.ts.map +1 -0
- package/lib/schema/annotations.js +116 -0
- package/lib/schema/annotations.js.map +1 -0
- package/lib/schema/converter.d.ts +363 -0
- package/lib/schema/converter.d.ts.map +1 -0
- package/lib/schema/converter.js +1377 -0
- package/lib/schema/converter.js.map +1 -0
- package/lib/schema/jsdoc.d.ts +34 -0
- package/lib/schema/jsdoc.d.ts.map +1 -0
- package/lib/schema/jsdoc.js +206 -0
- package/lib/schema/jsdoc.js.map +1 -0
- package/lib/schema/model.d.ts +27 -0
- package/lib/schema/model.d.ts.map +1 -0
- package/lib/schema/model.js +86 -0
- package/lib/schema/model.js.map +1 -0
- package/lib/schema/project.d.ts +34 -0
- package/lib/schema/project.d.ts.map +1 -0
- package/lib/schema/project.js +305 -0
- package/lib/schema/project.js.map +1 -0
- package/lib/schema/service.d.ts +62 -0
- package/lib/schema/service.d.ts.map +1 -0
- package/lib/schema/service.js +130 -0
- package/lib/schema/service.js.map +1 -0
- package/lib/schema/types.d.ts +61 -0
- package/lib/schema/types.d.ts.map +1 -0
- package/lib/schema/types.js +36 -0
- package/lib/schema/types.js.map +1 -0
- package/lib/schema/worker-cli.d.ts +3 -0
- package/lib/schema/worker-cli.d.ts.map +1 -0
- package/lib/schema/worker-cli.js +7 -0
- package/lib/schema/worker-cli.js.map +1 -0
- package/lib/schema/worker.d.ts +81 -0
- package/lib/schema/worker.d.ts.map +1 -0
- package/lib/schema/worker.js +186 -0
- package/lib/schema/worker.js.map +1 -0
- package/lib/server.d.ts +2 -0
- package/lib/server.d.ts.map +1 -0
- package/lib/server.js +133 -0
- package/lib/server.js.map +1 -0
- package/lib/session.d.ts +84 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/session.js +141 -0
- package/lib/session.js.map +1 -0
- package/lib/spans.d.ts +46 -0
- package/lib/spans.d.ts.map +1 -0
- package/lib/spans.js +64 -0
- package/lib/spans.js.map +1 -0
- package/lib/twopass.d.ts +45 -0
- package/lib/twopass.d.ts.map +1 -0
- package/lib/twopass.js +87 -0
- package/lib/twopass.js.map +1 -0
- package/lib/vite.d.ts +20 -0
- package/lib/vite.d.ts.map +1 -0
- package/lib/vite.js +41 -0
- package/lib/vite.js.map +1 -0
- package/package.json +89 -3
- package/README.md +0 -3
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A purpose-built TypeScript type -> JSON Schema converter on the 7.1 checker.
|
|
3
|
+
*
|
|
4
|
+
* This is stage 7 of the TypeScript 7 migration. Two alternatives were
|
|
5
|
+
* rejected on evidence (see `docs/contribute/TypeScript 7 Content Mappers.md`,
|
|
6
|
+
* "Stage 7 plan"): porting `@webda/schema`'s 2,200 lines, which target the
|
|
7
|
+
* vega corpus and blacklist fixtures they cannot match; and adopting
|
|
8
|
+
* `ts-json-schema-generator-go`, which today emits no accessor-backed
|
|
9
|
+
* properties at all.
|
|
10
|
+
*
|
|
11
|
+
* What makes a purpose-built converter tractable is how narrow the committed
|
|
12
|
+
* corpus is: 4,154 schema nodes using twelve keywords, no `oneOf`, no
|
|
13
|
+
* conditionals. Webda service parameters and models are ordinary object
|
|
14
|
+
* shapes, not arbitrary TypeScript.
|
|
15
|
+
*
|
|
16
|
+
* **The converter throws on any type it cannot convert.** There is no
|
|
17
|
+
* best-effort path — see {@link SchemaConversionError}.
|
|
18
|
+
*
|
|
19
|
+
* ### Fidelity
|
|
20
|
+
*
|
|
21
|
+
* The target is byte-identical output against the committed
|
|
22
|
+
* `webda.module.json`, verified by `tools/schema-diff.mjs`. That makes this a
|
|
23
|
+
* behavioural reimplementation, not a redesign: where `@webda/schema` does
|
|
24
|
+
* something odd and the oddity reached the committed artefact, it is
|
|
25
|
+
* reproduced and commented rather than corrected. Changing any of it is a
|
|
26
|
+
* separate, visible commit.
|
|
27
|
+
*/
|
|
28
|
+
import { IndexKind, ObjectFlags, SignatureKind, SymbolFlags, TypeFlags } from "typescript/unstable/sync";
|
|
29
|
+
import { ModifierFlags, SyntaxKind } from "typescript/unstable/ast";
|
|
30
|
+
import * as is from "typescript/unstable/ast/is";
|
|
31
|
+
import { applyDocs } from "./annotations.js";
|
|
32
|
+
import { SchemaConversionError } from "./types.js";
|
|
33
|
+
/** JSON Schema dialect every generated document declares. */
|
|
34
|
+
const DRAFT_07 = "http://json-schema.org/draft-07/schema#";
|
|
35
|
+
const KEEP = { optional: false, decision: "keep" };
|
|
36
|
+
const SKIP = { optional: false, decision: "skip" };
|
|
37
|
+
/**
|
|
38
|
+
* Converts TypeScript types into JSON Schema draft-07 documents.
|
|
39
|
+
*
|
|
40
|
+
* One instance may serve many conversions; `definitions` are reset per
|
|
41
|
+
* document.
|
|
42
|
+
*/
|
|
43
|
+
export class SchemaConverter {
|
|
44
|
+
project;
|
|
45
|
+
checker;
|
|
46
|
+
maxDepth;
|
|
47
|
+
mode;
|
|
48
|
+
booleanDefaultFalse;
|
|
49
|
+
bufferStrategy;
|
|
50
|
+
/** Sub-schemas hoisted out of the current document. */
|
|
51
|
+
definitions = {};
|
|
52
|
+
/** Fallback resolution context when a property has no declaration. */
|
|
53
|
+
targetNode;
|
|
54
|
+
/**
|
|
55
|
+
* @param options - project, checker and conversion settings
|
|
56
|
+
*/
|
|
57
|
+
constructor(options) {
|
|
58
|
+
this.project = options.project;
|
|
59
|
+
this.checker = options.checker;
|
|
60
|
+
this.maxDepth = options.maxDepth ?? 10;
|
|
61
|
+
this.mode = options.mode ?? "input";
|
|
62
|
+
this.booleanDefaultFalse = !options.disableBooleanDefaultToFalse;
|
|
63
|
+
this.bufferStrategy = options.bufferStrategy ?? "base64";
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------- entry --
|
|
66
|
+
/**
|
|
67
|
+
* Convert the type at a node into a complete schema document.
|
|
68
|
+
*
|
|
69
|
+
* Mirrors `SchemaGenerator.getSchemaFromNodes` with a single node, which is
|
|
70
|
+
* how `@webda/compiler` always calls it.
|
|
71
|
+
* @param node - a declaration or type node
|
|
72
|
+
* @returns a self-contained draft-07 document
|
|
73
|
+
*/
|
|
74
|
+
fromNode(node) {
|
|
75
|
+
const type = this.checker.getTypeAtLocation(node);
|
|
76
|
+
return this.document(type, this.declaredName(node) ?? this.checker.typeToString(type), node);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Convert a type into a complete schema document.
|
|
80
|
+
*
|
|
81
|
+
* Mirrors `SchemaGenerator.getSchemaFromType`.
|
|
82
|
+
* @param type - the type to convert
|
|
83
|
+
* @param contextNode - resolution context for properties without declarations
|
|
84
|
+
* @returns a self-contained draft-07 document
|
|
85
|
+
*/
|
|
86
|
+
fromType(type, contextNode) {
|
|
87
|
+
return this.document(type, this.checker.typeToString(type), contextNode);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build one document: convert, hoist, inline the root, sort.
|
|
91
|
+
* @param type - the type to convert
|
|
92
|
+
* @param typeName - key the root definition is registered under
|
|
93
|
+
* @param node - resolution context
|
|
94
|
+
* @returns the finished document
|
|
95
|
+
*/
|
|
96
|
+
document(type, typeName, node) {
|
|
97
|
+
this.definitions = {};
|
|
98
|
+
this.targetNode = node;
|
|
99
|
+
const root = {};
|
|
100
|
+
this.property(type, root, "/", node, 0);
|
|
101
|
+
const result = { $schema: DRAFT_07, $ref: `#/definitions/${encodeURIComponent(typeName)}` };
|
|
102
|
+
result.definitions = { ...this.definitions };
|
|
103
|
+
result.definitions[typeName] ??= root;
|
|
104
|
+
this.inlineRootRef(result);
|
|
105
|
+
return sortKeys(result);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Follow the root `$ref` into the document body.
|
|
109
|
+
*
|
|
110
|
+
* A definition can itself be a bare `$ref` — an anonymous object registered
|
|
111
|
+
* under a union-branch path, for instance — so the chain is followed to the
|
|
112
|
+
* end and every consumed definition removed.
|
|
113
|
+
* @param result - the document to flatten, mutated in place
|
|
114
|
+
*/
|
|
115
|
+
inlineRootRef(result) {
|
|
116
|
+
while (result.$ref && result.definitions) {
|
|
117
|
+
const key = decodeURIComponent(result.$ref.replace("#/definitions/", ""));
|
|
118
|
+
const target = result.definitions[key];
|
|
119
|
+
if (!target)
|
|
120
|
+
break;
|
|
121
|
+
delete result.$ref;
|
|
122
|
+
delete result.definitions[key];
|
|
123
|
+
Object.assign(result, target);
|
|
124
|
+
}
|
|
125
|
+
if (result.definitions && Object.keys(result.definitions).length === 0)
|
|
126
|
+
delete result.definitions;
|
|
127
|
+
}
|
|
128
|
+
// ------------------------------------------------------------ dispatch --
|
|
129
|
+
/**
|
|
130
|
+
* Convert one type into `definition`, in place.
|
|
131
|
+
*
|
|
132
|
+
* The branch order matters and is taken from `@webda/schema`: literals
|
|
133
|
+
* before their widened forms, `Date` and `RegExp` before the generic
|
|
134
|
+
* class/interface handling, arrays after unions. The final `else` throws.
|
|
135
|
+
* @param type - the type to convert
|
|
136
|
+
* @param definition - schema to populate
|
|
137
|
+
* @param path - schema path, used for definition keys and error messages
|
|
138
|
+
* @param node - declaration the type came from, when there is one
|
|
139
|
+
* @param depth - recursion depth
|
|
140
|
+
* @returns whether the parent should keep the property, and its optionality
|
|
141
|
+
*/
|
|
142
|
+
property(type, definition, path, node, depth) {
|
|
143
|
+
if (depth > this.maxDepth)
|
|
144
|
+
return KEEP;
|
|
145
|
+
const typeName = this.checker.typeToString(type);
|
|
146
|
+
// JSDoc from three sources, nearest last so it wins: the type's own
|
|
147
|
+
// symbol, the alias it was reached through, and the declaration node.
|
|
148
|
+
applyDocs(definition, type.getSymbol(), this.checker, this.project);
|
|
149
|
+
const aliasSymbol = type.getAliasSymbol();
|
|
150
|
+
if (aliasSymbol)
|
|
151
|
+
applyDocs(definition, aliasSymbol, this.checker, this.project);
|
|
152
|
+
if (node)
|
|
153
|
+
applyDocs(definition, this.checker.getSymbolAtLocation(node), this.checker, this.project);
|
|
154
|
+
this.inheritAliasDocs(definition, node);
|
|
155
|
+
// Checked before the shape dispatch so it also covers array-typed
|
|
156
|
+
// attributes whose element class carries the marker — `Binaries<T>`
|
|
157
|
+
// extends `Array`, so the class branch below never sees it.
|
|
158
|
+
if (path !== "/" && this.mode === "dto-in") {
|
|
159
|
+
const context = node ?? this.targetNode;
|
|
160
|
+
if (this.hasReadOnlyTag(type))
|
|
161
|
+
return SKIP;
|
|
162
|
+
if (context && this.resolveFromDto(type, context) === "skip")
|
|
163
|
+
return SKIP;
|
|
164
|
+
}
|
|
165
|
+
const flags = type.flags;
|
|
166
|
+
if (flags & TypeFlags.StringLiteral) {
|
|
167
|
+
definition.type = "string";
|
|
168
|
+
definition.const = type.value;
|
|
169
|
+
return KEEP;
|
|
170
|
+
}
|
|
171
|
+
if (flags & TypeFlags.String) {
|
|
172
|
+
definition.type = "string";
|
|
173
|
+
const literal = literalOf(this.initializerFor(type, node));
|
|
174
|
+
if (literal?.type === "string")
|
|
175
|
+
definition.default = literal.const;
|
|
176
|
+
return KEEP;
|
|
177
|
+
}
|
|
178
|
+
// A template literal (`` `pre-${string}` ``) or a string mapping
|
|
179
|
+
// (`Uppercase<T>`) constrains a string in a way draft-07 cannot express
|
|
180
|
+
// without deriving a regex. `type: "string"` would accept values the type
|
|
181
|
+
// rejects, and `isArrayLike` below would otherwise class them as arrays —
|
|
182
|
+
// `string` carries a numeric index signature — which is worse still.
|
|
183
|
+
if (flags & (TypeFlags.TemplateLiteral | TypeFlags.StringMapping)) {
|
|
184
|
+
throw new SchemaConversionError("a template literal or mapped string type has no JSON Schema form; widen it to `string` or add a `@pattern`", path, typeName);
|
|
185
|
+
}
|
|
186
|
+
if (flags & TypeFlags.NumberLiteral) {
|
|
187
|
+
definition.type = "number";
|
|
188
|
+
definition.const = type.value;
|
|
189
|
+
return KEEP;
|
|
190
|
+
}
|
|
191
|
+
if (flags & TypeFlags.BigIntLiteral) {
|
|
192
|
+
definition.type = "number";
|
|
193
|
+
return KEEP;
|
|
194
|
+
}
|
|
195
|
+
// `Function` is matched by name because the checker models it as an
|
|
196
|
+
// interface, so the class/interface branch would otherwise emit an object.
|
|
197
|
+
if (typeName === "Function")
|
|
198
|
+
return SKIP;
|
|
199
|
+
if (flags & TypeFlags.NumberLike) {
|
|
200
|
+
definition.type = "number";
|
|
201
|
+
return KEEP;
|
|
202
|
+
}
|
|
203
|
+
if (flags & TypeFlags.BooleanLiteral) {
|
|
204
|
+
definition.type = "boolean";
|
|
205
|
+
// 7.1 carries the value on the literal type; 6.x only exposed
|
|
206
|
+
// `intrinsicName`, which is empty here and would read as `false`.
|
|
207
|
+
definition.const = type.value === true;
|
|
208
|
+
return KEEP;
|
|
209
|
+
}
|
|
210
|
+
if (flags & TypeFlags.BooleanLike) {
|
|
211
|
+
definition.type = "boolean";
|
|
212
|
+
return KEEP;
|
|
213
|
+
}
|
|
214
|
+
if (flags & TypeFlags.BigIntLike) {
|
|
215
|
+
definition.type = "integer";
|
|
216
|
+
return KEEP;
|
|
217
|
+
}
|
|
218
|
+
if (flags & TypeFlags.Null) {
|
|
219
|
+
definition.type = "null";
|
|
220
|
+
return KEEP;
|
|
221
|
+
}
|
|
222
|
+
if (flags & (TypeFlags.Undefined | TypeFlags.Void)) {
|
|
223
|
+
definition.not = {};
|
|
224
|
+
return { optional: true, decision: "keep" };
|
|
225
|
+
}
|
|
226
|
+
if (flags & TypeFlags.Never) {
|
|
227
|
+
definition.not = {};
|
|
228
|
+
return KEEP;
|
|
229
|
+
}
|
|
230
|
+
if (this.isBuffer(type, typeName)) {
|
|
231
|
+
this.applyBuffer(definition);
|
|
232
|
+
return KEEP;
|
|
233
|
+
}
|
|
234
|
+
if (typeName === "RegExp" || type.getSymbol()?.name === "RegExp") {
|
|
235
|
+
// draft-07 has no regex-object format; `format: "regex"` is the
|
|
236
|
+
// convention downstream consumers already read.
|
|
237
|
+
definition.type = "string";
|
|
238
|
+
definition.format = "regex";
|
|
239
|
+
return KEEP;
|
|
240
|
+
}
|
|
241
|
+
if (typeName === "Date" || type.getSymbol()?.name === "Date") {
|
|
242
|
+
definition.type = "string";
|
|
243
|
+
// lib.es5.d.ts documents the `Date` interface itself; that text is not a
|
|
244
|
+
// description of the property, so it is dropped again.
|
|
245
|
+
applyDocs(definition, type.getSymbol(), this.checker, this.project);
|
|
246
|
+
delete definition.description;
|
|
247
|
+
definition.format ??= "date-time";
|
|
248
|
+
return KEEP;
|
|
249
|
+
}
|
|
250
|
+
if (flags & (TypeFlags.Any | TypeFlags.Unknown)) {
|
|
251
|
+
// Deliberately unconstrained: `any` permits anything, and saying so is
|
|
252
|
+
// accurate rather than best-effort.
|
|
253
|
+
return KEEP;
|
|
254
|
+
}
|
|
255
|
+
if (type.isClassOrInterface())
|
|
256
|
+
return this.objectLike(type, definition, path, typeName, node, depth);
|
|
257
|
+
if (type.isUnionType())
|
|
258
|
+
return this.union(type, definition, path, node, depth);
|
|
259
|
+
if (type.isIntersectionType())
|
|
260
|
+
return this.intersection(type, definition, path, depth);
|
|
261
|
+
if (this.isArrayLike(type))
|
|
262
|
+
return this.array(type, definition, path, depth);
|
|
263
|
+
if (type.isTypeParameter()) {
|
|
264
|
+
// An unresolved type parameter constrains nothing at this point.
|
|
265
|
+
return KEEP;
|
|
266
|
+
}
|
|
267
|
+
const objectFlags = objectFlagsOf(type);
|
|
268
|
+
if (objectFlags & ObjectFlags.Reference)
|
|
269
|
+
return this.objectLike(type, definition, path, typeName, node, depth);
|
|
270
|
+
if (objectFlags & ObjectFlags.Anonymous) {
|
|
271
|
+
if (type.getCallSignatures().length > 0) {
|
|
272
|
+
// A callable is not serialisable. At the root the caller asked for a
|
|
273
|
+
// function's schema, which service parameters never do.
|
|
274
|
+
if (depth > 0)
|
|
275
|
+
return SKIP;
|
|
276
|
+
throw new SchemaConversionError("cannot convert a callable type at the schema root", path, typeName);
|
|
277
|
+
}
|
|
278
|
+
return this.objectLike(type, definition, path, typeName, node, depth);
|
|
279
|
+
}
|
|
280
|
+
if (objectFlags && (type.getSymbol()?.flags ?? 0) & SymbolFlags.TypeLiteral) {
|
|
281
|
+
return this.objectLike(type, definition, path, typeName, node, depth);
|
|
282
|
+
}
|
|
283
|
+
if (flags & TypeFlags.NonPrimitive) {
|
|
284
|
+
definition.type = "object";
|
|
285
|
+
return KEEP;
|
|
286
|
+
}
|
|
287
|
+
if (flags & (TypeFlags.ESSymbol | TypeFlags.UniqueESSymbol)) {
|
|
288
|
+
// Symbols have no JSON representation, so the property cannot exist in
|
|
289
|
+
// the schema at all.
|
|
290
|
+
return SKIP;
|
|
291
|
+
}
|
|
292
|
+
const apparent = this.checker.getApparentType(type);
|
|
293
|
+
if (apparent.id !== type.id)
|
|
294
|
+
return this.property(apparent, definition, path, node, depth + 1);
|
|
295
|
+
throw new SchemaConversionError("no JSON Schema representation for this type", path, typeName);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Merge annotations from a type alias tagged `@schemaInherits`.
|
|
299
|
+
*
|
|
300
|
+
* `type Email = string` with `@format email` on the alias only reaches the
|
|
301
|
+
* property when the alias opts in, otherwise every use of a documented alias
|
|
302
|
+
* would inherit its prose.
|
|
303
|
+
* @param definition - schema to enrich
|
|
304
|
+
* @param node - the property declaration
|
|
305
|
+
*/
|
|
306
|
+
inheritAliasDocs(definition, node) {
|
|
307
|
+
if (!node || !is.isPropertySignatureDeclaration(node))
|
|
308
|
+
return;
|
|
309
|
+
const typeNode = node.type;
|
|
310
|
+
if (!typeNode || !is.isTypeReferenceNode(typeNode))
|
|
311
|
+
return;
|
|
312
|
+
const aliasSymbol = this.checker.getSymbolAtLocation(typeNode.typeName);
|
|
313
|
+
if (!aliasSymbol)
|
|
314
|
+
return;
|
|
315
|
+
const inherited = {};
|
|
316
|
+
applyDocs(inherited, aliasSymbol, this.checker, this.project);
|
|
317
|
+
if (!inherited.schemaInherits)
|
|
318
|
+
return;
|
|
319
|
+
for (const [key, value] of Object.entries(inherited)) {
|
|
320
|
+
if (key === "schemaInherits")
|
|
321
|
+
continue;
|
|
322
|
+
definition[key] ??= value;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// -------------------------------------------------------------- objects --
|
|
326
|
+
/**
|
|
327
|
+
* Convert a class, interface, anonymous object or generic reference.
|
|
328
|
+
*
|
|
329
|
+
* The shape is registered in `definitions` and the caller's slot replaced
|
|
330
|
+
* with a `$ref`, except at the document root where an anonymous object is
|
|
331
|
+
* inlined instead — otherwise the root would sit behind a definition keyed
|
|
332
|
+
* by its own stringified shape.
|
|
333
|
+
* @param type - the object-like type
|
|
334
|
+
* @param definition - schema slot to populate
|
|
335
|
+
* @param path - schema path
|
|
336
|
+
* @param typeName - stringified type, used as the definition key
|
|
337
|
+
* @param node - declaration context
|
|
338
|
+
* @param depth - recursion depth
|
|
339
|
+
* @returns keep or skip
|
|
340
|
+
*/
|
|
341
|
+
objectLike(type, definition, path, typeName, node, depth) {
|
|
342
|
+
// A nested class states its own serialised form, and that beats its
|
|
343
|
+
// structural shape. Which method is consulted depends on the direction
|
|
344
|
+
// the schema describes.
|
|
345
|
+
if (path !== "/") {
|
|
346
|
+
const context = node ?? this.targetNode;
|
|
347
|
+
if (this.mode === "dto-in" && context) {
|
|
348
|
+
// A class tagged `@readOnly`, or one whose `fromDto` takes `never`,
|
|
349
|
+
// is never user-supplied — the attribute leaves the Input schema.
|
|
350
|
+
if (this.hasReadOnlyTag(type))
|
|
351
|
+
return SKIP;
|
|
352
|
+
const accepted = this.resolveFromDto(type, context);
|
|
353
|
+
if (accepted === "skip")
|
|
354
|
+
return SKIP;
|
|
355
|
+
if (accepted) {
|
|
356
|
+
this.property(accepted, definition, path, node, depth + 1);
|
|
357
|
+
return KEEP;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (this.mode === "dto-out" && context) {
|
|
361
|
+
const produced = this.methodReturnType(type, ["toDTO", "toDto"], context);
|
|
362
|
+
if (produced) {
|
|
363
|
+
if (produced.flags & (TypeFlags.Void | TypeFlags.Undefined | TypeFlags.Null | TypeFlags.Never))
|
|
364
|
+
return SKIP;
|
|
365
|
+
this.property(this.simplifyIntersection(produced), definition, path, node, depth + 1);
|
|
366
|
+
return KEEP;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const serialized = context ? this.methodReturnType(type, ["toJSON"], context) : undefined;
|
|
370
|
+
if (serialized) {
|
|
371
|
+
const flags = serialized.flags;
|
|
372
|
+
if (flags & (TypeFlags.Void | TypeFlags.Undefined | TypeFlags.Null | TypeFlags.Never))
|
|
373
|
+
return SKIP;
|
|
374
|
+
const returned = this.checker.typeToString(serialized);
|
|
375
|
+
// A `toJSON(): this` says nothing new; only a concrete return type is
|
|
376
|
+
// worth substituting for the class shape.
|
|
377
|
+
if (returned !== typeName && !(flags & TypeFlags.Any)) {
|
|
378
|
+
this.property(this.simplifyIntersection(serialized), definition, path, node, depth + 1);
|
|
379
|
+
return KEEP;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// For alias-based utility types (`Partial<T>`, `Pick<T, K>`) the apparent
|
|
384
|
+
// type is the one with enumerable properties.
|
|
385
|
+
const structural = this.structuralType(type);
|
|
386
|
+
const shape = this.objectShape(structural, path);
|
|
387
|
+
// Annotations already collected outranks the structural keywords.
|
|
388
|
+
const merged = { ...shape, ...definition };
|
|
389
|
+
if (path === "/" && (typeName.trim().startsWith("{") || isAnonymous(structural))) {
|
|
390
|
+
for (const key of Object.keys(definition))
|
|
391
|
+
delete definition[key];
|
|
392
|
+
Object.assign(definition, merged);
|
|
393
|
+
return KEEP;
|
|
394
|
+
}
|
|
395
|
+
this.registerDefinition(typeName, merged, definition, path);
|
|
396
|
+
return KEEP;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Prefer the apparent type when a type alias hides the concrete shape.
|
|
400
|
+
* @param type - the declared type
|
|
401
|
+
* @returns the type whose properties should be enumerated
|
|
402
|
+
*/
|
|
403
|
+
structuralType(type) {
|
|
404
|
+
if (!type.getAliasSymbol() && type.getAliasTypeArguments().length === 0)
|
|
405
|
+
return type;
|
|
406
|
+
const apparent = this.checker.getApparentType(type);
|
|
407
|
+
if (this.checker.typeToString(apparent) === this.checker.typeToString(type))
|
|
408
|
+
return type;
|
|
409
|
+
if (this.checker.getPropertiesOfType(apparent).length < this.checker.getPropertiesOfType(type).length)
|
|
410
|
+
return type;
|
|
411
|
+
return apparent;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Build the `type` / `properties` / `required` / `additionalProperties`
|
|
415
|
+
* portion of an object schema.
|
|
416
|
+
*
|
|
417
|
+
* Properties are read off the **type**, not the declaration's own members,
|
|
418
|
+
* so inherited attributes are included.
|
|
419
|
+
* @param type - the object type
|
|
420
|
+
* @param path - schema path of the object
|
|
421
|
+
* @returns the object schema
|
|
422
|
+
*/
|
|
423
|
+
objectShape(type, path) {
|
|
424
|
+
const schema = { type: "object" };
|
|
425
|
+
const depth = (path.match(/\//g) ?? []).length;
|
|
426
|
+
const indexType = this.checker.getIndexTypeOfType(type, IndexKind.String);
|
|
427
|
+
if (indexType) {
|
|
428
|
+
const indexSchema = {};
|
|
429
|
+
this.property(indexType, indexSchema, joinPath(path, "[key: string]"), undefined, depth + 1);
|
|
430
|
+
schema.additionalProperties = indexSchema;
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
schema.additionalProperties = false;
|
|
434
|
+
}
|
|
435
|
+
for (const prop of this.checker.getPropertiesOfType(type)) {
|
|
436
|
+
const declaration = this.declarationOf(prop);
|
|
437
|
+
if (this.isSymbolKeyed(prop, declaration))
|
|
438
|
+
continue;
|
|
439
|
+
const location = declaration ?? this.targetNode;
|
|
440
|
+
if (!location)
|
|
441
|
+
continue;
|
|
442
|
+
const modifiers = declaration ? modifierFlagsOf(declaration) : 0;
|
|
443
|
+
if (modifiers & (ModifierFlags.Private | ModifierFlags.Protected))
|
|
444
|
+
continue;
|
|
445
|
+
if (declaration && isMethodLike(declaration))
|
|
446
|
+
continue;
|
|
447
|
+
const propertyType = this.readableType(prop, declaration, location);
|
|
448
|
+
if (!propertyType)
|
|
449
|
+
continue;
|
|
450
|
+
const propSchema = {};
|
|
451
|
+
const propPath = joinPath(path, prop.name);
|
|
452
|
+
const outcome = this.property(propertyType, propSchema, propPath, location, depth + 1);
|
|
453
|
+
applyDocs(propSchema, prop, this.checker, this.project);
|
|
454
|
+
if (outcome.decision === "skip" || propSchema.SchemaIgnore === true)
|
|
455
|
+
continue;
|
|
456
|
+
// `@readOnly` on the property is equivalent to the modifier — and so is
|
|
457
|
+
// `@readonly`, which is the spelling most of the corpus actually uses.
|
|
458
|
+
// Only the first is a JSON Schema keyword; the second arrives through
|
|
459
|
+
// the generic tag handling, so both have to be checked.
|
|
460
|
+
const readonly = (modifiers & ModifierFlags.Readonly) !== 0 || propSchema.readOnly === true || propSchema.readonly === true;
|
|
461
|
+
if (readonly && this.isInputMode())
|
|
462
|
+
continue;
|
|
463
|
+
let optional = outcome.optional;
|
|
464
|
+
if (hasQuestionToken(declaration) || writesUndefined(declaration) || propSchema.SchemaOptional) {
|
|
465
|
+
optional = true;
|
|
466
|
+
}
|
|
467
|
+
// `@param` documents a callback's arguments, not the property, and is
|
|
468
|
+
// not a JSON Schema keyword. It reaches the schema through the generic
|
|
469
|
+
// tag handling and is dropped here, as `@webda/schema` does.
|
|
470
|
+
delete propSchema.param;
|
|
471
|
+
schema.properties ??= {};
|
|
472
|
+
schema.properties[prop.name] = propSchema;
|
|
473
|
+
const initializer = declaration && !is.isPropertyAssignment(declaration) ? initializerOf(declaration) : undefined;
|
|
474
|
+
if (initializer) {
|
|
475
|
+
// A field with an initializer need not be supplied.
|
|
476
|
+
optional = true;
|
|
477
|
+
const literal = literalOf(initializer);
|
|
478
|
+
if (literal)
|
|
479
|
+
propSchema.default = literal.const;
|
|
480
|
+
}
|
|
481
|
+
if (propSchema.type === "boolean" && this.booleanDefaultFalse && propSchema.default === undefined) {
|
|
482
|
+
propSchema.default = false;
|
|
483
|
+
}
|
|
484
|
+
const declaredOptional = (prop.flags & SymbolFlags.Optional) !== 0;
|
|
485
|
+
if (!optional && !declaredOptional && propSchema.default === undefined) {
|
|
486
|
+
schema.required ??= [];
|
|
487
|
+
schema.required.push(prop.name);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
schema.required?.sort();
|
|
491
|
+
return schema;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The type a property contributes to this schema, honouring accessors.
|
|
495
|
+
*
|
|
496
|
+
* For an input schema a getter-only property cannot be supplied at all; when
|
|
497
|
+
* a setter exists its parameter type is the accepted one, which is what
|
|
498
|
+
* makes the asymmetric accessors the content mapper generates meaningful.
|
|
499
|
+
* @param prop - the property symbol
|
|
500
|
+
* @param declaration - its primary declaration
|
|
501
|
+
* @param location - resolution context
|
|
502
|
+
* @returns the type to convert, or undefined to drop the property
|
|
503
|
+
*/
|
|
504
|
+
readableType(prop, declaration, location) {
|
|
505
|
+
if (declaration && is.isGetAccessorDeclaration(declaration) && this.isInputMode()) {
|
|
506
|
+
const setter = prop.declarations
|
|
507
|
+
.map(handle => handle.resolve(this.project))
|
|
508
|
+
.find(node => node && is.isSetAccessorDeclaration(node));
|
|
509
|
+
if (!setter)
|
|
510
|
+
return undefined;
|
|
511
|
+
const parameter = setter.parameters[0];
|
|
512
|
+
if (!parameter)
|
|
513
|
+
return undefined;
|
|
514
|
+
return this.checker.getTypeAtLocation(parameter);
|
|
515
|
+
}
|
|
516
|
+
return this.checker.getTypeOfSymbolAtLocation(prop, location);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Register a schema under a definition key and rewrite the slot as a `$ref`.
|
|
520
|
+
*
|
|
521
|
+
* Inline object types stringify to their whole shape, which makes a useless
|
|
522
|
+
* key, so those fall back to the schema path.
|
|
523
|
+
* @param rawKey - stringified type
|
|
524
|
+
* @param source - the schema to store
|
|
525
|
+
* @param slot - the caller's schema object, emptied and replaced by a `$ref`
|
|
526
|
+
* @param path - schema path, used when the key is unusable
|
|
527
|
+
*/
|
|
528
|
+
registerDefinition(rawKey, source, slot, path) {
|
|
529
|
+
const key = rawKey.startsWith("{") || rawKey.endsWith('">') ? path.substring(1).replace(/\//g, "$") : rawKey;
|
|
530
|
+
this.definitions[key] = { ...source };
|
|
531
|
+
for (const existing of Object.keys(slot))
|
|
532
|
+
delete slot[existing];
|
|
533
|
+
slot.$ref = `#/definitions/${encodeURIComponent(key)}`;
|
|
534
|
+
}
|
|
535
|
+
// ------------------------------------------------------------ dto modes --
|
|
536
|
+
/**
|
|
537
|
+
* The return type of the first of `methodNames` the type declares.
|
|
538
|
+
*
|
|
539
|
+
* The apparent type is consulted first so a generic instantiation carries
|
|
540
|
+
* its type arguments into the signature.
|
|
541
|
+
* @param type - the type to inspect
|
|
542
|
+
* @param methodNames - names to try, in order
|
|
543
|
+
* @param location - resolution context
|
|
544
|
+
* @returns the return type, or undefined when no method matches
|
|
545
|
+
*/
|
|
546
|
+
methodReturnType(type, methodNames, location) {
|
|
547
|
+
const apparent = this.checker.getApparentType(type);
|
|
548
|
+
const parentArguments = apparent.isTypeReference() ? this.checker.getTypeArguments(apparent) : [];
|
|
549
|
+
for (const name of methodNames) {
|
|
550
|
+
const symbol = apparent.getProperty(name) ?? type.getProperty(name);
|
|
551
|
+
if (!symbol)
|
|
552
|
+
continue;
|
|
553
|
+
const methodType = this.checker.getTypeOfPropertyOfType(apparent, name) ??
|
|
554
|
+
this.checker.getTypeOfSymbolAtLocation(symbol, location);
|
|
555
|
+
const signatures = this.checker.getSignaturesOfType(methodType, SignatureKind.Call);
|
|
556
|
+
if (signatures.length === 0)
|
|
557
|
+
continue;
|
|
558
|
+
const returned = this.checker.getReturnTypeOfSignature(signatures[0]);
|
|
559
|
+
// Depending on traversal order the method type can keep the parent's
|
|
560
|
+
// own type parameter (`BinaryFileInfo<T>`) rather than the substituted
|
|
561
|
+
// argument. There is no usable return type then, so fall back to the
|
|
562
|
+
// parent's structural shape instead of emitting a schema for `T`.
|
|
563
|
+
if (parentArguments.length > 0 && this.mentionsTypeParameter(returned))
|
|
564
|
+
return undefined;
|
|
565
|
+
return returned;
|
|
566
|
+
}
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Whether a type still contains an unsubstituted type parameter.
|
|
571
|
+
* @param type - the type to inspect
|
|
572
|
+
* @param seen - types already visited, guarding cycles
|
|
573
|
+
* @returns true when a type parameter is reachable
|
|
574
|
+
*/
|
|
575
|
+
mentionsTypeParameter(type, seen = new Set()) {
|
|
576
|
+
if (seen.has(type.id))
|
|
577
|
+
return false;
|
|
578
|
+
seen.add(type.id);
|
|
579
|
+
if (type.flags & TypeFlags.TypeParameter)
|
|
580
|
+
return true;
|
|
581
|
+
if (type.isTypeReference()) {
|
|
582
|
+
if (this.checker.getTypeArguments(type).some(argument => this.mentionsTypeParameter(argument, seen)))
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
if (type.flags & (TypeFlags.Union | TypeFlags.Intersection)) {
|
|
586
|
+
return type.getTypes().some(member => this.mentionsTypeParameter(member, seen));
|
|
587
|
+
}
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* The value a class accepts through `fromDto`, for the Input schema.
|
|
592
|
+
*
|
|
593
|
+
* Static and instance declarations both count, because `fromDto` is
|
|
594
|
+
* normally static. A first parameter of `never` is the explicit way to say
|
|
595
|
+
* "this attribute is never supplied".
|
|
596
|
+
* @param type - the type to inspect
|
|
597
|
+
* @param location - resolution context
|
|
598
|
+
* @returns the accepted type, `"skip"`, or undefined when absent
|
|
599
|
+
*/
|
|
600
|
+
resolveFromDto(type, location) {
|
|
601
|
+
for (const name of ["fromDTO", "fromDto"]) {
|
|
602
|
+
let symbol = type.getProperty(name);
|
|
603
|
+
if (!symbol) {
|
|
604
|
+
const classSymbol = type.getSymbol();
|
|
605
|
+
const declaration = this.declarationOf(classSymbol);
|
|
606
|
+
if (classSymbol && declaration) {
|
|
607
|
+
symbol = this.checker.getTypeOfSymbolAtLocation(classSymbol, declaration).getProperty(name);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (!symbol)
|
|
611
|
+
continue;
|
|
612
|
+
const methodType = this.checker.getTypeOfSymbolAtLocation(symbol, location);
|
|
613
|
+
const signatures = this.checker.getSignaturesOfType(methodType, SignatureKind.Call);
|
|
614
|
+
if (signatures.length === 0)
|
|
615
|
+
continue;
|
|
616
|
+
const parameters = signatures[0].getParameters();
|
|
617
|
+
if (parameters.length === 0)
|
|
618
|
+
continue;
|
|
619
|
+
const parameterDeclaration = this.declarationOf(parameters[0]);
|
|
620
|
+
const parameterType = parameterDeclaration
|
|
621
|
+
? this.checker.getTypeAtLocation(parameterDeclaration)
|
|
622
|
+
: this.checker.getParameterType(signatures[0], 0);
|
|
623
|
+
return parameterType.flags & TypeFlags.Never ? "skip" : parameterType;
|
|
624
|
+
}
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Whether a class is tagged `@readOnly`, opting its attributes out of Input.
|
|
629
|
+
* @param type - the type to inspect
|
|
630
|
+
* @returns true when the tag is present
|
|
631
|
+
*/
|
|
632
|
+
hasReadOnlyTag(type) {
|
|
633
|
+
for (const symbol of [type.getSymbol(), type.getAliasSymbol()]) {
|
|
634
|
+
if (!symbol)
|
|
635
|
+
continue;
|
|
636
|
+
if (symbol.getJsDocTags(this.checker).some(tag => tag.name === "readOnly"))
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Reduce an intersection to the constituent that carries the data.
|
|
643
|
+
*
|
|
644
|
+
* `string & { __brand: x }` is a string, and `T & { toString(): string }`
|
|
645
|
+
* is `T` — an `allOf` of the parts would be technically true and useless.
|
|
646
|
+
* @param type - the type to simplify
|
|
647
|
+
* @returns the simplified type, or the original
|
|
648
|
+
*/
|
|
649
|
+
simplifyIntersection(type) {
|
|
650
|
+
if (!type.isIntersectionType())
|
|
651
|
+
return type;
|
|
652
|
+
const members = type.getTypes();
|
|
653
|
+
const primitives = TypeFlags.String |
|
|
654
|
+
TypeFlags.Number |
|
|
655
|
+
TypeFlags.Boolean |
|
|
656
|
+
TypeFlags.StringLiteral |
|
|
657
|
+
TypeFlags.NumberLiteral |
|
|
658
|
+
TypeFlags.BooleanLiteral;
|
|
659
|
+
const primitive = members.find(member => member.flags & primitives);
|
|
660
|
+
if (primitive)
|
|
661
|
+
return primitive;
|
|
662
|
+
const withData = members.filter(member => this.checker.getPropertiesOfType(member).some(property => {
|
|
663
|
+
const declaration = this.declarationOf(property);
|
|
664
|
+
return !declaration || !isMethodLike(declaration);
|
|
665
|
+
}));
|
|
666
|
+
return withData.length === 1 ? withData[0] : type;
|
|
667
|
+
}
|
|
668
|
+
// --------------------------------------------------------------- unions --
|
|
669
|
+
/**
|
|
670
|
+
* Union constituents in the order `webda.module.json` records them.
|
|
671
|
+
*
|
|
672
|
+
* `UnionType.getTypes()` returns them alphabetically, so without this every
|
|
673
|
+
* `enum` and every `type: [..]` in the corpus is reordered.
|
|
674
|
+
*
|
|
675
|
+
* TypeScript 6 exposed a union's constituents sorted by type id, and ids
|
|
676
|
+
* are issued as types are created. That has two regimes, and reproducing
|
|
677
|
+
* both is what makes this correct rather than lucky:
|
|
678
|
+
*
|
|
679
|
+
* - **Primitives** are interned when the checker starts, in a fixed order,
|
|
680
|
+
* so they always sort ahead of everything else and among themselves by
|
|
681
|
+
* that order. `number | string` is recorded as `["string", "number"]`.
|
|
682
|
+
* - **Literals** are interned on first use, so their ids follow the source.
|
|
683
|
+
*
|
|
684
|
+
* Sorting on 7.1's ids reproduces neither reliably: they are assigned as
|
|
685
|
+
* the session happens to query, so `"SUCCESS" | "ERROR"` came back reversed
|
|
686
|
+
* whenever something earlier in the batch had already interned `"ERROR"` —
|
|
687
|
+
* the score depended on which requests shared a worker. Primitives are
|
|
688
|
+
* ranked from the table instead, and literals from the syntax.
|
|
689
|
+
* @param type - the union type
|
|
690
|
+
* @param node - the declaration the union was reached through
|
|
691
|
+
* @returns the constituents, in the recorded order
|
|
692
|
+
*/
|
|
693
|
+
unionOrder(type, node) {
|
|
694
|
+
const members = [...type.getTypes()];
|
|
695
|
+
const declared = this.declaredUnionOrder(type, node);
|
|
696
|
+
const idRank = new Map([...members].sort((left, right) => left.id - right.id).map((member, index) => [member.id, index]));
|
|
697
|
+
const rankOf = (member) => {
|
|
698
|
+
const primitive = primitiveRank(member);
|
|
699
|
+
if (primitive !== undefined)
|
|
700
|
+
return primitive;
|
|
701
|
+
const position = declared?.indexOf(member.id) ?? -1;
|
|
702
|
+
// Anything the syntax does not mention — a synthesised constituent with
|
|
703
|
+
// no declaration order to recover — falls back to id order, after
|
|
704
|
+
// everything it does mention.
|
|
705
|
+
return PRIMITIVE_RANKS + (position >= 0 ? position : PRIMITIVE_RANKS + idRank.get(member.id));
|
|
706
|
+
};
|
|
707
|
+
return members
|
|
708
|
+
.map((member, index) => ({ member, index }))
|
|
709
|
+
.sort((left, right) => rankOf(left.member) - rankOf(right.member) || left.index - right.index)
|
|
710
|
+
.map(entry => entry.member);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Type ids of a union's constituents, in the order the syntax lists them.
|
|
714
|
+
* @param type - the union type
|
|
715
|
+
* @param node - the declaration the union was reached through
|
|
716
|
+
* @returns the ids, or undefined when there is no syntax to read
|
|
717
|
+
*/
|
|
718
|
+
declaredUnionOrder(type, node) {
|
|
719
|
+
const enumDeclaration = this.declarationOf(type.getSymbol());
|
|
720
|
+
if (enumDeclaration && is.isEnumDeclaration(enumDeclaration)) {
|
|
721
|
+
return enumDeclaration.members.map(member => this.checker.getTypeAtLocation(member).id);
|
|
722
|
+
}
|
|
723
|
+
const typeNode = this.unionTypeNode(type, node);
|
|
724
|
+
if (!typeNode)
|
|
725
|
+
return undefined;
|
|
726
|
+
const ids = [];
|
|
727
|
+
const collect = (union) => {
|
|
728
|
+
for (const member of union.types) {
|
|
729
|
+
const resolved = this.checker.getTypeFromTypeNode(member);
|
|
730
|
+
// A member may itself be a union — TypeScript flattens those, so the
|
|
731
|
+
// nested constituents have to be expanded to line up with `getTypes`.
|
|
732
|
+
if (resolved.isUnionType()) {
|
|
733
|
+
for (const nested of this.unionOrder(resolved, undefined))
|
|
734
|
+
ids.push(nested.id);
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
ids.push(resolved.id);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
collect(typeNode);
|
|
742
|
+
return ids;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* The `UnionTypeNode` a union type was written as, if there is one.
|
|
746
|
+
* @param type - the union type
|
|
747
|
+
* @param node - the declaration the union was reached through
|
|
748
|
+
* @returns the syntax node
|
|
749
|
+
*/
|
|
750
|
+
unionTypeNode(type, node) {
|
|
751
|
+
const aliasDeclaration = this.declarationOf(type.getAliasSymbol());
|
|
752
|
+
if (aliasDeclaration && is.isTypeAliasDeclaration(aliasDeclaration) && is.isUnionTypeNode(aliasDeclaration.type)) {
|
|
753
|
+
return aliasDeclaration.type;
|
|
754
|
+
}
|
|
755
|
+
const declared = node && node.type;
|
|
756
|
+
if (declared && is.isUnionTypeNode(declared))
|
|
757
|
+
return declared;
|
|
758
|
+
return undefined;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Convert a union into `anyOf`, then simplify.
|
|
762
|
+
*
|
|
763
|
+
* The simplifications are what turn most unions in the corpus into the
|
|
764
|
+
* `enum` and `type: [..]` forms that appear in `webda.module.json`, so they
|
|
765
|
+
* are part of the output contract rather than cosmetic.
|
|
766
|
+
* @param type - the union type
|
|
767
|
+
* @param definition - schema to populate
|
|
768
|
+
* @param path - schema path
|
|
769
|
+
* @param node - the declaration the union was reached through
|
|
770
|
+
* @param depth - recursion depth
|
|
771
|
+
* @returns keep, and whether `undefined` was a member
|
|
772
|
+
*/
|
|
773
|
+
union(type, definition, path, node, depth) {
|
|
774
|
+
const branches = [];
|
|
775
|
+
let optional = false;
|
|
776
|
+
const unionDeclaration = this.declarationOf(type.getSymbol());
|
|
777
|
+
const isEnum = unionDeclaration ? is.isEnumDeclaration(unionDeclaration) : false;
|
|
778
|
+
// -1 before the first member, -2 once a non-numeric member breaks the run.
|
|
779
|
+
let lastValue = -1;
|
|
780
|
+
let index = 0;
|
|
781
|
+
for (const member of this.unionOrder(type, node)) {
|
|
782
|
+
index++;
|
|
783
|
+
if (member.flags & TypeFlags.Undefined) {
|
|
784
|
+
optional = true;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
const branch = {};
|
|
788
|
+
const memberDeclaration = this.declarationOf(member.getSymbol()) ?? this.targetNode;
|
|
789
|
+
const outcome = this.property(member, branch, `${path}#${index}`, memberDeclaration, depth + 1);
|
|
790
|
+
if (outcome.decision === "skip")
|
|
791
|
+
continue;
|
|
792
|
+
const initializer = memberDeclaration ? initializerOf(memberDeclaration) : undefined;
|
|
793
|
+
if (initializer) {
|
|
794
|
+
const constant = this.checker.getConstantValue(memberDeclaration);
|
|
795
|
+
if (constant !== undefined) {
|
|
796
|
+
branch.const = constant;
|
|
797
|
+
if (isEnum)
|
|
798
|
+
lastValue = typeof constant === "number" ? constant : -2;
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
if (isEnum)
|
|
802
|
+
lastValue = -2;
|
|
803
|
+
const literal = literalOf(initializer);
|
|
804
|
+
if (literal) {
|
|
805
|
+
branch.const = literal.const;
|
|
806
|
+
branch.type = literal.type;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
else if (isEnum && lastValue !== -2) {
|
|
811
|
+
// Implicit enum members continue the numeric run.
|
|
812
|
+
lastValue += 1;
|
|
813
|
+
branch.const = lastValue;
|
|
814
|
+
}
|
|
815
|
+
branches.push(branch);
|
|
816
|
+
}
|
|
817
|
+
definition.anyOf = dedupe(branches);
|
|
818
|
+
for (const branch of definition.anyOf) {
|
|
819
|
+
if (branch.type === "null")
|
|
820
|
+
branch.const = null;
|
|
821
|
+
}
|
|
822
|
+
this.simplifyUnion(definition);
|
|
823
|
+
return { optional, decision: "keep" };
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Collapse an `anyOf` into the narrowest equivalent form.
|
|
827
|
+
* @param definition - schema carrying `anyOf`, mutated in place
|
|
828
|
+
*/
|
|
829
|
+
simplifyUnion(definition) {
|
|
830
|
+
let branches = definition.anyOf;
|
|
831
|
+
if (branches.length === 0) {
|
|
832
|
+
delete definition.anyOf;
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (branches.length === 1) {
|
|
836
|
+
delete definition.anyOf;
|
|
837
|
+
Object.assign(definition, branches[0]);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
// All constants: an `enum` says the same thing and is what consumers read.
|
|
841
|
+
if (branches.every(branch => branch.const !== undefined)) {
|
|
842
|
+
delete definition.anyOf;
|
|
843
|
+
definition.enum = Array.from(new Set(branches.map(branch => branch.const)));
|
|
844
|
+
const types = [...new Set(branches.map(branch => branch.type))];
|
|
845
|
+
definition.type = (types.length === 1 ? types[0] : types);
|
|
846
|
+
// `true | false` is just `boolean`; the enum adds nothing.
|
|
847
|
+
if (definition.enum.length === 2 && definition.type === "boolean")
|
|
848
|
+
delete definition.enum;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (branches.every(isBareType)) {
|
|
852
|
+
delete definition.anyOf;
|
|
853
|
+
definition.type = branches.map(branch => branch.type);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
// `true | false` reached here alongside other branches.
|
|
857
|
+
const hasTrue = branches.some(branch => branch.type === "boolean" && branch.const === true);
|
|
858
|
+
const hasFalse = branches.some(branch => branch.type === "boolean" && branch.const === false);
|
|
859
|
+
if (hasTrue && hasFalse) {
|
|
860
|
+
branches = branches.filter(branch => branch.type !== "boolean");
|
|
861
|
+
if (branches.length === 0) {
|
|
862
|
+
delete definition.anyOf;
|
|
863
|
+
definition.type = "boolean";
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
branches.push({ type: "boolean" });
|
|
867
|
+
definition.anyOf = branches;
|
|
868
|
+
}
|
|
869
|
+
if (definition.anyOf.every(isBareType)) {
|
|
870
|
+
definition.type = definition.anyOf.map(branch => branch.type);
|
|
871
|
+
delete definition.anyOf;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// -------------------------------------------------------- intersections --
|
|
875
|
+
/**
|
|
876
|
+
* Convert an intersection by merging object constituents.
|
|
877
|
+
*
|
|
878
|
+
* A merge is only sound when every constituent is an object; anything else
|
|
879
|
+
* falls back to `allOf`, which states the constraint without pretending to
|
|
880
|
+
* flatten it.
|
|
881
|
+
* @param type - the intersection type
|
|
882
|
+
* @param definition - schema to populate
|
|
883
|
+
* @param path - schema path
|
|
884
|
+
* @param depth - recursion depth
|
|
885
|
+
* @returns keep
|
|
886
|
+
*/
|
|
887
|
+
intersection(type, definition, path, depth) {
|
|
888
|
+
const properties = {};
|
|
889
|
+
const required = new Set();
|
|
890
|
+
const parts = [];
|
|
891
|
+
let closed = true;
|
|
892
|
+
let mergeable = true;
|
|
893
|
+
let index = 0;
|
|
894
|
+
for (const member of type.getTypes()) {
|
|
895
|
+
index++;
|
|
896
|
+
const partPath = `${path}&${index}`;
|
|
897
|
+
const part = {};
|
|
898
|
+
if (member.isClassOrInterface() || this.checker.getPropertiesOfType(member).length > 0) {
|
|
899
|
+
Object.assign(part, this.objectShape(member, partPath));
|
|
900
|
+
if (part.additionalProperties !== false)
|
|
901
|
+
closed = false;
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
const memberNode = this.declarationOf(member.getSymbol()) ?? this.targetNode;
|
|
905
|
+
if (this.property(member, part, partPath, memberNode, depth + 1).decision === "skip")
|
|
906
|
+
continue;
|
|
907
|
+
mergeable = false;
|
|
908
|
+
}
|
|
909
|
+
parts.push(part);
|
|
910
|
+
if (part.type === "object" && part.properties) {
|
|
911
|
+
for (const [name, schema] of Object.entries(part.properties))
|
|
912
|
+
properties[name] ??= schema;
|
|
913
|
+
for (const name of part.required ?? [])
|
|
914
|
+
required.add(name);
|
|
915
|
+
if (part.additionalProperties !== false)
|
|
916
|
+
closed = false;
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
mergeable = false;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (mergeable) {
|
|
923
|
+
definition.type = "object";
|
|
924
|
+
definition.properties = properties;
|
|
925
|
+
if (required.size > 0)
|
|
926
|
+
definition.required = [...required].sort();
|
|
927
|
+
definition.additionalProperties = closed ? false : undefined;
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
definition.allOf = parts;
|
|
931
|
+
}
|
|
932
|
+
return KEEP;
|
|
933
|
+
}
|
|
934
|
+
// --------------------------------------------------------------- arrays --
|
|
935
|
+
/**
|
|
936
|
+
* Convert an array or tuple type.
|
|
937
|
+
* @param type - the array-like type
|
|
938
|
+
* @param definition - schema to populate
|
|
939
|
+
* @param path - schema path
|
|
940
|
+
* @param depth - recursion depth
|
|
941
|
+
* @returns keep
|
|
942
|
+
*/
|
|
943
|
+
array(type, definition, path, depth) {
|
|
944
|
+
definition.type = "array";
|
|
945
|
+
if (this.checker.isTupleType(type))
|
|
946
|
+
return this.tuple(type, definition, path, depth);
|
|
947
|
+
const element = this.elementType(type);
|
|
948
|
+
if (!element) {
|
|
949
|
+
throw new SchemaConversionError("array element type could not be resolved", path, this.checker.typeToString(type));
|
|
950
|
+
}
|
|
951
|
+
const items = {};
|
|
952
|
+
definition.items = items;
|
|
953
|
+
const elementNode = this.declarationOf(element.getSymbol()) ?? this.targetNode;
|
|
954
|
+
if (this.property(element, items, `${path}[]`, elementNode, depth + 1).decision === "skip") {
|
|
955
|
+
delete definition.items;
|
|
956
|
+
}
|
|
957
|
+
return KEEP;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Convert a tuple into positional or homogeneous `items`.
|
|
961
|
+
* @param type - the tuple reference
|
|
962
|
+
* @param definition - schema to populate
|
|
963
|
+
* @param path - schema path
|
|
964
|
+
* @param depth - recursion depth
|
|
965
|
+
* @returns keep
|
|
966
|
+
*/
|
|
967
|
+
tuple(type, definition, path, depth) {
|
|
968
|
+
const target = type.getTarget();
|
|
969
|
+
const elementFlags = target.elementFlags;
|
|
970
|
+
// ElementFlags.Rest === 1 << 2.
|
|
971
|
+
const openEnded = elementFlags.length > 0 && (elementFlags[elementFlags.length - 1] & 4) !== 0;
|
|
972
|
+
const elements = this.checker.getTypeArguments(type);
|
|
973
|
+
const items = [];
|
|
974
|
+
definition.minItems = elements.length;
|
|
975
|
+
elements.forEach((element, index) => {
|
|
976
|
+
const item = {};
|
|
977
|
+
const elementNode = this.declarationOf(element.getSymbol()) ?? this.targetNode;
|
|
978
|
+
if (this.property(element, item, `${path}[${index}]`, elementNode, depth + 1).decision === "skip") {
|
|
979
|
+
definition.minItems--;
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
// ElementFlags.Optional === 1 << 1.
|
|
983
|
+
if ((elementFlags[index] & 2) !== 0)
|
|
984
|
+
definition.minItems--;
|
|
985
|
+
items.push(item);
|
|
986
|
+
});
|
|
987
|
+
definition.items = isHomogeneous(items) ? items[0] : items;
|
|
988
|
+
if (openEnded)
|
|
989
|
+
definition.minItems--;
|
|
990
|
+
else
|
|
991
|
+
definition.maxItems = elements.length;
|
|
992
|
+
if (target.readonly) {
|
|
993
|
+
// A readonly tuple of literals is a single constant value.
|
|
994
|
+
const constants = items.map(item => item.const).filter(value => value !== undefined);
|
|
995
|
+
if (constants.length === items.length)
|
|
996
|
+
definition.const = constants;
|
|
997
|
+
delete definition.items;
|
|
998
|
+
delete definition.minItems;
|
|
999
|
+
delete definition.maxItems;
|
|
1000
|
+
}
|
|
1001
|
+
return KEEP;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Whether the type should be represented as a JSON array.
|
|
1005
|
+
*
|
|
1006
|
+
* Mapped types are excluded explicitly: the checker synthesises a numeric
|
|
1007
|
+
* index signature for some of them, which would otherwise read as an array.
|
|
1008
|
+
* @param type - the type to test
|
|
1009
|
+
* @returns true when array-like
|
|
1010
|
+
*/
|
|
1011
|
+
isArrayLike(type) {
|
|
1012
|
+
if (objectFlagsOf(type) & ObjectFlags.Mapped)
|
|
1013
|
+
return false;
|
|
1014
|
+
if (this.checker.isArrayLikeType(type))
|
|
1015
|
+
return true;
|
|
1016
|
+
if (this.checker.getIndexTypeOfType(type, IndexKind.Number))
|
|
1017
|
+
return true;
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* The element type of an array-like type.
|
|
1022
|
+
* @param type - the array-like type
|
|
1023
|
+
* @returns the element type, or undefined when it cannot be resolved
|
|
1024
|
+
*/
|
|
1025
|
+
elementType(type) {
|
|
1026
|
+
const byIndex = this.checker.getIndexTypeOfType(type, IndexKind.Number);
|
|
1027
|
+
if (byIndex)
|
|
1028
|
+
return byIndex;
|
|
1029
|
+
if (type.isTypeReference()) {
|
|
1030
|
+
const args = this.checker.getTypeArguments(type);
|
|
1031
|
+
if (args.length > 0)
|
|
1032
|
+
return args[0];
|
|
1033
|
+
}
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
// -------------------------------------------------------------- buffers --
|
|
1037
|
+
/**
|
|
1038
|
+
* Whether the type is a binary buffer.
|
|
1039
|
+
* @param type - the type to test
|
|
1040
|
+
* @param typeName - its stringified form
|
|
1041
|
+
* @returns true for `Buffer`, `ArrayBuffer` or a `Buffer`-flavoured `Uint8Array`
|
|
1042
|
+
*/
|
|
1043
|
+
isBuffer(type, typeName) {
|
|
1044
|
+
const name = type.getSymbol()?.name;
|
|
1045
|
+
if (!name)
|
|
1046
|
+
return false;
|
|
1047
|
+
if (name === "Buffer" || typeName === "Buffer")
|
|
1048
|
+
return true;
|
|
1049
|
+
if (name === "ArrayBuffer" || typeName === "ArrayBuffer")
|
|
1050
|
+
return true;
|
|
1051
|
+
return name === "Uint8Array" && /Buffer/.test(typeName);
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Write the configured buffer representation.
|
|
1055
|
+
* @param definition - schema to populate
|
|
1056
|
+
*/
|
|
1057
|
+
applyBuffer(definition) {
|
|
1058
|
+
switch (this.bufferStrategy) {
|
|
1059
|
+
case "array":
|
|
1060
|
+
definition.type = "array";
|
|
1061
|
+
definition.items = { type: "integer", minimum: 0, maximum: 255 };
|
|
1062
|
+
return;
|
|
1063
|
+
case "binary":
|
|
1064
|
+
definition.type = "string";
|
|
1065
|
+
definition.format = "binary";
|
|
1066
|
+
definition.contentMediaType = "application/octet-stream";
|
|
1067
|
+
return;
|
|
1068
|
+
case "hex":
|
|
1069
|
+
definition.type = "string";
|
|
1070
|
+
definition.contentEncoding = "hex";
|
|
1071
|
+
definition.pattern = "^[0-9a-fA-F]+$";
|
|
1072
|
+
definition.contentMediaType = "application/octet-stream";
|
|
1073
|
+
return;
|
|
1074
|
+
case "base64":
|
|
1075
|
+
definition.type = "string";
|
|
1076
|
+
definition.contentEncoding = "base64";
|
|
1077
|
+
definition.contentMediaType = "application/octet-stream";
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// --------------------------------------------------------------- shared --
|
|
1082
|
+
/**
|
|
1083
|
+
* Whether the current mode describes values being supplied.
|
|
1084
|
+
* @returns true for `input` and `dto-in`
|
|
1085
|
+
*/
|
|
1086
|
+
isInputMode() {
|
|
1087
|
+
return this.mode === "input" || this.mode === "dto-in";
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Resolve a symbol's primary declaration node.
|
|
1091
|
+
*
|
|
1092
|
+
* `Symbol.declarations` are handles, not nodes; reaching the AST needs an
|
|
1093
|
+
* explicit `resolve` against the project.
|
|
1094
|
+
* @param symbol - the symbol
|
|
1095
|
+
* @returns the declaration, when it has one
|
|
1096
|
+
*/
|
|
1097
|
+
declarationOf(symbol) {
|
|
1098
|
+
if (!symbol)
|
|
1099
|
+
return undefined;
|
|
1100
|
+
return symbol.valueDeclaration?.resolve(this.project) ?? symbol.declarations[0]?.resolve(this.project);
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Whether a property is keyed by an ES symbol.
|
|
1104
|
+
*
|
|
1105
|
+
* Symbol-keyed slots are how Webda stores model internals
|
|
1106
|
+
* (`[WEBDA_STORAGE]`, `[WEBDA_EVENTS]`), and none of them are JSON.
|
|
1107
|
+
* @param prop - the property symbol
|
|
1108
|
+
* @param declaration - its declaration
|
|
1109
|
+
* @returns true when the key is an ES symbol
|
|
1110
|
+
*/
|
|
1111
|
+
isSymbolKeyed(prop, declaration) {
|
|
1112
|
+
if (declaration && hasName(declaration)) {
|
|
1113
|
+
const name = declaration.name;
|
|
1114
|
+
if (name && is.isComputedPropertyName(name)) {
|
|
1115
|
+
const keyFlags = this.checker.getTypeAtLocation(name.expression).flags;
|
|
1116
|
+
if (keyFlags & (TypeFlags.ESSymbol | TypeFlags.UniqueESSymbol))
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return String(prop.escapedName).startsWith("__@");
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* The initializer expression governing a type, if any.
|
|
1124
|
+
* @param type - the type
|
|
1125
|
+
* @param node - the declaration it was read from
|
|
1126
|
+
* @returns the initializer expression
|
|
1127
|
+
*/
|
|
1128
|
+
initializerFor(type, node) {
|
|
1129
|
+
if (node)
|
|
1130
|
+
return initializerOf(node);
|
|
1131
|
+
return initializerOf(this.declarationOf(type.getSymbol()));
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* The name under which a declaration's schema is registered.
|
|
1135
|
+
* @param node - the node being converted
|
|
1136
|
+
* @returns the declared name, or undefined for type nodes
|
|
1137
|
+
*/
|
|
1138
|
+
declaredName(node) {
|
|
1139
|
+
if (is.isClassDeclaration(node) || is.isInterfaceDeclaration(node) || is.isTypeAliasDeclaration(node)) {
|
|
1140
|
+
return node.name?.text;
|
|
1141
|
+
}
|
|
1142
|
+
return undefined;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
// ------------------------------------------------------------- free helpers --
|
|
1146
|
+
/**
|
|
1147
|
+
* Join a schema path segment.
|
|
1148
|
+
* @param base - the parent path
|
|
1149
|
+
* @param segment - the child segment
|
|
1150
|
+
* @returns the joined path
|
|
1151
|
+
*/
|
|
1152
|
+
function joinPath(base, segment) {
|
|
1153
|
+
if (!base || base === "/")
|
|
1154
|
+
return `/${segment}`;
|
|
1155
|
+
return `${base.replace(/\/+$/, "")}/${segment.replace(/^\/+/, "")}`;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Whether an object type is anonymous rather than declared.
|
|
1159
|
+
* @param type - the type to test
|
|
1160
|
+
* @returns true for anonymous object types
|
|
1161
|
+
*/
|
|
1162
|
+
function isAnonymous(type) {
|
|
1163
|
+
return (objectFlagsOf(type) & ObjectFlags.Anonymous) !== 0;
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* A type's object flags, or zero when it is not an object type.
|
|
1167
|
+
* @param type - the type
|
|
1168
|
+
* @returns the object flags
|
|
1169
|
+
*/
|
|
1170
|
+
function objectFlagsOf(type) {
|
|
1171
|
+
return type.isObjectType() ? type.objectFlags : 0;
|
|
1172
|
+
}
|
|
1173
|
+
/** Ranks reserved for primitives; anything else sorts after them. */
|
|
1174
|
+
const PRIMITIVE_RANKS = 100;
|
|
1175
|
+
/**
|
|
1176
|
+
* Where a primitive sits in the checker's intrinsic-type creation order.
|
|
1177
|
+
*
|
|
1178
|
+
* Taken from `initializeTypeChecker`, which interns these before any user
|
|
1179
|
+
* type, so in TypeScript 6 they always carried the lowest ids and therefore
|
|
1180
|
+
* came first in a union. Boolean literals are ranked by value because 7.1
|
|
1181
|
+
* leaves `intrinsicName` empty on them.
|
|
1182
|
+
* @param type - the constituent to rank
|
|
1183
|
+
* @returns its rank, or undefined when it is not a primitive
|
|
1184
|
+
*/
|
|
1185
|
+
function primitiveRank(type) {
|
|
1186
|
+
const flags = type.flags;
|
|
1187
|
+
if (flags & TypeFlags.Any)
|
|
1188
|
+
return 0;
|
|
1189
|
+
if (flags & TypeFlags.Unknown)
|
|
1190
|
+
return 1;
|
|
1191
|
+
if (flags & TypeFlags.Undefined)
|
|
1192
|
+
return 2;
|
|
1193
|
+
if (flags & TypeFlags.Null)
|
|
1194
|
+
return 3;
|
|
1195
|
+
if (flags & TypeFlags.StringLiteral)
|
|
1196
|
+
return undefined;
|
|
1197
|
+
if (flags & TypeFlags.String)
|
|
1198
|
+
return 4;
|
|
1199
|
+
if (flags & TypeFlags.NumberLiteral)
|
|
1200
|
+
return undefined;
|
|
1201
|
+
if (flags & TypeFlags.Number)
|
|
1202
|
+
return 5;
|
|
1203
|
+
if (flags & TypeFlags.BigIntLiteral)
|
|
1204
|
+
return undefined;
|
|
1205
|
+
if (flags & TypeFlags.BigInt)
|
|
1206
|
+
return 6;
|
|
1207
|
+
if (flags & TypeFlags.BooleanLiteral)
|
|
1208
|
+
return type.value === true ? 8 : 7;
|
|
1209
|
+
if (flags & TypeFlags.Boolean)
|
|
1210
|
+
return 9;
|
|
1211
|
+
if (flags & TypeFlags.ESSymbol)
|
|
1212
|
+
return 10;
|
|
1213
|
+
if (flags & TypeFlags.Void)
|
|
1214
|
+
return 11;
|
|
1215
|
+
if (flags & TypeFlags.Never)
|
|
1216
|
+
return 12;
|
|
1217
|
+
if (flags & TypeFlags.NonPrimitive)
|
|
1218
|
+
return 13;
|
|
1219
|
+
return undefined;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Whether a schema says nothing but `type`.
|
|
1223
|
+
* @param schema - the schema to test
|
|
1224
|
+
* @returns true when `type` is its only keyword
|
|
1225
|
+
*/
|
|
1226
|
+
function isBareType(schema) {
|
|
1227
|
+
return Object.keys(schema).length === 1 && schema.type !== undefined;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Drop structurally identical branches from a union.
|
|
1231
|
+
* @param branches - the branch schemas
|
|
1232
|
+
* @returns the branches, deduplicated, in first-seen order
|
|
1233
|
+
*/
|
|
1234
|
+
function dedupe(branches) {
|
|
1235
|
+
const seen = new Set();
|
|
1236
|
+
const unique = [];
|
|
1237
|
+
for (const branch of branches) {
|
|
1238
|
+
const key = JSON.stringify(branch);
|
|
1239
|
+
if (seen.has(key))
|
|
1240
|
+
continue;
|
|
1241
|
+
seen.add(key);
|
|
1242
|
+
unique.push(branch);
|
|
1243
|
+
}
|
|
1244
|
+
return unique;
|
|
1245
|
+
}
|
|
1246
|
+
/** Keywords compared when deciding whether tuple elements share a shape. */
|
|
1247
|
+
const SHAPE_KEYWORDS = ["type", "const", "enum", "format", "pattern", "minimum", "maximum", "minLength", "maxLength"];
|
|
1248
|
+
/**
|
|
1249
|
+
* Whether every tuple element has the same constraining keywords.
|
|
1250
|
+
* @param items - the element schemas
|
|
1251
|
+
* @returns true when a single `items` schema is equivalent
|
|
1252
|
+
*/
|
|
1253
|
+
function isHomogeneous(items) {
|
|
1254
|
+
if (items.length === 0)
|
|
1255
|
+
return false;
|
|
1256
|
+
const first = items[0];
|
|
1257
|
+
const count = (schema) => Object.keys(schema).filter(key => SHAPE_KEYWORDS.includes(key)).length;
|
|
1258
|
+
return items.every(item => SHAPE_KEYWORDS.every(key => JSON.stringify(first[key]) === JSON.stringify(item[key])) &&
|
|
1259
|
+
count(item) === count(first));
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Read a declaration's initializer.
|
|
1263
|
+
* @param node - the declaration
|
|
1264
|
+
* @returns the initializer expression, when present
|
|
1265
|
+
*/
|
|
1266
|
+
function initializerOf(node) {
|
|
1267
|
+
if (!node)
|
|
1268
|
+
return undefined;
|
|
1269
|
+
if (is.isPropertyDeclaration(node) ||
|
|
1270
|
+
is.isPropertySignatureDeclaration(node) ||
|
|
1271
|
+
is.isPropertyAssignment(node) ||
|
|
1272
|
+
is.isParameterDeclaration(node) ||
|
|
1273
|
+
is.isVariableDeclaration(node) ||
|
|
1274
|
+
is.isEnumMember(node)) {
|
|
1275
|
+
return node.initializer ?? undefined;
|
|
1276
|
+
}
|
|
1277
|
+
return undefined;
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Extract a non-numeric literal value from an initializer.
|
|
1281
|
+
*
|
|
1282
|
+
* Numbers are deliberately excluded: `@webda/schema` treats them as values
|
|
1283
|
+
* rather than schema defaults, and enum numbering is handled separately.
|
|
1284
|
+
* @param expression - the initializer
|
|
1285
|
+
* @returns the JSON Schema type and constant, or undefined
|
|
1286
|
+
*/
|
|
1287
|
+
function literalOf(expression) {
|
|
1288
|
+
if (!expression)
|
|
1289
|
+
return undefined;
|
|
1290
|
+
const inner = is.skipOuterExpressions(expression);
|
|
1291
|
+
switch (inner.kind) {
|
|
1292
|
+
case SyntaxKind.TrueKeyword:
|
|
1293
|
+
return { type: "boolean", const: true };
|
|
1294
|
+
case SyntaxKind.FalseKeyword:
|
|
1295
|
+
return { type: "boolean", const: false };
|
|
1296
|
+
case SyntaxKind.NullKeyword:
|
|
1297
|
+
return { type: "null", const: null };
|
|
1298
|
+
case SyntaxKind.StringLiteral:
|
|
1299
|
+
return { type: "string", const: inner.text };
|
|
1300
|
+
default:
|
|
1301
|
+
return undefined;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Whether a declaration is a method rather than a data property.
|
|
1306
|
+
* @param node - the declaration
|
|
1307
|
+
* @returns true for methods and function-typed members
|
|
1308
|
+
*/
|
|
1309
|
+
function isMethodLike(node) {
|
|
1310
|
+
return is.isMethodDeclaration(node) || is.isMethodSignatureDeclaration(node) || is.isFunctionTypeNode(node);
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Whether a member declaration carries a `?` token.
|
|
1314
|
+
* @param node - the declaration
|
|
1315
|
+
* @returns true when optional
|
|
1316
|
+
*/
|
|
1317
|
+
function hasQuestionToken(node) {
|
|
1318
|
+
if (!node)
|
|
1319
|
+
return false;
|
|
1320
|
+
const postfix = node.postfixToken;
|
|
1321
|
+
if (postfix)
|
|
1322
|
+
return postfix.kind === SyntaxKind.QuestionToken;
|
|
1323
|
+
return node.questionToken !== undefined;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Whether a property is written as `T | undefined`.
|
|
1327
|
+
*
|
|
1328
|
+
* Checked on the syntax rather than the type because most Webda packages
|
|
1329
|
+
* compile with `strict: false`, where the checker folds `string | undefined`
|
|
1330
|
+
* back to `string` and the property would otherwise be required. The author
|
|
1331
|
+
* wrote `| undefined`, so the schema says optional.
|
|
1332
|
+
* @param node - the property declaration
|
|
1333
|
+
* @returns true when `undefined` appears in a written union
|
|
1334
|
+
*/
|
|
1335
|
+
function writesUndefined(node) {
|
|
1336
|
+
const typeNode = node && node.type;
|
|
1337
|
+
if (!typeNode || !is.isUnionTypeNode(typeNode))
|
|
1338
|
+
return false;
|
|
1339
|
+
return typeNode.types.some(member => member.kind === SyntaxKind.UndefinedKeyword);
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Whether a node has a declaration name.
|
|
1343
|
+
* @param node - the node
|
|
1344
|
+
* @returns true when it exposes a `name`
|
|
1345
|
+
*/
|
|
1346
|
+
function hasName(node) {
|
|
1347
|
+
return "name" in node;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* A declaration's modifier flags, or zero when it has none.
|
|
1351
|
+
* @param node - the declaration
|
|
1352
|
+
* @returns the modifier flags
|
|
1353
|
+
*/
|
|
1354
|
+
function modifierFlagsOf(node) {
|
|
1355
|
+
return node.modifierFlags ?? 0;
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Recursively sort object keys.
|
|
1359
|
+
*
|
|
1360
|
+
* The committed `webda.module.json` is key-sorted, so a stable order is part
|
|
1361
|
+
* of byte-identical output rather than a nicety.
|
|
1362
|
+
* @param value - any JSON value
|
|
1363
|
+
* @returns a deep copy with sorted keys
|
|
1364
|
+
*/
|
|
1365
|
+
function sortKeys(value) {
|
|
1366
|
+
if (Array.isArray(value))
|
|
1367
|
+
return value.map(sortKeys);
|
|
1368
|
+
if (value && typeof value === "object") {
|
|
1369
|
+
const sorted = {};
|
|
1370
|
+
for (const key of Object.keys(value).sort()) {
|
|
1371
|
+
sorted[key] = sortKeys(value[key]);
|
|
1372
|
+
}
|
|
1373
|
+
return sorted;
|
|
1374
|
+
}
|
|
1375
|
+
return value;
|
|
1376
|
+
}
|
|
1377
|
+
//# sourceMappingURL=converter.js.map
|