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
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Generate TypeScript HTTP clients from OpenAPI 3.0 or 3.1 specifications.
|
|
|
4
4
|
Generated code depends only on the tiny `genoc/runtime` module. Full type safety. Bring your own HTTP client.
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/genoc)
|
|
7
|
-
[](https://nodejs.org/)
|
|
8
8
|
[](https://www.typescriptlang.org/)
|
|
9
9
|
[](./LICENSE)
|
|
10
10
|
|
|
@@ -122,7 +122,7 @@ For the full example of catching and narrowing errors, see [Error handling](./do
|
|
|
122
122
|
|
|
123
123
|
## Requirements
|
|
124
124
|
|
|
125
|
-
- Node.js 18 or later
|
|
125
|
+
- Node.js 20.18.1 or later
|
|
126
126
|
- OpenAPI 3.0.x or 3.1.x specification (JSON or YAML, file path or URL)
|
|
127
127
|
|
|
128
128
|
## License
|
package/dist/analyzer/analyze.js
CHANGED
|
@@ -1,42 +1,21 @@
|
|
|
1
1
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
2
|
import { buildSchemaRenameMap, buildTypeJsDoc, sanitizeJsDocText, sanitizeTypeName, toPascalCase, } from '../utils/generator-helpers.js';
|
|
3
3
|
import { operationEmissions, RESERVED_TYPE_NAMES } from '../utils/operation-naming.js';
|
|
4
|
+
import { buildDiscriminatorRegistry, renameAwareTypeName } from './discriminator-registry.js';
|
|
4
5
|
import { analyzePathsDetailed } from './path-analyzer.js';
|
|
5
6
|
import { SchemaMapper } from './schema-mapper.js';
|
|
6
|
-
/**
|
|
7
|
-
* If the schema is a $ref to a discriminated base type (or an array whose items
|
|
8
|
-
* are), replace the type name with the {Base}Variant union type.
|
|
9
|
-
*/
|
|
10
|
-
function substituteDiscriminatedType(tsType, schema, discriminatorInfo, renameMap) {
|
|
11
|
-
const refSchema = schema;
|
|
12
|
-
if (!refSchema || typeof refSchema !== 'object')
|
|
13
|
-
return tsType;
|
|
14
|
-
if (typeof refSchema.$ref === 'string') {
|
|
15
|
-
const rawName = refSchema.$ref.split('/').pop();
|
|
16
|
-
const schemaName = sanitizeTypeName(rawName);
|
|
17
|
-
if (schemaName && discriminatorInfo.has(schemaName)) {
|
|
18
|
-
const renamed = renameMap.get(rawName) ?? schemaName;
|
|
19
|
-
return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
if (refSchema.items && typeof refSchema.items === 'object') {
|
|
23
|
-
const items = refSchema.items;
|
|
24
|
-
if (typeof items.$ref === 'string') {
|
|
25
|
-
const rawName = items.$ref.split('/').pop();
|
|
26
|
-
const schemaName = sanitizeTypeName(rawName);
|
|
27
|
-
if (schemaName && discriminatorInfo.has(schemaName)) {
|
|
28
|
-
const renamed = renameMap.get(rawName) ?? schemaName;
|
|
29
|
-
return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return tsType;
|
|
34
|
-
}
|
|
35
7
|
/**
|
|
36
8
|
* Indent unit matching `INDENT_UNIT` in schema-mapper.ts (2 spaces, pinned by
|
|
37
9
|
* the mapper's golden tests). Kept local to avoid modifying the mapper module.
|
|
38
10
|
*/
|
|
39
11
|
const INDENT_UNIT = ' ';
|
|
12
|
+
/**
|
|
13
|
+
* Shared warning channel for analyze() and the discriminator registry —
|
|
14
|
+
* the same sink the mapper defaults to.
|
|
15
|
+
*/
|
|
16
|
+
function writeWarning(message) {
|
|
17
|
+
process.stderr.write(message);
|
|
18
|
+
}
|
|
40
19
|
/**
|
|
41
20
|
* Build a description-only type-level JSDoc comment for operation-derived
|
|
42
21
|
* types (body / response). Returns '' when the description is absent or
|
|
@@ -301,45 +280,25 @@ export function analyze(doc, opts = {}) {
|
|
|
301
280
|
const renamingTypeGenerator = (refString) => {
|
|
302
281
|
const segments = refString.split('/');
|
|
303
282
|
const rawSegment = segments[segments.length - 1] || 'unknown';
|
|
304
|
-
return renameMap
|
|
283
|
+
return renameAwareTypeName(renameMap, rawSegment);
|
|
305
284
|
};
|
|
306
|
-
const discriminatorInfo = new Map();
|
|
307
|
-
if (doc.components?.schemas) {
|
|
308
|
-
for (const [name, schema] of Object.entries(doc.components.schemas)) {
|
|
309
|
-
const resolved = resolver.resolve(schema);
|
|
310
|
-
if (resolved.discriminator) {
|
|
311
|
-
const mapping = new Map();
|
|
312
|
-
if (resolved.discriminator.mapping) {
|
|
313
|
-
for (const [key, ref] of Object.entries(resolved.discriminator.mapping)) {
|
|
314
|
-
const rawTarget = ref.split('/').pop() || key;
|
|
315
|
-
const renamedTarget = renameMap.get(rawTarget) ?? sanitizeTypeName(rawTarget);
|
|
316
|
-
mapping.set(key, renamedTarget);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
discriminatorInfo.set(sanitizeTypeName(name), {
|
|
320
|
-
rawName: name,
|
|
321
|
-
propertyName: resolved.discriminator.propertyName,
|
|
322
|
-
mapping,
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
const discriminatorTargets = new Map();
|
|
328
|
-
for (const [, info] of discriminatorInfo) {
|
|
329
|
-
for (const [mappingKey, schemaName] of info.mapping) {
|
|
330
|
-
discriminatorTargets.set(schemaName, {
|
|
331
|
-
propertyName: info.propertyName,
|
|
332
|
-
literalValue: mappingKey,
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
285
|
const allSchemaNames = new Set();
|
|
337
286
|
if (doc.components?.schemas) {
|
|
338
287
|
for (const name of Object.keys(doc.components.schemas)) {
|
|
339
288
|
allSchemaNames.add(renameMap.get(name) ?? sanitizeTypeName(name));
|
|
340
289
|
}
|
|
341
290
|
}
|
|
342
|
-
|
|
291
|
+
// Family-aware discriminator registry (plan D5) — the single knowledge
|
|
292
|
+
// source for discriminator translation: the mapper's ref-translation seam
|
|
293
|
+
// consumes it, and the always-emitted `{Base}Variant` unions below are
|
|
294
|
+
// emitted from it.
|
|
295
|
+
const discriminatorRegistry = buildDiscriminatorRegistry(doc.components?.schemas, resolver, renameMap, allSchemaNames, writeWarning);
|
|
296
|
+
const mapper = new SchemaMapper(resolver, {
|
|
297
|
+
typeNameGenerator: renamingTypeGenerator,
|
|
298
|
+
emittedNames: allSchemaNames,
|
|
299
|
+
discriminatorRegistry,
|
|
300
|
+
effectiveVersion: opts?.effectiveVersion,
|
|
301
|
+
});
|
|
343
302
|
// Section 1: Schema types
|
|
344
303
|
const schemaEntries = [];
|
|
345
304
|
if (doc.components?.schemas) {
|
|
@@ -362,17 +321,18 @@ export function analyze(doc, opts = {}) {
|
|
|
362
321
|
definition: entry.definition,
|
|
363
322
|
jsDoc: entry.jsDoc,
|
|
364
323
|
}));
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
324
|
+
// Variant unions are emitted from the registry (D6): ALWAYS (mapping
|
|
325
|
+
// values ∪ implicit oneOf/anyOf refs, deduped by rename-aware name) and
|
|
326
|
+
// under collision-safe names so a user schema named `{Base}Variant`
|
|
327
|
+
// cannot collide with the generated union.
|
|
328
|
+
for (const family of discriminatorRegistry.families.values()) {
|
|
329
|
+
if (family.variantUnionMembers.length === 0)
|
|
368
330
|
continue;
|
|
369
|
-
const unionType = subtypeNames.join(' | ');
|
|
370
|
-
const renamedBase = renameMap.get(info.rawName) ?? sanitizeTypeName(info.rawName);
|
|
371
331
|
schemaTypes.push({
|
|
372
|
-
name:
|
|
373
|
-
definition: `export type ${
|
|
332
|
+
name: family.variantUnionName,
|
|
333
|
+
definition: `export type ${family.variantUnionName} = ${family.variantUnionMembers.join(' | ')};`,
|
|
374
334
|
});
|
|
375
|
-
allSchemaNames.add(
|
|
335
|
+
allSchemaNames.add(family.variantUnionName);
|
|
376
336
|
}
|
|
377
337
|
const { operations: analyzedOperations, requestBodySchemas } = analyzePathsDetailed(doc, resolver, strategy);
|
|
378
338
|
const operations = [];
|
|
@@ -461,10 +421,9 @@ export function analyze(doc, opts = {}) {
|
|
|
461
421
|
return 'StreamResponse';
|
|
462
422
|
}
|
|
463
423
|
if (r.schema) {
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return substituted;
|
|
424
|
+
const tsType = mapper.mapSchema(r.schema, undefined, 'response').tsType;
|
|
425
|
+
r.finishedType = tsType;
|
|
426
|
+
return tsType;
|
|
468
427
|
}
|
|
469
428
|
r.finishedType = r.isVoid ? 'void' : 'unknown';
|
|
470
429
|
return r.finishedType;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { RefResolver } from '../parser/ref-resolver.js';
|
|
2
|
+
import type { SchemaObject } from '../types/openapi.js';
|
|
3
|
+
/**
|
|
4
|
+
* Family-aware discriminator registry (plan D5) — the single knowledge source
|
|
5
|
+
* for discriminator families the mapper seam will translate through:
|
|
6
|
+
*
|
|
7
|
+
* - One record per discriminator family (a component schema declaring
|
|
8
|
+
* `discriminator`), keyed by `familyId` (the base's raw component name).
|
|
9
|
+
* - Variant entries keyed by the FULL `$ref` string (mapping values are exact
|
|
10
|
+
* ref strings) plus a derived rename-aware name index.
|
|
11
|
+
* - Literals are stored PRE-ESCAPED (D8: `'` and `\` only) so the mapper never
|
|
12
|
+
* interpolates raw values into string literals.
|
|
13
|
+
* - Implicit variants (oneOf/anyOf `$ref` members absent from `mapping`) carry
|
|
14
|
+
* the RAW last ref segment as their literal (D3 precedence: mapping key >
|
|
15
|
+
* own `const` > single-value `enum` > raw segment).
|
|
16
|
+
*
|
|
17
|
+
* Field budget (D5): audited — every remaining field has a live consumer in
|
|
18
|
+
* production (seam / Variant-union emission) or in the registry unit tests.
|
|
19
|
+
* Rename-aware keying is mandatory: the name index goes through the same
|
|
20
|
+
* `buildSchemaRenameMap` machinery `analyze()` uses (see the past-bug note at
|
|
21
|
+
* schema-mapper.ts:75–78 — raw-name keying silently drops literal injection
|
|
22
|
+
* for renamed subtypes).
|
|
23
|
+
*/
|
|
24
|
+
/** Rename-aware type-name derivation — the exact expression `analyze()` wires
|
|
25
|
+
* into the mapper's `typeNameGenerator` (`renameMap.get(raw) ?? sanitized`).
|
|
26
|
+
* Single source of truth so registry name keys can never diverge from the
|
|
27
|
+
* names the mapper emits for the same `$ref`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function renameAwareTypeName(renameMap: Map<string, string>, rawName: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* One discriminator variant target inside a family. Consumers: `propertyName`
|
|
32
|
+
* → literal append + `Omit<M, quoteKey(P)>` key + union wrapper;
|
|
33
|
+
* `literalValue` → the appended literal (pre-escaped — render as
|
|
34
|
+
* `'${literalValue}'`); `isNamed` → bare-name vs inline-expansion decision;
|
|
35
|
+
* `isUnion`/`isNullable` → spine `Omit` fallback decision (D2); `familyId` →
|
|
36
|
+
* same-family spine detection (D1/D2); `refStr` → identity match at the
|
|
37
|
+
* ref-translation seam; `typeName` → bare-name emission and the name index.
|
|
38
|
+
*/
|
|
39
|
+
export interface DiscriminatorVariantEntry {
|
|
40
|
+
/** Discriminator property name declared by the family base (e.g. `'$type'`). */
|
|
41
|
+
propertyName: string;
|
|
42
|
+
/** Pre-escaped literal (D8) — the mapping key, own const/enum value, or raw ref segment. No surrounding quotes. */
|
|
43
|
+
literalValue: string;
|
|
44
|
+
/** The rename-aware derived name is an emitted component schema (`∈ allSchemaNames`). */
|
|
45
|
+
isNamed: boolean;
|
|
46
|
+
/** The target's own resolved schema declares `oneOf`/`anyOf` (spine `Omit` would drop variant props — D2 fallback). */
|
|
47
|
+
isUnion: boolean;
|
|
48
|
+
/** The target's own resolved schema is nullable (3.0 `nullable: true` or a 3.1 type array containing `'null'`). */
|
|
49
|
+
isNullable: boolean;
|
|
50
|
+
/** Owning family id — the discriminator base's raw component name. */
|
|
51
|
+
familyId: string;
|
|
52
|
+
/** The exact `$ref` string that produced this entry (mapping value or oneOf/anyOf member ref). */
|
|
53
|
+
refStr: string;
|
|
54
|
+
/** Rename-aware type name for the ref (same derivation as the mapper's `typeNameGenerator`). */
|
|
55
|
+
typeName: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One discriminator family: a component schema declaring `discriminator`
|
|
59
|
+
* (`familyId` = its raw component name). Consumers: `baseRefStr` → base-ref
|
|
60
|
+
* detection at the seam (D1: `$ref` to the base emits `variantUnionName`
|
|
61
|
+
* outside same-family spines); `variantUnionName` / `variantUnionMembers` →
|
|
62
|
+
* the always-emit `{Base}Variant` union (D6); `entriesByRef` → family
|
|
63
|
+
* construction substrate and per-family lookups.
|
|
64
|
+
*/
|
|
65
|
+
export interface DiscriminatorFamily {
|
|
66
|
+
/** Raw component name of the discriminator base — the family id. */
|
|
67
|
+
familyId: string;
|
|
68
|
+
/** Rename-aware emitted type name of the base. */
|
|
69
|
+
baseTypeName: string;
|
|
70
|
+
/** Canonical ref of the base component (`#/components/schemas/<encoded key>`). */
|
|
71
|
+
baseRefStr: string;
|
|
72
|
+
/** Discriminator property name (`discriminator.propertyName`). */
|
|
73
|
+
propertyName: string;
|
|
74
|
+
/** Collision-safe `{Base}Variant` union name (D6 — via the reserved-name machinery). */
|
|
75
|
+
variantUnionName: string;
|
|
76
|
+
/** Union member type names (rename-aware, deduped; mapping order, then unmapped oneOf/anyOf refs). */
|
|
77
|
+
variantUnionMembers: string[];
|
|
78
|
+
/** Family entries keyed by the FULL `$ref` string (mapping value or oneOf/anyOf member ref). */
|
|
79
|
+
entriesByRef: Map<string, DiscriminatorVariantEntry>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The discriminator registry built once per `analyze()` run. Consumers:
|
|
83
|
+
* `byRef`/`byName` for variant-target lookup at the ref-translation seam;
|
|
84
|
+
* `baseRefs` for D1 base-ref translation; `families` for same-family spine
|
|
85
|
+
* scoping (entry `familyId` → family) and the `{Base}Variant` union emission.
|
|
86
|
+
* Flat lookups are first-wins across families (spec order) — ambiguous
|
|
87
|
+
* cross-family targets stay reachable through their per-family maps.
|
|
88
|
+
*/
|
|
89
|
+
export interface DiscriminatorRegistry {
|
|
90
|
+
/** Families keyed by familyId (raw base component name), in spec order. */
|
|
91
|
+
families: Map<string, DiscriminatorFamily>;
|
|
92
|
+
/** Flat lookup: full refStr → entry (first-wins across families). */
|
|
93
|
+
byRef: Map<string, DiscriminatorVariantEntry>;
|
|
94
|
+
/** Flat lookup: rename-aware type name → entry (first-wins across families). */
|
|
95
|
+
byName: Map<string, DiscriminatorVariantEntry>;
|
|
96
|
+
/** Canonical base refStr → family, for D1 base-ref translation. */
|
|
97
|
+
baseRefs: Map<string, DiscriminatorFamily>;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build the family-aware discriminator registry from the component schemas.
|
|
101
|
+
*
|
|
102
|
+
* Inputs must be the SAME `renameMap` / `allSchemaNames` `analyze()` derives
|
|
103
|
+
* (the rename-aware keying discipline) — pass `buildSchemaRenameMap`'s output
|
|
104
|
+
* and the emitted-name set, never a parallel sanitization. Pure with respect
|
|
105
|
+
* to the document: no mutation of the schemas; warnings (D3 mapping-key
|
|
106
|
+
* conflicts) go to `warnSink`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function buildDiscriminatorRegistry(schemas: Record<string, SchemaObject> | undefined, resolver: RefResolver, renameMap: Map<string, string>, allSchemaNames: ReadonlySet<string>, warnSink?: (message: string) => void): DiscriminatorRegistry;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { buildSchemaRenameMap, sanitizeTypeName } from '../utils/generator-helpers.js';
|
|
2
|
+
import { RESERVED_TYPE_NAMES } from '../utils/operation-naming.js';
|
|
3
|
+
import { escapeStringLiteral } from '../utils/string.js';
|
|
4
|
+
import { parseJsonPointer } from '../utils/url.js';
|
|
5
|
+
/**
|
|
6
|
+
* Family-aware discriminator registry (plan D5) — the single knowledge source
|
|
7
|
+
* for discriminator families the mapper seam will translate through:
|
|
8
|
+
*
|
|
9
|
+
* - One record per discriminator family (a component schema declaring
|
|
10
|
+
* `discriminator`), keyed by `familyId` (the base's raw component name).
|
|
11
|
+
* - Variant entries keyed by the FULL `$ref` string (mapping values are exact
|
|
12
|
+
* ref strings) plus a derived rename-aware name index.
|
|
13
|
+
* - Literals are stored PRE-ESCAPED (D8: `'` and `\` only) so the mapper never
|
|
14
|
+
* interpolates raw values into string literals.
|
|
15
|
+
* - Implicit variants (oneOf/anyOf `$ref` members absent from `mapping`) carry
|
|
16
|
+
* the RAW last ref segment as their literal (D3 precedence: mapping key >
|
|
17
|
+
* own `const` > single-value `enum` > raw segment).
|
|
18
|
+
*
|
|
19
|
+
* Field budget (D5): audited — every remaining field has a live consumer in
|
|
20
|
+
* production (seam / Variant-union emission) or in the registry unit tests.
|
|
21
|
+
* Rename-aware keying is mandatory: the name index goes through the same
|
|
22
|
+
* `buildSchemaRenameMap` machinery `analyze()` uses (see the past-bug note at
|
|
23
|
+
* schema-mapper.ts:75–78 — raw-name keying silently drops literal injection
|
|
24
|
+
* for renamed subtypes).
|
|
25
|
+
*/
|
|
26
|
+
/** Rename-aware type-name derivation — the exact expression `analyze()` wires
|
|
27
|
+
* into the mapper's `typeNameGenerator` (`renameMap.get(raw) ?? sanitized`).
|
|
28
|
+
* Single source of truth so registry name keys can never diverge from the
|
|
29
|
+
* names the mapper emits for the same `$ref`.
|
|
30
|
+
*/
|
|
31
|
+
export function renameAwareTypeName(renameMap, rawName) {
|
|
32
|
+
return renameMap.get(rawName) ?? sanitizeTypeName(rawName);
|
|
33
|
+
}
|
|
34
|
+
function isRefObject(obj) {
|
|
35
|
+
return (obj !== null &&
|
|
36
|
+
typeof obj === 'object' &&
|
|
37
|
+
!Array.isArray(obj) &&
|
|
38
|
+
'$ref' in obj);
|
|
39
|
+
}
|
|
40
|
+
/** Decode the last segment of a `$ref` string into its raw component key (`~1`/`~0` unescaped). */
|
|
41
|
+
function rawRefSegment(refStr) {
|
|
42
|
+
const segments = refStr.startsWith('#') ? parseJsonPointer(refStr.slice(1)) : refStr.split('/');
|
|
43
|
+
return segments[segments.length - 1] ?? refStr;
|
|
44
|
+
}
|
|
45
|
+
/** Encode a component key for embedding in a `#/components/schemas/…` ref string. */
|
|
46
|
+
function encodePointerSegment(segment) {
|
|
47
|
+
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A discriminator variant's own declared literal on the discriminator
|
|
51
|
+
* property: `const` first, then a single-value `enum` (D3 steps 2–3).
|
|
52
|
+
* Returns the string form; multi-value enums and absent props yield nothing.
|
|
53
|
+
*/
|
|
54
|
+
function resolveOwnDiscriminatorLiteral(resolved, propertyName, resolver) {
|
|
55
|
+
if (!resolved)
|
|
56
|
+
return undefined;
|
|
57
|
+
let prop = resolved.properties?.[propertyName];
|
|
58
|
+
if (prop === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (isRefObject(prop)) {
|
|
61
|
+
try {
|
|
62
|
+
prop = resolver.resolveSchema(prop);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (prop.const !== undefined)
|
|
69
|
+
return { value: String(prop.const), source: 'const' };
|
|
70
|
+
if (prop.enum !== undefined && prop.enum.length === 1) {
|
|
71
|
+
return { value: String(prop.enum[0]), source: 'enum' };
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
/** Resolve a mapping/oneOf ref target for flag facts; unresolvable refs degrade to undefined (mapper-style try/catch). */
|
|
76
|
+
function resolveTargetSchema(resolver, refStr) {
|
|
77
|
+
try {
|
|
78
|
+
return resolver.resolveSchema({ $ref: refStr });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build the family-aware discriminator registry from the component schemas.
|
|
86
|
+
*
|
|
87
|
+
* Inputs must be the SAME `renameMap` / `allSchemaNames` `analyze()` derives
|
|
88
|
+
* (the rename-aware keying discipline) — pass `buildSchemaRenameMap`'s output
|
|
89
|
+
* and the emitted-name set, never a parallel sanitization. Pure with respect
|
|
90
|
+
* to the document: no mutation of the schemas; warnings (D3 mapping-key
|
|
91
|
+
* conflicts) go to `warnSink`.
|
|
92
|
+
*/
|
|
93
|
+
export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSchemaNames, warnSink) {
|
|
94
|
+
const warn = warnSink ??
|
|
95
|
+
((message) => {
|
|
96
|
+
process.stderr.write(message);
|
|
97
|
+
});
|
|
98
|
+
const registry = {
|
|
99
|
+
families: new Map(),
|
|
100
|
+
byRef: new Map(),
|
|
101
|
+
byName: new Map(),
|
|
102
|
+
baseRefs: new Map(),
|
|
103
|
+
};
|
|
104
|
+
if (!schemas)
|
|
105
|
+
return registry;
|
|
106
|
+
// Pass 1 — discover families; per family, register mapping entries first
|
|
107
|
+
// (D3: mapping key wins), then implicit oneOf/anyOf variants absent from
|
|
108
|
+
// the mapping (literal = own const > single-value enum > RAW ref segment).
|
|
109
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
110
|
+
const resolved = resolver.resolve(schema);
|
|
111
|
+
const disc = resolved.discriminator;
|
|
112
|
+
if (!disc)
|
|
113
|
+
continue;
|
|
114
|
+
const familyId = name;
|
|
115
|
+
const family = {
|
|
116
|
+
familyId,
|
|
117
|
+
baseTypeName: renameAwareTypeName(renameMap, name),
|
|
118
|
+
baseRefStr: `#/components/schemas/${encodePointerSegment(name)}`,
|
|
119
|
+
propertyName: disc.propertyName,
|
|
120
|
+
variantUnionName: '',
|
|
121
|
+
variantUnionMembers: [],
|
|
122
|
+
entriesByRef: new Map(),
|
|
123
|
+
};
|
|
124
|
+
const mappedRefStrs = new Set();
|
|
125
|
+
if (disc.mapping) {
|
|
126
|
+
for (const [mappingKey, refStr] of Object.entries(disc.mapping)) {
|
|
127
|
+
if (mappedRefStrs.has(refStr))
|
|
128
|
+
continue; // two keys → same target: first key wins
|
|
129
|
+
mappedRefStrs.add(refStr);
|
|
130
|
+
const target = resolveTargetSchema(resolver, refStr);
|
|
131
|
+
const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
|
|
132
|
+
if (own !== undefined && own.value !== mappingKey) {
|
|
133
|
+
warn(`Warning: Discriminator mapping key "${mappingKey}" for "${refStr}" conflicts with its own declared ${own.source} literal "${own.value}"; the mapping key wins.\n`);
|
|
134
|
+
}
|
|
135
|
+
const rawSegment = rawRefSegment(refStr);
|
|
136
|
+
const entry = {
|
|
137
|
+
propertyName: disc.propertyName,
|
|
138
|
+
literalValue: escapeStringLiteral(mappingKey),
|
|
139
|
+
isNamed: allSchemaNames.has(renameAwareTypeName(renameMap, rawSegment)),
|
|
140
|
+
isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
|
|
141
|
+
isNullable: target?.nullable === true ||
|
|
142
|
+
(Array.isArray(target?.type) ? target.type.includes('null') : false),
|
|
143
|
+
familyId,
|
|
144
|
+
refStr,
|
|
145
|
+
typeName: renameAwareTypeName(renameMap, rawSegment),
|
|
146
|
+
};
|
|
147
|
+
family.entriesByRef.set(refStr, entry);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const implicitRefs = [];
|
|
151
|
+
for (const member of [...(resolved.oneOf ?? []), ...(resolved.anyOf ?? [])]) {
|
|
152
|
+
if (isRefObject(member))
|
|
153
|
+
implicitRefs.push(member.$ref);
|
|
154
|
+
}
|
|
155
|
+
for (const refStr of implicitRefs) {
|
|
156
|
+
if (mappedRefStrs.has(refStr))
|
|
157
|
+
continue; // already registered via mapping
|
|
158
|
+
const target = resolveTargetSchema(resolver, refStr);
|
|
159
|
+
const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
|
|
160
|
+
const rawSegment = rawRefSegment(refStr);
|
|
161
|
+
const entry = {
|
|
162
|
+
propertyName: disc.propertyName,
|
|
163
|
+
// D3 fallback chain for unmapped variants: own const > single-value
|
|
164
|
+
// enum > RAW last ref segment (the runtime value, NOT the sanitized
|
|
165
|
+
// type name).
|
|
166
|
+
literalValue: escapeStringLiteral(own?.value ?? rawSegment),
|
|
167
|
+
isNamed: allSchemaNames.has(renameAwareTypeName(renameMap, rawSegment)),
|
|
168
|
+
isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
|
|
169
|
+
isNullable: target?.nullable === true ||
|
|
170
|
+
(Array.isArray(target?.type) ? target.type.includes('null') : false),
|
|
171
|
+
familyId,
|
|
172
|
+
refStr,
|
|
173
|
+
typeName: renameAwareTypeName(renameMap, rawSegment),
|
|
174
|
+
};
|
|
175
|
+
family.entriesByRef.set(refStr, entry);
|
|
176
|
+
}
|
|
177
|
+
registry.families.set(familyId, family);
|
|
178
|
+
}
|
|
179
|
+
// Pass 2 — flat lookups + per-family name index + union members
|
|
180
|
+
// (mapping order, then unmapped oneOf/anyOf refs; deduped by refStr and
|
|
181
|
+
// by typeName — alias refs to one emitted name collapse).
|
|
182
|
+
for (const family of registry.families.values()) {
|
|
183
|
+
const memberNames = [];
|
|
184
|
+
const seenNames = new Set();
|
|
185
|
+
for (const [refStr, entry] of family.entriesByRef) {
|
|
186
|
+
if (!registry.byRef.has(refStr))
|
|
187
|
+
registry.byRef.set(refStr, entry);
|
|
188
|
+
if (!registry.byName.has(entry.typeName))
|
|
189
|
+
registry.byName.set(entry.typeName, entry);
|
|
190
|
+
if (!seenNames.has(entry.typeName)) {
|
|
191
|
+
seenNames.add(entry.typeName);
|
|
192
|
+
memberNames.push(entry.typeName);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
family.variantUnionMembers = memberNames;
|
|
196
|
+
registry.baseRefs.set(family.baseRefStr, family);
|
|
197
|
+
}
|
|
198
|
+
// Pass 3 — collision-safe `{Base}Variant` union names (D6): candidates go
|
|
199
|
+
// through the SAME reserved-name machinery as schemas, seeded with every
|
|
200
|
+
// emitted schema name, so a user schema literally named `ThingVariant`
|
|
201
|
+
// forces the generated union to `ThingVariantModel` (no duplicate export).
|
|
202
|
+
const unionCandidates = [...registry.families.values()].map((f) => `${f.baseTypeName}Variant`);
|
|
203
|
+
const unionRenames = buildSchemaRenameMap(unionCandidates, new Set([...RESERVED_TYPE_NAMES, ...allSchemaNames]));
|
|
204
|
+
for (const family of registry.families.values()) {
|
|
205
|
+
const candidate = `${family.baseTypeName}Variant`;
|
|
206
|
+
family.variantUnionName = unionRenames.get(candidate) ?? candidate;
|
|
207
|
+
}
|
|
208
|
+
return registry;
|
|
209
|
+
}
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
2
|
import type { TypeMappingResult } from '../types/contracts.js';
|
|
3
3
|
import type { SchemaObject, ReferenceObject } from '../types/openapi.js';
|
|
4
|
+
import type { DiscriminatorRegistry } from './discriminator-registry.js';
|
|
4
5
|
/**
|
|
5
6
|
* Callback to customize how $ref strings are converted to TypeScript type names.
|
|
6
7
|
*/
|
|
7
8
|
export type TypeNameGenerator = (refString: string) => string;
|
|
9
|
+
/**
|
|
10
|
+
* Optional construction dependencies for {@link SchemaMapper}. Every field is
|
|
11
|
+
* optional and defaults exactly as the previous positional constructor
|
|
12
|
+
* parameters did.
|
|
13
|
+
*/
|
|
14
|
+
export interface SchemaMapperOptions {
|
|
15
|
+
typeNameGenerator?: TypeNameGenerator;
|
|
16
|
+
discriminatorTargets?: Map<string, {
|
|
17
|
+
propertyName: string;
|
|
18
|
+
literalValue: string;
|
|
19
|
+
}>;
|
|
20
|
+
emittedNames?: Set<string>;
|
|
21
|
+
warnSink?: (message: string) => void;
|
|
22
|
+
discriminatorRegistry?: DiscriminatorRegistry;
|
|
23
|
+
/**
|
|
24
|
+
* Effective OpenAPI dialect. Omitted is treated as '3.1' (the nullable
|
|
25
|
+
* deprecation warning fires). Pass '3.0' to silence it — 'nullable' is the
|
|
26
|
+
* correct mechanism in 3.0.
|
|
27
|
+
*/
|
|
28
|
+
effectiveVersion?: '3.0' | '3.1';
|
|
29
|
+
}
|
|
8
30
|
/**
|
|
9
31
|
* SchemaMapper converts OpenAPI 3.1 Schema Objects to TypeScript type strings.
|
|
10
32
|
*
|
|
@@ -15,14 +37,13 @@ export declare class SchemaMapper {
|
|
|
15
37
|
private readonly resolver;
|
|
16
38
|
private readonly typeNameGenerator;
|
|
17
39
|
private readonly discriminatorTargets;
|
|
18
|
-
private readonly
|
|
40
|
+
private readonly emittedNames;
|
|
41
|
+
private readonly discriminatorRegistry;
|
|
19
42
|
private readonly brandedTypes;
|
|
20
43
|
private nullableWarned;
|
|
21
44
|
private readonly warnSink;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
literalValue: string;
|
|
25
|
-
}>, reservedNames?: Set<string>, warnSink?: (message: string) => void);
|
|
45
|
+
private readonly effectiveVersion;
|
|
46
|
+
constructor(resolver: RefResolver, opts?: SchemaMapperOptions);
|
|
26
47
|
getBrandedTypes(): Map<string, {
|
|
27
48
|
name: string;
|
|
28
49
|
format: string;
|
|
@@ -37,12 +58,52 @@ export declare class SchemaMapper {
|
|
|
37
58
|
* @returns TypeMappingResult with tsType string and imports array
|
|
38
59
|
*/
|
|
39
60
|
mapSchema(schema: SchemaObject | ReferenceObject, name?: string, context?: 'request' | 'response'): TypeMappingResult;
|
|
61
|
+
/**
|
|
62
|
+
* Append the discriminator literal intersection exactly ONCE (site 2),
|
|
63
|
+
* parenthesizing a top-level union so `X | null & {...}` cannot bind the
|
|
64
|
+
* literal to the last union member only (D9).
|
|
65
|
+
*/
|
|
66
|
+
private appendDiscriminatorLiteral;
|
|
40
67
|
private getBrandTypeName;
|
|
41
68
|
private mapInternal;
|
|
69
|
+
/**
|
|
70
|
+
* The single ref-translation seam (site 1). When the mapper carries a
|
|
71
|
+
* discriminator registry, EVERY `$ref` resolves through here:
|
|
72
|
+
*
|
|
73
|
+
* - a discriminator BASE becomes `{Base}Variant` at all sites except
|
|
74
|
+
* inside a same-family member's own definition spine, where it stays
|
|
75
|
+
* the bare base name (structural inheritance, no circularity — D1);
|
|
76
|
+
* - a same-family mapping target inside a spine becomes `Omit<M, 'P'>`,
|
|
77
|
+
* falling back to inline expansion WITHOUT the literal when M is a
|
|
78
|
+
* union, nullable, unnamed or non-object (D2/D4);
|
|
79
|
+
* - a mapping target at any other site is its bare name — the literal is
|
|
80
|
+
* injected exactly once, at the target's own named definition.
|
|
81
|
+
*/
|
|
82
|
+
private translateRefThroughSeam;
|
|
83
|
+
/**
|
|
84
|
+
* D2/D4 spine rule for a same-family `$ref`. `Omit<M, 'P'>` strips the
|
|
85
|
+
* sibling's own literal so the appender can re-add ours; the inline-strip
|
|
86
|
+
* fallback covers the cases where `Omit` would silently drop data
|
|
87
|
+
* (union target: `Omit<A|B,K>` drops variant props; nullable target:
|
|
88
|
+
* `Omit<M|null,K>` collapses to `{}`; unnamed/non-object target).
|
|
89
|
+
*/
|
|
90
|
+
private mapSameFamilySpineRef;
|
|
91
|
+
/** Normalize a `#/components/schemas/…` ref to its canonically-encoded form (`a/b` ↔ `a~1b`). */
|
|
92
|
+
private canonicalSchemasRef;
|
|
93
|
+
private tryResolveRef;
|
|
94
|
+
private isObjectIshSchema;
|
|
42
95
|
private resolveDiscriminatorInfo;
|
|
43
96
|
private mapEnumValues;
|
|
44
97
|
private mapCombinator;
|
|
45
98
|
private mapDiscriminatedUnion;
|
|
99
|
+
/**
|
|
100
|
+
* D3 literal precedence for registry-mode wrapper literals (inline variant
|
|
101
|
+
* schemas and foreign refs): mapping key > own `const` on the discriminant
|
|
102
|
+
* property > single-value `enum` > RAW last ref segment (the runtime
|
|
103
|
+
* value, NOT the sanitized type name). Values are escaped for a
|
|
104
|
+
* single-quoted TS string.
|
|
105
|
+
*/
|
|
106
|
+
private resolveRegistryDiscriminantValue;
|
|
46
107
|
private resolveDiscriminantValue;
|
|
47
108
|
private mapArray;
|
|
48
109
|
/**
|