@workos/oagen-emitters 0.17.0 → 0.18.1
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BLnR-FMi.mjs → plugin-CtU_wbid.mjs} +182 -49
- package/dist/plugin-CtU_wbid.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/src/node/client.ts +2 -2
- package/src/node/discriminated-models.ts +197 -50
- package/src/node/models.ts +44 -5
- package/src/node/options.ts +23 -0
- package/src/node/resources.ts +56 -12
- package/src/node/tests.ts +5 -3
- package/test/node/discriminated-pure-oneof.test.ts +108 -0
- package/dist/plugin-BLnR-FMi.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-CtU_wbid.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos/oagen-emitters",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "WorkOS' oagen emitters",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "WorkOS",
|
|
@@ -42,18 +42,18 @@
|
|
|
42
42
|
"@commitlint/config-conventional": "^21.0.2",
|
|
43
43
|
"@types/node": "^25.9.3",
|
|
44
44
|
"husky": "^9.1.7",
|
|
45
|
-
"oxfmt": "^0.
|
|
46
|
-
"oxlint": "^1.
|
|
45
|
+
"oxfmt": "^0.55.0",
|
|
46
|
+
"oxlint": "^1.70.0",
|
|
47
47
|
"prettier": "^3.8.4",
|
|
48
|
-
"tsdown": "^0.22.
|
|
48
|
+
"tsdown": "^0.22.3",
|
|
49
49
|
"tsx": "^4.22.4",
|
|
50
50
|
"typescript": "^6.0.3",
|
|
51
|
-
"vitest": "^4.1.
|
|
51
|
+
"vitest": "^4.1.9"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.10.0"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@workos/oagen": "^0.22.
|
|
57
|
+
"@workos/oagen": "^0.22.6"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/node/client.ts
CHANGED
|
@@ -179,7 +179,7 @@ function exportedNamesForSource(ctx: EmitterContext, sourceFile: string): string
|
|
|
179
179
|
*/
|
|
180
180
|
function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
181
181
|
const files: GeneratedFile[] = [];
|
|
182
|
-
const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
182
|
+
const { modelToService, resolveDir, serviceNameMap } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
183
183
|
const enumToService = assignEnumsToServices(spec.enums, spec.services, spec.models, ctx);
|
|
184
184
|
|
|
185
185
|
// Group interface files by directory, tracking exported symbol names
|
|
@@ -190,7 +190,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
|
|
|
190
190
|
const dirSymbols = new Map<string, Set<string>>();
|
|
191
191
|
const ownedDirNames = new Set<string>();
|
|
192
192
|
for (const service of spec.services) {
|
|
193
|
-
if (isNodeOwnedService(ctx, service.name)) {
|
|
193
|
+
if (isNodeOwnedService(ctx, service.name, serviceNameMap.get(service.name))) {
|
|
194
194
|
const dir = resolveDir(service.name);
|
|
195
195
|
ownedDirNames.add(dir);
|
|
196
196
|
// Ensure owned directories always get a barrel entry, even if no
|
|
@@ -50,13 +50,17 @@ interface FieldSpec {
|
|
|
50
50
|
modelDeps: Set<string>;
|
|
51
51
|
/** Whether the field requires date parsing/formatting (format: date-time). */
|
|
52
52
|
hasDateTime: boolean;
|
|
53
|
+
/** Inline string-enum values, rendered as a literal union (e.g. `'a' | 'b'`). */
|
|
54
|
+
enumValues?: string[];
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
interface VariantSpec {
|
|
56
58
|
/** Domain interface name suffix, e.g. `OAuth`, `M2M`. */
|
|
57
59
|
nameSuffix: string;
|
|
58
|
-
/** Discriminator
|
|
60
|
+
/** Discriminator value, e.g. `oauth`, `m2m`, or `true`/`false`. */
|
|
59
61
|
discriminatorValue: string;
|
|
62
|
+
/** Whether the discriminator value is a boolean literal (emit unquoted). */
|
|
63
|
+
discriminatorIsBoolean?: boolean;
|
|
60
64
|
/** Fields specific to this variant (excluding the discriminator). */
|
|
61
65
|
fields: FieldSpec[];
|
|
62
66
|
}
|
|
@@ -72,6 +76,13 @@ interface DiscriminatedShape {
|
|
|
72
76
|
/** Description from the OpenAPI spec, if present. */
|
|
73
77
|
discriminatorDescription?: string;
|
|
74
78
|
variants: VariantSpec[];
|
|
79
|
+
/**
|
|
80
|
+
* Set for pure `oneOf` schemas (no `allOf` base wrapper). These are emitted
|
|
81
|
+
* as an inline anonymous union (`type X = { … } | { … }`) rather than as a
|
|
82
|
+
* set of named variant interfaces, which keeps two-variant unions — e.g. the
|
|
83
|
+
* boolean-discriminated `active: true | false` token response — readable.
|
|
84
|
+
*/
|
|
85
|
+
inlineUnion?: boolean;
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
// ---------------------------------------------------------------------------
|
|
@@ -83,25 +94,45 @@ export function detectDiscriminatedShape(
|
|
|
83
94
|
rawSchemas: Record<string, RawSchema>,
|
|
84
95
|
): DiscriminatedShape | null {
|
|
85
96
|
const schema = rawSchemas[modelName];
|
|
86
|
-
if (!schema
|
|
97
|
+
if (!schema) return null;
|
|
87
98
|
|
|
88
|
-
// The expected shape: allOf contains exactly one base object and one oneOf
|
|
89
|
-
// wrapper. The base contributes shared fields; the oneOf contributes
|
|
90
|
-
// variant-specific fields.
|
|
91
99
|
let baseObject: RawSchema | null = null;
|
|
92
100
|
let oneOfVariants: RawSchema[] | null = null;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
let inlineUnion = false;
|
|
102
|
+
|
|
103
|
+
if (schema.allOf) {
|
|
104
|
+
// `allOf [base, oneOf [variant, …]]`: the base contributes shared fields;
|
|
105
|
+
// the oneOf contributes variant-specific fields. Emitted as named variant
|
|
106
|
+
// interfaces (one per variant) plus a union alias.
|
|
107
|
+
for (const member of schema.allOf) {
|
|
108
|
+
const resolved = resolveRef(member, rawSchemas);
|
|
109
|
+
if (resolved.oneOf) {
|
|
110
|
+
if (oneOfVariants) return null; // unexpected: multiple oneOf branches at top
|
|
111
|
+
oneOfVariants = resolved.oneOf;
|
|
112
|
+
} else if (resolved.properties) {
|
|
113
|
+
baseObject = mergeBase(baseObject, resolved);
|
|
114
|
+
} else if (resolved.allOf) {
|
|
115
|
+
// Nested allOf at top: walk it
|
|
116
|
+
const nestedBase = flattenObjectAllOf(resolved, rawSchemas);
|
|
117
|
+
baseObject = mergeBase(baseObject, nestedBase);
|
|
118
|
+
}
|
|
104
119
|
}
|
|
120
|
+
} else if (schema.oneOf && schema.oneOf.length >= 2) {
|
|
121
|
+
// Pure `oneOf` (no base wrapper): every branch must be a plain inline
|
|
122
|
+
// object so a shared const discriminator can tell them apart. This is the
|
|
123
|
+
// discriminated-union shape the parser would otherwise flatten into a
|
|
124
|
+
// single all-optional interface (e.g. the boolean-discriminated token
|
|
125
|
+
// response `{ active: true; … } | { active: false; … }`). The
|
|
126
|
+
// mutually-exclusive-field-group oneOf (no shared discriminator) is left
|
|
127
|
+
// alone by the `findSharedDiscriminator` check below.
|
|
128
|
+
const allInlineObjects = schema.oneOf.every(
|
|
129
|
+
(v) => v.properties && !v.$ref && (v.type === 'object' || !v.type) && !v.allOf && !v.oneOf,
|
|
130
|
+
);
|
|
131
|
+
if (!allInlineObjects) return null;
|
|
132
|
+
oneOfVariants = schema.oneOf;
|
|
133
|
+
inlineUnion = true;
|
|
134
|
+
} else {
|
|
135
|
+
return null;
|
|
105
136
|
}
|
|
106
137
|
if (!oneOfVariants || oneOfVariants.length < 2) return null;
|
|
107
138
|
|
|
@@ -120,12 +151,13 @@ export function detectDiscriminatedShape(
|
|
|
120
151
|
|
|
121
152
|
// Build variant specs.
|
|
122
153
|
const variants: VariantSpec[] = flattenedVariants
|
|
123
|
-
.map((fv) => {
|
|
124
|
-
const discValue =
|
|
125
|
-
if (
|
|
154
|
+
.map((fv): VariantSpec | null => {
|
|
155
|
+
const discValue = readConst(fv.alwaysProperties.get(discProp));
|
|
156
|
+
if (discValue === null) return null;
|
|
126
157
|
return {
|
|
127
|
-
nameSuffix: variantNameSuffix(discValue),
|
|
128
|
-
discriminatorValue: discValue,
|
|
158
|
+
nameSuffix: variantNameSuffix(String(discValue)),
|
|
159
|
+
discriminatorValue: String(discValue),
|
|
160
|
+
discriminatorIsBoolean: typeof discValue === 'boolean',
|
|
129
161
|
fields: variantFields(fv, discProp, modelName),
|
|
130
162
|
};
|
|
131
163
|
})
|
|
@@ -144,6 +176,7 @@ export function detectDiscriminatedShape(
|
|
|
144
176
|
discriminatorPropertyDomain: toCamelCase(discProp),
|
|
145
177
|
discriminatorDescription,
|
|
146
178
|
variants,
|
|
179
|
+
inlineUnion,
|
|
147
180
|
};
|
|
148
181
|
}
|
|
149
182
|
|
|
@@ -302,12 +335,12 @@ function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
|
|
|
302
335
|
const values: string[] = [];
|
|
303
336
|
for (const v of variants) {
|
|
304
337
|
const schema = v.alwaysProperties.get(propName);
|
|
305
|
-
const val =
|
|
338
|
+
const val = readConst(schema);
|
|
306
339
|
if (val === null) {
|
|
307
340
|
allConst = false;
|
|
308
341
|
break;
|
|
309
342
|
}
|
|
310
|
-
values.push(val);
|
|
343
|
+
values.push(String(val));
|
|
311
344
|
}
|
|
312
345
|
if (allConst && new Set(values).size === values.length) {
|
|
313
346
|
return propName;
|
|
@@ -316,11 +349,17 @@ function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
|
|
|
316
349
|
return null;
|
|
317
350
|
}
|
|
318
351
|
|
|
319
|
-
|
|
352
|
+
/**
|
|
353
|
+
* Read a discriminator value pinned by `const` (or a single-value `enum`).
|
|
354
|
+
* Supports string and boolean literals — the latter drives the
|
|
355
|
+
* `active: true | false` style token response union.
|
|
356
|
+
*/
|
|
357
|
+
function readConst(schema: RawSchema | undefined | null): string | boolean | null {
|
|
320
358
|
if (!schema) return null;
|
|
321
|
-
if (typeof schema.const === 'string') return schema.const;
|
|
322
|
-
if (Array.isArray(schema.enum) && schema.enum.length === 1
|
|
323
|
-
|
|
359
|
+
if (typeof schema.const === 'string' || typeof schema.const === 'boolean') return schema.const;
|
|
360
|
+
if (Array.isArray(schema.enum) && schema.enum.length === 1) {
|
|
361
|
+
const only = schema.enum[0];
|
|
362
|
+
if (typeof only === 'string' || typeof only === 'boolean') return only;
|
|
324
363
|
}
|
|
325
364
|
return null;
|
|
326
365
|
}
|
|
@@ -360,6 +399,10 @@ function buildField(rawName: string, schema: RawSchema, required: boolean, paren
|
|
|
360
399
|
const modelDeps = new Set<string>();
|
|
361
400
|
const domainType = rawSchemaToTS(schema, parentName, rawName, false, modelDeps);
|
|
362
401
|
const wireType = rawSchemaToTS(schema, parentName, rawName, true, modelDeps);
|
|
402
|
+
const enumValues =
|
|
403
|
+
Array.isArray(schema.enum) && schema.enum.length > 0 && schema.enum.every((e) => typeof e === 'string')
|
|
404
|
+
? (schema.enum as string[])
|
|
405
|
+
: undefined;
|
|
363
406
|
return {
|
|
364
407
|
name: rawName,
|
|
365
408
|
description: schema.description,
|
|
@@ -368,6 +411,7 @@ function buildField(rawName: string, schema: RawSchema, required: boolean, paren
|
|
|
368
411
|
wireType,
|
|
369
412
|
modelDeps,
|
|
370
413
|
hasDateTime: schemaHasDateTime(schema),
|
|
414
|
+
enumValues,
|
|
371
415
|
};
|
|
372
416
|
}
|
|
373
417
|
|
|
@@ -487,6 +531,15 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
|
|
|
487
531
|
depDirMap.set(rawName, irModelDir.get(stripped)!);
|
|
488
532
|
}
|
|
489
533
|
}
|
|
534
|
+
// Synthetic IR models — e.g. inline-object variant fields like the token
|
|
535
|
+
// response's nested `access_token` — have PascalCase names that never appear
|
|
536
|
+
// in rawSchemas, so the loop above misses them. Seed their directories from
|
|
537
|
+
// irModelDir so `collectImports` (which only receives `depDirMap` via the
|
|
538
|
+
// plan) can resolve a cross-service inline-object dep instead of defaulting
|
|
539
|
+
// to a possibly-wrong same-dir import.
|
|
540
|
+
for (const [irName, dir] of irModelDir) {
|
|
541
|
+
if (!depDirMap.has(irName)) depDirMap.set(irName, dir);
|
|
542
|
+
}
|
|
490
543
|
|
|
491
544
|
for (const model of models) {
|
|
492
545
|
const shape = detectDiscriminatedShape(model.name, rawSchemas);
|
|
@@ -503,7 +556,17 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
|
|
|
503
556
|
for (const d of field.modelDeps) allDeps.add(d);
|
|
504
557
|
}
|
|
505
558
|
}
|
|
506
|
-
|
|
559
|
+
// `modelDeps` may carry an inline-object synthetic name in raw form
|
|
560
|
+
// (`Parent_field`) while the resolution maps are keyed by the PascalCase IR
|
|
561
|
+
// model name (`ParentField`). Resolve either spelling before deciding a dep
|
|
562
|
+
// is unreachable — otherwise inline-object variant fields (e.g. the token
|
|
563
|
+
// response's nested `access_token`) would wrongly drop the whole plan.
|
|
564
|
+
const resolvable = (dep: string): boolean =>
|
|
565
|
+
depDirMap.has(dep) ||
|
|
566
|
+
irModelDir.has(dep) ||
|
|
567
|
+
depDirMap.has(toPascalCase(dep)) ||
|
|
568
|
+
irModelDir.has(toPascalCase(dep));
|
|
569
|
+
const hasUnresolvableDeps = [...allDeps].some((dep) => !resolvable(dep));
|
|
507
570
|
if (hasUnresolvableDeps) continue;
|
|
508
571
|
const modelDir = resolveDir(modelToService.get(model.name));
|
|
509
572
|
plans.set(model.name, { shape, modelDir, depDirMap });
|
|
@@ -525,6 +588,7 @@ export function generateDiscriminatedFiles(
|
|
|
525
588
|
|
|
526
589
|
function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): GeneratedFile {
|
|
527
590
|
const { shape, modelDir } = plan;
|
|
591
|
+
if (shape.inlineUnion) return buildInlineUnionFile(plan);
|
|
528
592
|
const domain = toPascalCase(shape.modelName);
|
|
529
593
|
const wire = wireInterfaceName(domain);
|
|
530
594
|
const lines: string[] = [];
|
|
@@ -561,6 +625,53 @@ function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gene
|
|
|
561
625
|
};
|
|
562
626
|
}
|
|
563
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Emit a pure-`oneOf` discriminated union as a single inline type alias
|
|
630
|
+
* (`export type X = { … } | { … }`) for both the domain and wire shapes. Used
|
|
631
|
+
* instead of named per-variant interfaces, which read poorly for small
|
|
632
|
+
* (often two-variant, boolean-discriminated) unions like the token response.
|
|
633
|
+
*/
|
|
634
|
+
function buildInlineUnionFile(plan: DiscriminatedPlan): GeneratedFile {
|
|
635
|
+
const { shape, modelDir } = plan;
|
|
636
|
+
const domain = toPascalCase(shape.modelName);
|
|
637
|
+
const wire = wireInterfaceName(domain);
|
|
638
|
+
const lines: string[] = [];
|
|
639
|
+
|
|
640
|
+
const imports = collectImports(plan);
|
|
641
|
+
if (imports.length > 0) {
|
|
642
|
+
for (const imp of imports) {
|
|
643
|
+
lines.push(`import type { ${imp.symbols.join(', ')} } from '${imp.path}';`);
|
|
644
|
+
}
|
|
645
|
+
lines.push('');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
lines.push(...buildInlineUnionAlias(domain, shape, /*isWire*/ false));
|
|
649
|
+
lines.push('');
|
|
650
|
+
lines.push(...buildInlineUnionAlias(wire, shape, /*isWire*/ true));
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
path: `src/${modelDir}/interfaces/${fileName(shape.modelName)}.interface.ts`,
|
|
654
|
+
content: lines.join('\n') + '\n',
|
|
655
|
+
overwriteExisting: true,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function buildInlineUnionAlias(name: string, shape: DiscriminatedShape, isWire: boolean): string[] {
|
|
660
|
+
const lines: string[] = [`export type ${name} =`];
|
|
661
|
+
shape.variants.forEach((variant, idx) => {
|
|
662
|
+
const isLast = idx === shape.variants.length - 1;
|
|
663
|
+
const discKey = isWire ? shape.discriminatorProperty : shape.discriminatorPropertyDomain;
|
|
664
|
+
const members = [`${discKey}: ${discLiteral(variant)}`];
|
|
665
|
+
for (const field of variant.fields) {
|
|
666
|
+
const key = isWire ? field.name : toCamelCase(field.name);
|
|
667
|
+
const opt = field.required ? '' : '?';
|
|
668
|
+
members.push(`${key}${opt}: ${inlineFieldType(field, isWire)}`);
|
|
669
|
+
}
|
|
670
|
+
lines.push(` | { ${members.join('; ')} }${isLast ? ';' : ''}`);
|
|
671
|
+
});
|
|
672
|
+
return lines;
|
|
673
|
+
}
|
|
674
|
+
|
|
564
675
|
function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: VariantSpec, isWire: boolean): string[] {
|
|
565
676
|
const lines: string[] = [];
|
|
566
677
|
lines.push(`export interface ${name} {`);
|
|
@@ -578,7 +689,7 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
|
|
|
578
689
|
if (shape.discriminatorDescription) {
|
|
579
690
|
lines.push(` /** ${shape.discriminatorDescription} */`);
|
|
580
691
|
}
|
|
581
|
-
lines.push(` ${discKey}:
|
|
692
|
+
lines.push(` ${discKey}: ${discLiteral(variant)};`);
|
|
582
693
|
// Variant-specific fields
|
|
583
694
|
for (const field of variant.fields) {
|
|
584
695
|
pushFieldLine(lines, field, isWire);
|
|
@@ -587,6 +698,31 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
|
|
|
587
698
|
return lines;
|
|
588
699
|
}
|
|
589
700
|
|
|
701
|
+
/**
|
|
702
|
+
* The discriminator value as a TS literal: quoted for strings (`'oauth'`),
|
|
703
|
+
* bare for booleans (`true`).
|
|
704
|
+
*/
|
|
705
|
+
function discLiteral(variant: VariantSpec): string {
|
|
706
|
+
return variant.discriminatorIsBoolean ? variant.discriminatorValue : `'${variant.discriminatorValue}'`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Field type for an inline-union member: a literal union for inline string
|
|
711
|
+
* enums, otherwise the resolved domain/wire type.
|
|
712
|
+
*
|
|
713
|
+
* The `enumValues` branch deliberately ignores `isWire`: `enumValues` is only
|
|
714
|
+
* populated for string enums (see `buildField`), whose domain and wire
|
|
715
|
+
* representations are identical literal unions. Any field whose domain/wire
|
|
716
|
+
* types actually differ (model refs, dates, non-string enums) leaves
|
|
717
|
+
* `enumValues` undefined and falls through to the type-specific branch.
|
|
718
|
+
*/
|
|
719
|
+
function inlineFieldType(field: FieldSpec, isWire: boolean): string {
|
|
720
|
+
if (field.enumValues) {
|
|
721
|
+
return field.enumValues.map((v) => `'${v}'`).join(' | ');
|
|
722
|
+
}
|
|
723
|
+
return isWire ? field.wireType : field.domainType;
|
|
724
|
+
}
|
|
725
|
+
|
|
590
726
|
function pushFieldLine(lines: string[], field: FieldSpec, isWire: boolean): void {
|
|
591
727
|
const key = isWire ? field.name : toCamelCase(field.name);
|
|
592
728
|
const opt = field.required ? '' : '?';
|
|
@@ -617,7 +753,10 @@ function collectImports(plan: DiscriminatedPlan): ImportSpec[] {
|
|
|
617
753
|
const domain = toPascalCase(dep);
|
|
618
754
|
const wire = wireInterfaceName(domain);
|
|
619
755
|
const symbols = wire !== domain ? [domain, wire] : [domain];
|
|
620
|
-
|
|
756
|
+
// `dep` may be a raw spec name or a synthetic inline-object name in snake
|
|
757
|
+
// form (`Parent_field`); `depDirMap` keys the latter under its PascalCase IR
|
|
758
|
+
// name, so fall back to that spelling before defaulting to a same-dir path.
|
|
759
|
+
const depDir = plan.depDirMap.get(dep) ?? plan.depDirMap.get(domain);
|
|
621
760
|
const baseName = fileName(toSnakeFromPascal(domain));
|
|
622
761
|
let importPath: string;
|
|
623
762
|
if (!depDir || depDir === plan.modelDir) {
|
|
@@ -660,10 +799,16 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
|
|
|
660
799
|
for (const variant of shape.variants)
|
|
661
800
|
for (const field of variant.fields) for (const d of field.modelDeps) allDeps.add(d);
|
|
662
801
|
|
|
802
|
+
// Pure-`oneOf` unions are response shapes (e.g. the token response): they are
|
|
803
|
+
// only ever deserialized, and their inline-object fields may have no
|
|
804
|
+
// serializer generated. Emit a deserializer only and import just the
|
|
805
|
+
// deserialize helpers — mirrors the published hand-written serializer.
|
|
806
|
+
const deserializeOnly = shape.inlineUnion === true;
|
|
663
807
|
for (const dep of [...allDeps].sort()) {
|
|
664
808
|
const depDomain = toPascalCase(dep);
|
|
665
809
|
const depFile = fileName(toSnakeFromPascal(depDomain));
|
|
666
|
-
|
|
810
|
+
const helpers = deserializeOnly ? `deserialize${depDomain}` : `deserialize${depDomain}, serialize${depDomain}`;
|
|
811
|
+
lines.push(`import { ${helpers} } from './${depFile}.serializer';`);
|
|
667
812
|
}
|
|
668
813
|
lines.push('');
|
|
669
814
|
|
|
@@ -671,12 +816,12 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
|
|
|
671
816
|
lines.push(`export const deserialize${domain} = (response: ${wire}): ${domain} => {`);
|
|
672
817
|
lines.push(` switch (response.${shape.discriminatorProperty}) {`);
|
|
673
818
|
for (const variant of shape.variants) {
|
|
674
|
-
lines.push(` case
|
|
819
|
+
lines.push(` case ${discLiteral(variant)}:`);
|
|
675
820
|
lines.push(` return {`);
|
|
676
821
|
for (const field of shape.baseFields) {
|
|
677
822
|
lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
|
|
678
823
|
}
|
|
679
|
-
lines.push(` ${shape.discriminatorPropertyDomain}:
|
|
824
|
+
lines.push(` ${shape.discriminatorPropertyDomain}: ${discLiteral(variant)},`);
|
|
680
825
|
for (const field of variant.fields) {
|
|
681
826
|
lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
|
|
682
827
|
}
|
|
@@ -684,29 +829,31 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
|
|
|
684
829
|
}
|
|
685
830
|
lines.push(` default:`);
|
|
686
831
|
lines.push(
|
|
687
|
-
` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${(response as
|
|
832
|
+
` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${String((response as Record<string, unknown>).${shape.discriminatorProperty})}\`);`,
|
|
688
833
|
);
|
|
689
834
|
lines.push(` }`);
|
|
690
835
|
lines.push(`};`);
|
|
691
|
-
lines.push('');
|
|
692
836
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
lines.push(`
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
lines.push(`
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
lines.push(` ${
|
|
837
|
+
if (!deserializeOnly) {
|
|
838
|
+
lines.push('');
|
|
839
|
+
// Serializer
|
|
840
|
+
lines.push(`export const serialize${domain} = (model: ${domain}): ${wire} => {`);
|
|
841
|
+
lines.push(` switch (model.${shape.discriminatorPropertyDomain}) {`);
|
|
842
|
+
for (const variant of shape.variants) {
|
|
843
|
+
lines.push(` case ${discLiteral(variant)}:`);
|
|
844
|
+
lines.push(` return {`);
|
|
845
|
+
for (const field of shape.baseFields) {
|
|
846
|
+
lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
|
|
847
|
+
}
|
|
848
|
+
lines.push(` ${shape.discriminatorProperty}: ${discLiteral(variant)},`);
|
|
849
|
+
for (const field of variant.fields) {
|
|
850
|
+
lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
|
|
851
|
+
}
|
|
852
|
+
lines.push(` };`);
|
|
705
853
|
}
|
|
706
|
-
lines.push(`
|
|
854
|
+
lines.push(` }`);
|
|
855
|
+
lines.push(`};`);
|
|
707
856
|
}
|
|
708
|
-
lines.push(` }`);
|
|
709
|
-
lines.push(`};`);
|
|
710
857
|
|
|
711
858
|
return {
|
|
712
859
|
path: `src/${modelDir}/serializers/${fileName(shape.modelName)}.serializer.ts`,
|
package/src/node/models.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
wireInterfaceName,
|
|
12
12
|
resolveMethodName,
|
|
13
13
|
isAdoptedModelName,
|
|
14
|
+
buildServiceNameMap,
|
|
14
15
|
} from './naming.js';
|
|
15
16
|
import {
|
|
16
17
|
collectFieldDependencies,
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
hasDateTimeConversion,
|
|
43
44
|
} from './field-plan.js';
|
|
44
45
|
import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
|
|
45
|
-
import { isNodeOwnedService } from './options.js';
|
|
46
|
+
import { isNodeOwnedService, isHandOwnedType } from './options.js';
|
|
46
47
|
import { unwrapListModel } from './fixtures.js';
|
|
47
48
|
import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
48
49
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
@@ -244,6 +245,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
244
245
|
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
245
246
|
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
246
247
|
if (discriminatedSkip?.has(model.name)) continue;
|
|
248
|
+
// Hand-owned types are declared in a hand-written file (e.g. generics the
|
|
249
|
+
// spec cannot express). Never generate an interface for them; references
|
|
250
|
+
// route to the existing declaration via its baseline source file.
|
|
251
|
+
if (
|
|
252
|
+
isHandOwnedType(ctx, model.name) ||
|
|
253
|
+
isHandOwnedType(ctx, resolveInterfaceName(model.name, ctx, { skipTypeAlias: true }))
|
|
254
|
+
)
|
|
255
|
+
continue;
|
|
247
256
|
const service = modelToService.get(model.name);
|
|
248
257
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
249
258
|
if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
|
|
@@ -342,6 +351,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
342
351
|
const crossServiceImports = new Map<string, { name: string; relPath: string }>();
|
|
343
352
|
const unresolvableNames = new Set<string>();
|
|
344
353
|
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services, ctx.spec.models, ctx);
|
|
354
|
+
// Resolve mounted/owned service names (e.g. IR `Directories` -> `DirectorySync`)
|
|
355
|
+
// so owned-service enum-import planning matches `generateEnums`, which keys
|
|
356
|
+
// ownership off the resolved name (see enums.ts).
|
|
357
|
+
const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
|
|
345
358
|
const resolvedEnumNames = new Map<string, string>();
|
|
346
359
|
for (const e of ctx.spec.enums) {
|
|
347
360
|
resolvedEnumNames.set(resolveInterfaceName(e.name, ctx), e.name);
|
|
@@ -375,7 +388,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
375
388
|
// even when the baseline declares the name elsewhere (usually in
|
|
376
389
|
// the very file being overwritten), so import planning must
|
|
377
390
|
// target the canonical path to agree with that emission.
|
|
378
|
-
const eEnumIsOwned = isNodeOwnedService(ctx, eService);
|
|
391
|
+
const eEnumIsOwned = isNodeOwnedService(ctx, eService, eService ? serviceNameMap.get(eService) : undefined);
|
|
379
392
|
const bSrc = eEnumIsOwned ? undefined : ((bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile);
|
|
380
393
|
const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
|
|
381
394
|
const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
@@ -453,7 +466,11 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
453
466
|
const baselineEnum = ctx.apiSurface?.enums?.[dep];
|
|
454
467
|
const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
|
|
455
468
|
const depService = enumToService.get(dep);
|
|
456
|
-
const depEnumIsOwned = isNodeOwnedService(
|
|
469
|
+
const depEnumIsOwned = isNodeOwnedService(
|
|
470
|
+
ctx,
|
|
471
|
+
depService,
|
|
472
|
+
depService ? serviceNameMap.get(depService) : undefined,
|
|
473
|
+
);
|
|
457
474
|
// Fall back to the live-surface declaration path: `generateEnums` skips
|
|
458
475
|
// emission when the enum is already declared elsewhere in the SDK (same
|
|
459
476
|
// fallback, see enums.ts), so the import must follow that declaration —
|
|
@@ -835,6 +852,12 @@ export function generateSerializers(
|
|
|
835
852
|
if (isListMetadataModel(model) && !serializerListMetadataNeeded.has(model.name)) continue;
|
|
836
853
|
if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
|
|
837
854
|
if (discriminatedSerializerSkip?.has(model.name)) continue;
|
|
855
|
+
// Hand-owned types keep their hand-written serializer (see generateModels).
|
|
856
|
+
if (
|
|
857
|
+
isHandOwnedType(ctx, model.name) ||
|
|
858
|
+
isHandOwnedType(ctx, resolveInterfaceName(model.name, ctx, { skipTypeAlias: true }))
|
|
859
|
+
)
|
|
860
|
+
continue;
|
|
838
861
|
const service = modelToService.get(model.name);
|
|
839
862
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
840
863
|
if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
|
|
@@ -979,12 +1002,28 @@ export function generateSerializers(
|
|
|
979
1002
|
}
|
|
980
1003
|
const liveRootForBarrel = ctx.outputDir ?? ctx.targetDir;
|
|
981
1004
|
for (const [dir, stems] of serializersByDir) {
|
|
982
|
-
if (liveRootForBarrel
|
|
1005
|
+
if (liveRootForBarrel) {
|
|
1006
|
+
const dirIsOwned = isNodeOwnedService(ctx, dir);
|
|
983
1007
|
const serializersDir = path.join(liveRootForBarrel, 'src', dir, 'serializers');
|
|
984
1008
|
try {
|
|
985
1009
|
for (const entry of fs.readdirSync(serializersDir)) {
|
|
986
1010
|
if (!entry.endsWith('.serializer.ts')) continue;
|
|
987
|
-
|
|
1011
|
+
const stem = entry.replace(/\.serializer\.ts$/, '');
|
|
1012
|
+
if (stems.has(stem)) continue;
|
|
1013
|
+
// Owned directories are otherwise fully regenerated. Only preserve a
|
|
1014
|
+
// hand-written serializer when it belongs to a hand-owned type (i.e.
|
|
1015
|
+
// exports `(de)serialize<HandOwnedType>`); stale auto-generated files
|
|
1016
|
+
// should be pruned and unrelated hand-written files must not collide
|
|
1017
|
+
// with generated exports.
|
|
1018
|
+
if (dirIsOwned) {
|
|
1019
|
+
const content = fs.readFileSync(path.join(serializersDir, entry), 'utf-8');
|
|
1020
|
+
if (/auto-generated by oagen/i.test(content.slice(0, 400))) continue;
|
|
1021
|
+
const serializerFns = [
|
|
1022
|
+
...content.matchAll(/export\s+(?:const|function)\s+((?:de)?serialize[A-Za-z0-9_]+)/g),
|
|
1023
|
+
].map((m) => m[1].replace(/^(?:de)?serialize/, ''));
|
|
1024
|
+
if (!serializerFns.some((typeName) => isHandOwnedType(ctx, typeName))) continue;
|
|
1025
|
+
}
|
|
1026
|
+
stems.add(stem);
|
|
988
1027
|
}
|
|
989
1028
|
} catch {
|
|
990
1029
|
// Directory doesn't exist yet — only this-pass serializers will appear.
|
package/src/node/options.ts
CHANGED
|
@@ -38,6 +38,17 @@ export interface NodeEmitterOptions {
|
|
|
38
38
|
* without affecting other languages.
|
|
39
39
|
*/
|
|
40
40
|
operationOverrides?: Record<string, OperationOverride>;
|
|
41
|
+
/**
|
|
42
|
+
* Type/model names whose hand-written declarations are authoritative even
|
|
43
|
+
* inside an owned service. The emitter will NOT generate an interface or
|
|
44
|
+
* serializer for these names; instead it treats them like a baseline type,
|
|
45
|
+
* routing imports and barrel exports to the existing hand-written file.
|
|
46
|
+
*
|
|
47
|
+
* Use this to keep hand-owned generics the OpenAPI spec cannot express
|
|
48
|
+
* (for example `DirectoryUserWithGroups<TCustomAttributes>`) while still
|
|
49
|
+
* letting oagen own the rest of the service.
|
|
50
|
+
*/
|
|
51
|
+
handOwnedTypes?: string[];
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export function nodeOptions(ctx: EmitterContext): NodeEmitterOptions {
|
|
@@ -67,3 +78,15 @@ export function isNodeOwnedService(ctx: EmitterContext, ...names: Array<string |
|
|
|
67
78
|
name !== undefined ? ownedLookupNames(name).some((candidate) => owned.has(normalizeServiceName(candidate))) : false,
|
|
68
79
|
);
|
|
69
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when `name` is a hand-owned type (see {@link NodeEmitterOptions.handOwnedTypes}).
|
|
84
|
+
* Hand-owned types are never generated; the emitter defers to the existing
|
|
85
|
+
* hand-written declaration and routes imports/barrel exports to it.
|
|
86
|
+
*/
|
|
87
|
+
export function isHandOwnedType(ctx: EmitterContext, name: string | undefined): boolean {
|
|
88
|
+
if (name === undefined) return false;
|
|
89
|
+
const configured = nodeOptions(ctx).handOwnedTypes;
|
|
90
|
+
if (!configured || configured.length === 0) return false;
|
|
91
|
+
return configured.includes(name);
|
|
92
|
+
}
|