genoc 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/analyzer/analyze.js +32 -73
- package/dist/analyzer/discriminator-registry.d.ts +108 -0
- package/dist/analyzer/discriminator-registry.js +209 -0
- package/dist/analyzer/schema-mapper.d.ts +66 -5
- package/dist/analyzer/schema-mapper.js +291 -36
- package/dist/analyzer/types.d.ts +8 -1
- package/dist/cli/impl.js +4 -1
- package/dist/generator/method-generator.js +33 -17
- package/dist/pipeline.d.ts +2 -0
- package/dist/pipeline.js +1 -0
- package/dist/utils/proxy.d.ts +2 -0
- package/dist/utils/proxy.js +16 -0
- package/dist/utils/string.d.ts +7 -0
- package/dist/utils/string.js +9 -0
- package/package.json +15 -7
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
2
|
import { formatToBrandTypeName } from '../utils/case.js';
|
|
3
3
|
import { buildTypeJsDoc, sanitizeTypeName } from '../utils/generator-helpers.js';
|
|
4
|
-
import { quoteKey } from '../utils/string.js';
|
|
4
|
+
import { escapeStringLiteral, quoteKey } from '../utils/string.js';
|
|
5
|
+
import { parseJsonPointer } from '../utils/url.js';
|
|
5
6
|
/** Indentation contract for multi-line types — 2-space unit pinned from the generated ServerParams interface; binding for downstream generators. */
|
|
6
7
|
const INDENT_UNIT = ' ';
|
|
7
8
|
function indentBy(level) {
|
|
@@ -28,6 +29,70 @@ function isComplexType(tsType) {
|
|
|
28
29
|
function needsParens(tsType) {
|
|
29
30
|
return tsType.includes(' | ') || tsType.includes(' & ');
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether the rendered type has a top-level `|` (depth 0, outside any
|
|
34
|
+
* brackets/braces and outside string literals). Unlike the substring check
|
|
35
|
+
* in `needsParens`, nested unions inside `{...}`, `<...>` or `'...'` do not
|
|
36
|
+
* trigger it — this is the guard for appending `& {...}` onto a type whose
|
|
37
|
+
* tail could otherwise capture the append (`X | null & {...}` loses the
|
|
38
|
+
* literal to `null`).
|
|
39
|
+
*/
|
|
40
|
+
function hasTopLevelUnion(tsType) {
|
|
41
|
+
let depth = 0;
|
|
42
|
+
let inString = false;
|
|
43
|
+
for (let i = 0; i < tsType.length; i++) {
|
|
44
|
+
const ch = tsType[i];
|
|
45
|
+
if (ch === '\\') {
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ch === "'") {
|
|
50
|
+
inString = !inString;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (inString)
|
|
54
|
+
continue;
|
|
55
|
+
if (ch === '(' || ch === '{' || ch === '[' || ch === '<')
|
|
56
|
+
depth++;
|
|
57
|
+
else if (ch === ')' || ch === '}' || ch === ']' || ch === '>')
|
|
58
|
+
depth--;
|
|
59
|
+
else if (depth === 0 && ch === '|' && tsType[i - 1] === ' ' && tsType[i + 1] === ' ') {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/** Decode the last segment of a `$ref` string into its raw component key (`~1`/`~0` unescaped). */
|
|
66
|
+
function decodedLastRefSegment(refStr) {
|
|
67
|
+
const segments = refStr.startsWith('#') ? parseJsonPointer(refStr.slice(1)) : refStr.split('/');
|
|
68
|
+
return segments[segments.length - 1] ?? refStr;
|
|
69
|
+
}
|
|
70
|
+
/** Encode a component key for embedding in a `#/components/schemas/…` ref string. */
|
|
71
|
+
function encodePointerSegment(segment) {
|
|
72
|
+
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
73
|
+
}
|
|
74
|
+
function spineForEntry(entry) {
|
|
75
|
+
return {
|
|
76
|
+
familyId: entry.familyId,
|
|
77
|
+
propertyName: entry.propertyName,
|
|
78
|
+
literalValue: entry.literalValue,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A variant's own declared discriminant on the discriminator property:
|
|
83
|
+
* `const` first, then a single-value `enum` (D3). Multi-value enums and
|
|
84
|
+
* absent props yield nothing.
|
|
85
|
+
*/
|
|
86
|
+
function ownDeclaredDiscriminant(propSchema) {
|
|
87
|
+
if (!propSchema)
|
|
88
|
+
return undefined;
|
|
89
|
+
if (propSchema.const !== undefined)
|
|
90
|
+
return String(propSchema.const);
|
|
91
|
+
if (propSchema.enum !== undefined && propSchema.enum.length === 1) {
|
|
92
|
+
return String(propSchema.enum[0]);
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
31
96
|
/**
|
|
32
97
|
* SchemaMapper converts OpenAPI 3.1 Schema Objects to TypeScript type strings.
|
|
33
98
|
*
|
|
@@ -38,24 +103,33 @@ export class SchemaMapper {
|
|
|
38
103
|
resolver;
|
|
39
104
|
typeNameGenerator;
|
|
40
105
|
discriminatorTargets;
|
|
41
|
-
|
|
106
|
+
emittedNames;
|
|
107
|
+
discriminatorRegistry;
|
|
42
108
|
brandedTypes = new Map();
|
|
43
109
|
nullableWarned = false;
|
|
44
110
|
warnSink;
|
|
45
|
-
|
|
111
|
+
effectiveVersion;
|
|
112
|
+
constructor(resolver, opts) {
|
|
46
113
|
// NOTE: discriminatorTargets must be keyed by names produced by the SAME
|
|
47
114
|
// (rename-aware) typeNameGenerator that resolves $refs. Passing targets
|
|
48
115
|
// keyed by raw schema names means renamed subtypes silently lose their
|
|
49
116
|
// `& { prop: 'literal' }` intersection in the generated output.
|
|
117
|
+
// analyze() no longer builds or passes a targets map — the discriminator
|
|
118
|
+
// registry (when provided) is the SINGLE discriminator knowledge source
|
|
119
|
+
// (D1–D10 seam). The optional legacy targets param is kept for
|
|
120
|
+
// registry-less constructions; it only drives the legacy ref fallback in
|
|
121
|
+
// `resolveDiscriminatorInfo` below.
|
|
50
122
|
this.resolver = resolver;
|
|
51
|
-
this.typeNameGenerator = typeNameGenerator ?? defaultTypeNameGenerator;
|
|
52
|
-
this.discriminatorTargets = discriminatorTargets ?? new Map();
|
|
53
|
-
this.
|
|
123
|
+
this.typeNameGenerator = opts?.typeNameGenerator ?? defaultTypeNameGenerator;
|
|
124
|
+
this.discriminatorTargets = opts?.discriminatorTargets ?? new Map();
|
|
125
|
+
this.emittedNames = opts?.emittedNames ?? new Set();
|
|
126
|
+
this.discriminatorRegistry = opts?.discriminatorRegistry;
|
|
54
127
|
this.warnSink =
|
|
55
|
-
warnSink ??
|
|
128
|
+
opts?.warnSink ??
|
|
56
129
|
((message) => {
|
|
57
130
|
process.stderr.write(message);
|
|
58
131
|
});
|
|
132
|
+
this.effectiveVersion = opts?.effectiveVersion;
|
|
59
133
|
}
|
|
60
134
|
getBrandedTypes() {
|
|
61
135
|
return this.brandedTypes;
|
|
@@ -73,12 +147,28 @@ export class SchemaMapper {
|
|
|
73
147
|
return { tsType: schema ? 'unknown' : 'never', imports: [] };
|
|
74
148
|
}
|
|
75
149
|
const visited = new Set();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
result.
|
|
150
|
+
if (this.discriminatorRegistry && name !== undefined) {
|
|
151
|
+
const entry = this.discriminatorRegistry.byName.get(name);
|
|
152
|
+
const spine = entry ? spineForEntry(entry) : undefined;
|
|
153
|
+
const result = this.mapInternal(schema, name, context, visited, 0, spine);
|
|
154
|
+
if (entry) {
|
|
155
|
+
return this.appendDiscriminatorLiteral(result, entry.propertyName, entry.literalValue);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
80
158
|
}
|
|
81
|
-
return
|
|
159
|
+
return this.mapInternal(schema, name, context, visited, 0, undefined);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Append the discriminator literal intersection exactly ONCE (site 2),
|
|
163
|
+
* parenthesizing a top-level union so `X | null & {...}` cannot bind the
|
|
164
|
+
* literal to the last union member only (D9).
|
|
165
|
+
*/
|
|
166
|
+
appendDiscriminatorLiteral(result, propertyName, literalValue) {
|
|
167
|
+
const appendage = ` & { ${quoteKey(propertyName)}: '${literalValue}' }`;
|
|
168
|
+
const tsType = hasTopLevelUnion(result.tsType)
|
|
169
|
+
? `(${result.tsType})${appendage}`
|
|
170
|
+
: `${result.tsType}${appendage}`;
|
|
171
|
+
return { tsType, imports: result.imports };
|
|
82
172
|
}
|
|
83
173
|
getBrandTypeName(format, openApiType) {
|
|
84
174
|
if (!format || format.trim() === '')
|
|
@@ -86,14 +176,17 @@ export class SchemaMapper {
|
|
|
86
176
|
if (format === 'binary' || format === 'byte')
|
|
87
177
|
return null;
|
|
88
178
|
const brandName = formatToBrandTypeName(format, openApiType);
|
|
89
|
-
if (this.
|
|
179
|
+
if (this.emittedNames.has(brandName))
|
|
90
180
|
return null;
|
|
91
181
|
return brandName;
|
|
92
182
|
}
|
|
93
|
-
mapInternal(schema, name, context, visited, indent) {
|
|
183
|
+
mapInternal(schema, name, context, visited, indent, spine) {
|
|
94
184
|
if (isRefObject(schema)) {
|
|
95
185
|
const refStr = schema.$ref;
|
|
96
186
|
const refName = this.typeNameGenerator(refStr);
|
|
187
|
+
if (this.discriminatorRegistry) {
|
|
188
|
+
return this.translateRefThroughSeam(refStr, refName, context, visited, indent, spine);
|
|
189
|
+
}
|
|
97
190
|
let resolved;
|
|
98
191
|
try {
|
|
99
192
|
resolved = this.resolver.resolveSchema(schema);
|
|
@@ -104,9 +197,8 @@ export class SchemaMapper {
|
|
|
104
197
|
if (resolved) {
|
|
105
198
|
const discInfo = this.resolveDiscriminatorInfo(resolved, refStr);
|
|
106
199
|
if (discInfo) {
|
|
107
|
-
const expanded = this.mapInternal(resolved, undefined, context, visited, indent);
|
|
108
|
-
expanded
|
|
109
|
-
return expanded;
|
|
200
|
+
const expanded = this.mapInternal(resolved, undefined, context, visited, indent, undefined);
|
|
201
|
+
return this.appendDiscriminatorLiteral(expanded, discInfo.propertyName, escapeStringLiteral(discInfo.literalValue));
|
|
110
202
|
}
|
|
111
203
|
}
|
|
112
204
|
return { tsType: refName, imports: [refName] };
|
|
@@ -122,7 +214,7 @@ export class SchemaMapper {
|
|
|
122
214
|
// TODO: Remove deprecated nullable warning and handling when OpenAPI 3.1 support is complete
|
|
123
215
|
// The 'nullable' property is deprecated in OpenAPI 3.1 in favor of type arrays like ["string", "null"]
|
|
124
216
|
// This warning should be removed once full type array support is implemented
|
|
125
|
-
if (s.nullable === true && !this.nullableWarned) {
|
|
217
|
+
if (s.nullable === true && this.effectiveVersion !== '3.0' && !this.nullableWarned) {
|
|
126
218
|
this.warnSink('Warning: \'nullable\' is deprecated in OpenAPI 3.1. Use \'type: ["string", "null"]\' instead.');
|
|
127
219
|
this.nullableWarned = true;
|
|
128
220
|
}
|
|
@@ -134,7 +226,7 @@ export class SchemaMapper {
|
|
|
134
226
|
return { tsType, imports: [] };
|
|
135
227
|
}
|
|
136
228
|
if (s.allOf !== undefined && s.allOf.length > 0) {
|
|
137
|
-
const result = this.mapCombinator(s.allOf, '&', context, visited, indent);
|
|
229
|
+
const result = this.mapCombinator(s.allOf, '&', context, visited, indent, spine);
|
|
138
230
|
if (s.nullable === true) {
|
|
139
231
|
return {
|
|
140
232
|
tsType: needsParens(result.tsType)
|
|
@@ -147,8 +239,8 @@ export class SchemaMapper {
|
|
|
147
239
|
}
|
|
148
240
|
if (s.oneOf !== undefined && s.oneOf.length > 0) {
|
|
149
241
|
const result = s.discriminator
|
|
150
|
-
? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited, indent)
|
|
151
|
-
: this.mapCombinator(s.oneOf, '|', context, visited, indent);
|
|
242
|
+
? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited, indent, spine)
|
|
243
|
+
: this.mapCombinator(s.oneOf, '|', context, visited, indent, spine);
|
|
152
244
|
if (s.nullable === true) {
|
|
153
245
|
return {
|
|
154
246
|
tsType: needsParens(result.tsType)
|
|
@@ -161,8 +253,8 @@ export class SchemaMapper {
|
|
|
161
253
|
}
|
|
162
254
|
if (s.anyOf !== undefined && s.anyOf.length > 0) {
|
|
163
255
|
const result = s.discriminator
|
|
164
|
-
? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited, indent)
|
|
165
|
-
: this.mapCombinator(s.anyOf, '|', context, visited, indent);
|
|
256
|
+
? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited, indent, spine)
|
|
257
|
+
: this.mapCombinator(s.anyOf, '|', context, visited, indent, spine);
|
|
166
258
|
if (s.nullable === true) {
|
|
167
259
|
return {
|
|
168
260
|
tsType: needsParens(result.tsType)
|
|
@@ -182,7 +274,7 @@ export class SchemaMapper {
|
|
|
182
274
|
if (nonNull.length === 0) {
|
|
183
275
|
return { tsType: 'null', imports: [] };
|
|
184
276
|
}
|
|
185
|
-
const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited), indent);
|
|
277
|
+
const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited), indent, spine);
|
|
186
278
|
if (hasNull) {
|
|
187
279
|
return {
|
|
188
280
|
tsType: `${baseResult.tsType} | null`,
|
|
@@ -239,11 +331,96 @@ export class SchemaMapper {
|
|
|
239
331
|
case 'array':
|
|
240
332
|
return this.mapArray(s, context, visited, indent);
|
|
241
333
|
case 'object':
|
|
242
|
-
return this.mapObject(s, name, context, visited, indent);
|
|
334
|
+
return this.mapObject(s, name, context, visited, indent, spine);
|
|
243
335
|
default:
|
|
244
336
|
return { tsType: 'unknown', imports: [] };
|
|
245
337
|
}
|
|
246
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* The single ref-translation seam (site 1). When the mapper carries a
|
|
341
|
+
* discriminator registry, EVERY `$ref` resolves through here:
|
|
342
|
+
*
|
|
343
|
+
* - a discriminator BASE becomes `{Base}Variant` at all sites except
|
|
344
|
+
* inside a same-family member's own definition spine, where it stays
|
|
345
|
+
* the bare base name (structural inheritance, no circularity — D1);
|
|
346
|
+
* - a same-family mapping target inside a spine becomes `Omit<M, 'P'>`,
|
|
347
|
+
* falling back to inline expansion WITHOUT the literal when M is a
|
|
348
|
+
* union, nullable, unnamed or non-object (D2/D4);
|
|
349
|
+
* - a mapping target at any other site is its bare name — the literal is
|
|
350
|
+
* injected exactly once, at the target's own named definition.
|
|
351
|
+
*/
|
|
352
|
+
translateRefThroughSeam(refStr, refName, context, visited, indent, spine) {
|
|
353
|
+
const registry = this.discriminatorRegistry;
|
|
354
|
+
const canonical = this.canonicalSchemasRef(refStr);
|
|
355
|
+
const baseFamily = registry.baseRefs.get(refStr) ?? registry.baseRefs.get(canonical);
|
|
356
|
+
if (baseFamily) {
|
|
357
|
+
if (spine && spine.familyId === baseFamily.familyId) {
|
|
358
|
+
return { tsType: baseFamily.baseTypeName, imports: [baseFamily.baseTypeName] };
|
|
359
|
+
}
|
|
360
|
+
return { tsType: baseFamily.variantUnionName, imports: [baseFamily.variantUnionName] };
|
|
361
|
+
}
|
|
362
|
+
const entry = registry.byRef.get(refStr) ?? registry.byRef.get(canonical);
|
|
363
|
+
if (entry) {
|
|
364
|
+
if (spine && spine.familyId === entry.familyId) {
|
|
365
|
+
return this.mapSameFamilySpineRef(entry, context, visited, indent);
|
|
366
|
+
}
|
|
367
|
+
if (entry.isNamed) {
|
|
368
|
+
return { tsType: entry.typeName, imports: [entry.typeName] };
|
|
369
|
+
}
|
|
370
|
+
// Unnamed target at a leaf site: expand inline and append the literal
|
|
371
|
+
// here — the target has no named definition to carry it.
|
|
372
|
+
const resolved = this.tryResolveRef(entry.refStr);
|
|
373
|
+
if (resolved) {
|
|
374
|
+
const expanded = this.mapInternal(resolved, undefined, context, visited, indent, spineForEntry(entry));
|
|
375
|
+
return this.appendDiscriminatorLiteral(expanded, entry.propertyName, entry.literalValue);
|
|
376
|
+
}
|
|
377
|
+
return { tsType: entry.typeName, imports: [entry.typeName] };
|
|
378
|
+
}
|
|
379
|
+
return { tsType: refName, imports: [refName] };
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* D2/D4 spine rule for a same-family `$ref`. `Omit<M, 'P'>` strips the
|
|
383
|
+
* sibling's own literal so the appender can re-add ours; the inline-strip
|
|
384
|
+
* fallback covers the cases where `Omit` would silently drop data
|
|
385
|
+
* (union target: `Omit<A|B,K>` drops variant props; nullable target:
|
|
386
|
+
* `Omit<M|null,K>` collapses to `{}`; unnamed/non-object target).
|
|
387
|
+
*/
|
|
388
|
+
mapSameFamilySpineRef(entry, context, visited, indent) {
|
|
389
|
+
const resolved = this.tryResolveRef(entry.refStr);
|
|
390
|
+
if (entry.isNamed && !entry.isUnion && !entry.isNullable && this.isObjectIshSchema(resolved)) {
|
|
391
|
+
const key = `'${escapeStringLiteral(entry.propertyName)}'`;
|
|
392
|
+
return { tsType: `Omit<${entry.typeName}, ${key}>`, imports: [entry.typeName] };
|
|
393
|
+
}
|
|
394
|
+
if (resolved) {
|
|
395
|
+
return this.mapInternal(resolved, undefined, context, visited, indent, spineForEntry(entry));
|
|
396
|
+
}
|
|
397
|
+
return { tsType: entry.typeName, imports: [entry.typeName] };
|
|
398
|
+
}
|
|
399
|
+
/** Normalize a `#/components/schemas/…` ref to its canonically-encoded form (`a/b` ↔ `a~1b`). */
|
|
400
|
+
canonicalSchemasRef(refStr) {
|
|
401
|
+
if (!refStr.startsWith('#/components/schemas/'))
|
|
402
|
+
return refStr;
|
|
403
|
+
return `#/components/schemas/${encodePointerSegment(decodedLastRefSegment(refStr))}`;
|
|
404
|
+
}
|
|
405
|
+
tryResolveRef(refStr) {
|
|
406
|
+
try {
|
|
407
|
+
return this.resolver.resolveSchema({ $ref: refStr });
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
isObjectIshSchema(s) {
|
|
414
|
+
if (!s)
|
|
415
|
+
return false;
|
|
416
|
+
if (s.type === 'object')
|
|
417
|
+
return true;
|
|
418
|
+
if (Array.isArray(s.type))
|
|
419
|
+
return s.type.includes('object');
|
|
420
|
+
if (s.allOf !== undefined && s.allOf.length > 0)
|
|
421
|
+
return true;
|
|
422
|
+
return s.type === undefined && s.properties !== undefined;
|
|
423
|
+
}
|
|
247
424
|
resolveDiscriminatorInfo(schema, refStr) {
|
|
248
425
|
const schemaName = this.typeNameGenerator(refStr);
|
|
249
426
|
const target = this.discriminatorTargets.get(schemaName);
|
|
@@ -272,7 +449,7 @@ export class SchemaMapper {
|
|
|
272
449
|
return values
|
|
273
450
|
.map((v) => {
|
|
274
451
|
if (typeof v === 'string')
|
|
275
|
-
return `'${v}'`;
|
|
452
|
+
return `'${escapeStringLiteral(v)}'`;
|
|
276
453
|
if (typeof v === 'number')
|
|
277
454
|
return String(v);
|
|
278
455
|
if (typeof v === 'boolean')
|
|
@@ -283,8 +460,8 @@ export class SchemaMapper {
|
|
|
283
460
|
})
|
|
284
461
|
.join(' | ');
|
|
285
462
|
}
|
|
286
|
-
mapCombinator(schemas, kind, context, visited, indent) {
|
|
287
|
-
const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited, indent));
|
|
463
|
+
mapCombinator(schemas, kind, context, visited, indent, spine) {
|
|
464
|
+
const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited, indent, spine));
|
|
288
465
|
const allImports = [];
|
|
289
466
|
for (const r of results) {
|
|
290
467
|
allImports.push(...r.imports);
|
|
@@ -298,20 +475,83 @@ export class SchemaMapper {
|
|
|
298
475
|
});
|
|
299
476
|
return { tsType: parts.join(separator), imports: allImports };
|
|
300
477
|
}
|
|
301
|
-
mapDiscriminatedUnion(schemas, discriminator, context, visited, indent) {
|
|
478
|
+
mapDiscriminatedUnion(schemas, discriminator, context, visited, indent, spine) {
|
|
302
479
|
const propertyName = discriminator.propertyName;
|
|
303
480
|
const mapping = discriminator.mapping;
|
|
304
481
|
const allImports = [];
|
|
305
482
|
const parts = [];
|
|
306
483
|
for (const schema of schemas) {
|
|
307
|
-
|
|
308
|
-
|
|
484
|
+
if (this.discriminatorRegistry && isRefObject(schema)) {
|
|
485
|
+
const refStr = schema.$ref;
|
|
486
|
+
const canonical = this.canonicalSchemasRef(refStr);
|
|
487
|
+
const entry = this.discriminatorRegistry.byRef.get(refStr) ??
|
|
488
|
+
this.discriminatorRegistry.byRef.get(canonical);
|
|
489
|
+
// D7: registered family variants are bare names — their named
|
|
490
|
+
// definitions already carry the literal exactly once. An explicit
|
|
491
|
+
// mapping defines the family's membership: when one exists, a
|
|
492
|
+
// oneOf member absent from it is foreign (wrapper + warn below);
|
|
493
|
+
// without a mapping every oneOf ref is an implicit member (D3).
|
|
494
|
+
const mappedRef = mapping !== undefined &&
|
|
495
|
+
(Object.values(mapping).includes(refStr) || Object.values(mapping).includes(canonical));
|
|
496
|
+
if (entry && entry.propertyName === propertyName && (mapping === undefined || mappedRef)) {
|
|
497
|
+
if (entry.isNamed) {
|
|
498
|
+
allImports.push(entry.typeName);
|
|
499
|
+
parts.push(entry.typeName);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const resolved = this.tryResolveRef(entry.refStr);
|
|
503
|
+
if (resolved) {
|
|
504
|
+
const expanded = this.mapInternal(resolved, undefined, context, visited, indent, spineForEntry(entry));
|
|
505
|
+
allImports.push(...expanded.imports);
|
|
506
|
+
parts.push(this.appendDiscriminatorLiteral(expanded, propertyName, entry.literalValue).tsType);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Foreign (non-family) ref: keep the literal wrapper and warn —
|
|
511
|
+
// nothing guarantees the target declares its own discriminant.
|
|
512
|
+
this.warnSink(`Warning: discriminated union member "${refStr}" is not registered in a discriminator family; keeping the inline '{ ${propertyName}: <literal> } &' wrapper.\n`);
|
|
513
|
+
}
|
|
514
|
+
const discriminantValue = this.discriminatorRegistry
|
|
515
|
+
? this.resolveRegistryDiscriminantValue(schema, propertyName, mapping)
|
|
516
|
+
: this.resolveDiscriminantValue(schema, propertyName, mapping);
|
|
517
|
+
const variantResult = this.mapInternal(schema, undefined, context, visited, indent, this.discriminatorRegistry ? undefined : spine);
|
|
309
518
|
allImports.push(...variantResult.imports);
|
|
310
519
|
const quotedProp = quoteKey(propertyName);
|
|
311
520
|
parts.push(`({ ${quotedProp}: '${discriminantValue}' } & ${variantResult.tsType})`);
|
|
312
521
|
}
|
|
313
522
|
return { tsType: parts.join(' | '), imports: allImports };
|
|
314
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* D3 literal precedence for registry-mode wrapper literals (inline variant
|
|
526
|
+
* schemas and foreign refs): mapping key > own `const` on the discriminant
|
|
527
|
+
* property > single-value `enum` > RAW last ref segment (the runtime
|
|
528
|
+
* value, NOT the sanitized type name). Values are escaped for a
|
|
529
|
+
* single-quoted TS string.
|
|
530
|
+
*/
|
|
531
|
+
resolveRegistryDiscriminantValue(schema, propertyName, mapping) {
|
|
532
|
+
if (mapping && isRefObject(schema)) {
|
|
533
|
+
const refStr = schema.$ref;
|
|
534
|
+
for (const [value, ref] of Object.entries(mapping)) {
|
|
535
|
+
if (ref === refStr)
|
|
536
|
+
return escapeStringLiteral(value);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const resolved = isRefObject(schema)
|
|
540
|
+
? this.tryResolveRef(schema.$ref)
|
|
541
|
+
: schema;
|
|
542
|
+
const propSchema = resolved?.properties?.[propertyName];
|
|
543
|
+
if (propSchema && propSchema.const !== undefined) {
|
|
544
|
+
return escapeStringLiteral(String(propSchema.const));
|
|
545
|
+
}
|
|
546
|
+
if (propSchema && propSchema.enum !== undefined && propSchema.enum.length === 1) {
|
|
547
|
+
return escapeStringLiteral(String(propSchema.enum[0]));
|
|
548
|
+
}
|
|
549
|
+
if (isRefObject(schema)) {
|
|
550
|
+
const refStr = schema.$ref;
|
|
551
|
+
return escapeStringLiteral(decodedLastRefSegment(refStr));
|
|
552
|
+
}
|
|
553
|
+
return 'unknown';
|
|
554
|
+
}
|
|
315
555
|
resolveDiscriminantValue(schema, propertyName, mapping) {
|
|
316
556
|
// Try explicit mapping first
|
|
317
557
|
if (mapping && isRefObject(schema)) {
|
|
@@ -343,7 +583,7 @@ export class SchemaMapper {
|
|
|
343
583
|
imports: [],
|
|
344
584
|
};
|
|
345
585
|
}
|
|
346
|
-
const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent);
|
|
586
|
+
const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent, undefined);
|
|
347
587
|
const tsType = isComplexType(itemResult.tsType)
|
|
348
588
|
? `Array<${itemResult.tsType}>`
|
|
349
589
|
: `${itemResult.tsType}[]`;
|
|
@@ -378,7 +618,7 @@ export class SchemaMapper {
|
|
|
378
618
|
const ind = indentBy(indentLevel);
|
|
379
619
|
return block.split('\n').map((line) => `${ind}${line}`);
|
|
380
620
|
}
|
|
381
|
-
mapObject(schema, name, context, visited, indent) {
|
|
621
|
+
mapObject(schema, name, context, visited, indent, spine) {
|
|
382
622
|
const properties = schema.properties ?? {};
|
|
383
623
|
const requiredSet = new Set(schema.required ?? []);
|
|
384
624
|
const propNames = Object.keys(properties);
|
|
@@ -393,6 +633,19 @@ export class SchemaMapper {
|
|
|
393
633
|
return false;
|
|
394
634
|
return true;
|
|
395
635
|
});
|
|
636
|
+
// Inside a mapping target's own definition the discriminator property is
|
|
637
|
+
// supplied by the single literal append — an own redeclaration (const or
|
|
638
|
+
// enum) would intersect a conflicting literal and collapse to `never`.
|
|
639
|
+
if (spine) {
|
|
640
|
+
const ownDiscIndex = filteredPropNames.indexOf(spine.propertyName);
|
|
641
|
+
if (ownDiscIndex !== -1) {
|
|
642
|
+
const ownValue = ownDeclaredDiscriminant(properties[spine.propertyName]);
|
|
643
|
+
if (ownValue !== undefined && escapeStringLiteral(ownValue) !== spine.literalValue) {
|
|
644
|
+
this.warnSink(`Warning: Discriminator mapping key "${spine.literalValue}" for property "${spine.propertyName}" conflicts with the variant's own declared value "${ownValue}"; the mapping key wins.\n`);
|
|
645
|
+
}
|
|
646
|
+
filteredPropNames.splice(ownDiscIndex, 1);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
396
649
|
const ownIndent = indentBy(indent);
|
|
397
650
|
const memberIndent = indentBy(indent + 1);
|
|
398
651
|
const memberLines = [];
|
|
@@ -407,7 +660,7 @@ export class SchemaMapper {
|
|
|
407
660
|
}
|
|
408
661
|
for (const propName of filteredPropNames) {
|
|
409
662
|
const propSchema = properties[propName];
|
|
410
|
-
const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1);
|
|
663
|
+
const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1, undefined);
|
|
411
664
|
allImports.push(...propResult.imports);
|
|
412
665
|
memberLines.push(...this.renderPropertyJsDoc(propSchema, indent + 1));
|
|
413
666
|
const optional = requiredSet.has(propName) ? '' : '?';
|
|
@@ -423,7 +676,7 @@ export class SchemaMapper {
|
|
|
423
676
|
indexSignature = '[key: string]: unknown';
|
|
424
677
|
}
|
|
425
678
|
else if (additionalProps !== undefined && typeof additionalProps === 'object') {
|
|
426
|
-
const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1);
|
|
679
|
+
const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1, undefined);
|
|
427
680
|
allImports.push(...addPropResult.imports);
|
|
428
681
|
indexSignature = `[key: string]: ${addPropResult.tsType}`;
|
|
429
682
|
}
|
|
@@ -471,7 +724,9 @@ export class SchemaMapper {
|
|
|
471
724
|
}
|
|
472
725
|
if (schema.nullable === true) {
|
|
473
726
|
return {
|
|
474
|
-
tsType:
|
|
727
|
+
tsType: hasTopLevelUnion(result.tsType)
|
|
728
|
+
? `(${result.tsType}) | null`
|
|
729
|
+
: `${result.tsType} | null`,
|
|
475
730
|
imports: result.imports,
|
|
476
731
|
};
|
|
477
732
|
}
|
package/dist/analyzer/types.d.ts
CHANGED
|
@@ -95,7 +95,9 @@ export interface AnalyzedSpec {
|
|
|
95
95
|
/**
|
|
96
96
|
* Section-1 component schema declarations as finished entries, in the
|
|
97
97
|
* pre-refactor emission order: topologically sorted schemas first, then
|
|
98
|
-
*
|
|
98
|
+
* one always-emitted `{Base}Variant` union per discriminator family
|
|
99
|
+
* (members = mapping values ∪ implicit oneOf/anyOf refs, sourced from the
|
|
100
|
+
* discriminator registry; collision-safe names) in family discovery order.
|
|
99
101
|
*/
|
|
100
102
|
schemaTypes: TypeDeclaration[];
|
|
101
103
|
/** Branded format types discovered during mapping, in discovery order. */
|
|
@@ -116,4 +118,9 @@ export interface AnalyzeOptions {
|
|
|
116
118
|
resolver?: RefResolver;
|
|
117
119
|
/** Method naming strategy; defaults to `'path-based'`. */
|
|
118
120
|
strategy?: MethodNameStrategy;
|
|
121
|
+
/**
|
|
122
|
+
* Effective OpenAPI dialect, sourced from `VersionProfile.effective`.
|
|
123
|
+
* Omitted is treated as `'3.1'`.
|
|
124
|
+
*/
|
|
125
|
+
effectiveVersion?: '3.0' | '3.1';
|
|
119
126
|
}
|
package/dist/cli/impl.js
CHANGED
|
@@ -165,7 +165,10 @@ async function runOneTarget(target, context) {
|
|
|
165
165
|
};
|
|
166
166
|
const { preserveRefSiblings } = profile;
|
|
167
167
|
context.process.stdout.write('Generating client...\n');
|
|
168
|
-
await generateFullOutput(doc, config, {
|
|
168
|
+
await generateFullOutput(doc, config, {
|
|
169
|
+
preserveRefSiblings,
|
|
170
|
+
effectiveVersion: profile.effective,
|
|
171
|
+
});
|
|
169
172
|
context.process.stdout.write(`✅ Success! Generated client files:\n`);
|
|
170
173
|
context.process.stdout.write(` - ${target.outputDir}/contracts.ts\n`);
|
|
171
174
|
context.process.stdout.write(` - ${target.outputDir}/client.ts\n`);
|
|
@@ -1,34 +1,50 @@
|
|
|
1
1
|
import { sanitizeJsDocText } from '../utils/generator-helpers.js';
|
|
2
2
|
import { getSuccessType, operationEmissions } from '../utils/operation-naming.js';
|
|
3
|
+
/**
|
|
4
|
+
* A `?:` parameter may only be followed by other optional parameters — a
|
|
5
|
+
* required parameter after an optional one is a TS1016 compile error
|
|
6
|
+
* ("A required parameter cannot follow an optional parameter"). Optional
|
|
7
|
+
* parameters that precede a required one are therefore rendered as
|
|
8
|
+
* `name: T | undefined` instead of `name?: T`.
|
|
9
|
+
*/
|
|
10
|
+
function renderParams(specs) {
|
|
11
|
+
return specs
|
|
12
|
+
.map((spec, index) => {
|
|
13
|
+
if (!spec.isOptional) {
|
|
14
|
+
return `${spec.name}: ${spec.type}`;
|
|
15
|
+
}
|
|
16
|
+
const hasRequiredAfter = specs.slice(index + 1).some((s) => !s.isOptional);
|
|
17
|
+
if (hasRequiredAfter) {
|
|
18
|
+
return `${spec.name}: ${spec.type} | undefined`;
|
|
19
|
+
}
|
|
20
|
+
return `${spec.name}?: ${spec.type}`;
|
|
21
|
+
})
|
|
22
|
+
.join(', ');
|
|
23
|
+
}
|
|
3
24
|
function buildParameters(op) {
|
|
4
|
-
const params = [];
|
|
5
25
|
const emissions = operationEmissions(op);
|
|
26
|
+
const specs = [];
|
|
6
27
|
for (const param of op.pathParams) {
|
|
7
|
-
|
|
28
|
+
specs.push({ name: param.name, type: 'string', isOptional: false });
|
|
8
29
|
}
|
|
9
30
|
if (emissions.query !== undefined) {
|
|
10
31
|
const allOptional = op.queryParams.every((p) => !p.required);
|
|
11
|
-
|
|
12
|
-
if (allOptional && hasRequiredAfter) {
|
|
13
|
-
// All optional query params + required param after: use explicit undefined to avoid "required param cannot follow optional" error
|
|
14
|
-
params.push(`query: ${emissions.query} | undefined`);
|
|
15
|
-
}
|
|
16
|
-
else {
|
|
17
|
-
// Normal case: use optional notation
|
|
18
|
-
const optional = allOptional ? '?' : '';
|
|
19
|
-
params.push(`query${optional}: ${emissions.query}`);
|
|
20
|
-
}
|
|
32
|
+
specs.push({ name: 'query', type: emissions.query, isOptional: allOptional });
|
|
21
33
|
}
|
|
22
34
|
if (op.requestBody) {
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
// A schema-less body has no named type; the literal keeps the rendered
|
|
36
|
+
// signature byte-identical to the previous template-literal fallback.
|
|
37
|
+
specs.push({
|
|
38
|
+
name: 'body',
|
|
39
|
+
type: emissions.body ?? 'undefined',
|
|
40
|
+
isOptional: !op.requestBody.required,
|
|
41
|
+
});
|
|
25
42
|
}
|
|
26
43
|
if (emissions.headers !== undefined) {
|
|
27
44
|
const allOptional = op.headerParams.every((p) => !p.required);
|
|
28
|
-
|
|
29
|
-
params.push(`headers${optional}: ${emissions.headers}`);
|
|
45
|
+
specs.push({ name: 'headers', type: emissions.headers, isOptional: allOptional });
|
|
30
46
|
}
|
|
31
|
-
return
|
|
47
|
+
return renderParams(specs);
|
|
32
48
|
}
|
|
33
49
|
function buildJsDoc(op) {
|
|
34
50
|
const lines = [];
|
package/dist/pipeline.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { OpenAPIDocument } from './types/openapi.js';
|
|
|
4
4
|
export interface GenerationOptions {
|
|
5
5
|
/** When true, sibling properties alongside $ref are preserved (OpenAPI 3.1 behavior). */
|
|
6
6
|
preserveRefSiblings?: boolean;
|
|
7
|
+
/** Effective OpenAPI dialect, sourced from `VersionProfile.effective`. */
|
|
8
|
+
effectiveVersion?: '3.0' | '3.1';
|
|
7
9
|
}
|
|
8
10
|
/**
|
|
9
11
|
* Generate and write all output files to disk: builds the resolver, runs the
|
package/dist/pipeline.js
CHANGED
|
@@ -15,6 +15,7 @@ export async function generateFullOutput(doc, config, options) {
|
|
|
15
15
|
const analyzed = analyze(doc, {
|
|
16
16
|
resolver,
|
|
17
17
|
strategy: config.methodNameStrategy ?? 'path-based',
|
|
18
|
+
effectiveVersion: options?.effectiveVersion,
|
|
18
19
|
});
|
|
19
20
|
const { contracts, client, index } = generateOutput(analyzed, config);
|
|
20
21
|
await mkdir(config.outputDir, { recursive: true });
|
package/dist/utils/proxy.d.ts
CHANGED
|
@@ -3,4 +3,6 @@ export type LoadOptions = {
|
|
|
3
3
|
};
|
|
4
4
|
export declare function assertValidProxyUrl(url: string): void;
|
|
5
5
|
export declare function hasProxyEnv(): boolean;
|
|
6
|
+
/** True when the dynamic `import('undici')` failed because undici is not installed (Node: ERR_MODULE_NOT_FOUND; Bun: "Cannot find package" message). */
|
|
7
|
+
export declare function isMissingUndiciError(err: unknown): boolean;
|
|
6
8
|
export declare function fetchSpec(url: string, opts?: LoadOptions): Promise<Response>;
|