@workos/oagen-emitters 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BLnR-FMi.mjs → plugin-DAa-HsN5.mjs} +144 -39
- package/dist/plugin-DAa-HsN5.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/src/node/discriminated-models.ts +197 -50
- package/src/node/resources.ts +34 -10
- 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-DAa-HsN5.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.0",
|
|
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
|
}
|
|
@@ -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/resources.ts
CHANGED
|
@@ -327,6 +327,18 @@ function isValidTypeIdentifier(name: string): boolean {
|
|
|
327
327
|
return /^[A-Za-z_$][\w$]*$/.test(name);
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Extract candidate named type references from a compound type expression such
|
|
332
|
+
* as `GetAccessTokenOptions & { provider: string }`. PascalCase tokens are type
|
|
333
|
+
* names worth importing; lowercase tokens are property keys or primitives and
|
|
334
|
+
* are skipped. The caller only imports the ones that resolve to a known source
|
|
335
|
+
* file, so unrecognized PascalCase tokens (e.g. `Date`, `Record`) are harmless.
|
|
336
|
+
*/
|
|
337
|
+
function extractNamedTypeRefs(typeExpr: string): string[] {
|
|
338
|
+
const matches = typeExpr.match(/\b[A-Z][A-Za-z0-9_$]*\b/g) ?? [];
|
|
339
|
+
return [...new Set(matches)];
|
|
340
|
+
}
|
|
341
|
+
|
|
330
342
|
function autoPaginatableItemType(returnType: string | undefined): string | undefined {
|
|
331
343
|
// Match both AutoPaginatable<T> and the legacy List<T> pattern so baseline
|
|
332
344
|
// item types are extracted even when the hand-written code predates AutoPaginatable.
|
|
@@ -964,16 +976,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
964
976
|
|
|
965
977
|
const importedTypeNames = new Set<string>();
|
|
966
978
|
for (const optionType of optionObjectTypes) {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
979
|
+
if (isValidTypeIdentifier(optionType)) {
|
|
980
|
+
if (importedTypeNames.has(optionType)) continue;
|
|
981
|
+
importedTypeNames.add(optionType);
|
|
982
|
+
const sourceFile = baselineTypeSourceFile(ctx, optionType);
|
|
983
|
+
const relPath = sourceFile
|
|
984
|
+
? relativeImport(resourcePath, sourceFile)
|
|
985
|
+
: `./interfaces/${fileName(optionType)}.interface`;
|
|
986
|
+
lines.push(`import type { ${optionType} } from '${relPath}';`);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
// Compound option types (e.g. a baseline `GetAccessTokenOptions & { provider:
|
|
990
|
+
// string }`) keep their inline object literal inline, but the named type(s)
|
|
991
|
+
// they reference must still be imported. Only import names that resolve to a
|
|
992
|
+
// known source file — inline literals, primitives, and property keys have no
|
|
993
|
+
// importable source and are skipped.
|
|
994
|
+
for (const typeName of extractNamedTypeRefs(optionType)) {
|
|
995
|
+
if (importedTypeNames.has(typeName)) continue;
|
|
996
|
+
const sourceFile = baselineTypeSourceFile(ctx, typeName) ?? liveSurfaceInterfacePath(typeName);
|
|
997
|
+
if (!sourceFile) continue;
|
|
998
|
+
importedTypeNames.add(typeName);
|
|
999
|
+
lines.push(`import type { ${typeName} } from '${relativeImport(resourcePath, sourceFile)}';`);
|
|
1000
|
+
}
|
|
977
1001
|
}
|
|
978
1002
|
for (const typeName of returnTypeImports) {
|
|
979
1003
|
if (importedTypeNames.has(typeName)) continue;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { EmitterContext, ApiSpec } from '@workos/oagen';
|
|
3
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
+
import {
|
|
5
|
+
detectDiscriminatedShape,
|
|
6
|
+
generateDiscriminatedFiles,
|
|
7
|
+
type DiscriminatedPlan,
|
|
8
|
+
} from '../../src/node/discriminated-models.js';
|
|
9
|
+
|
|
10
|
+
const emptySpec: ApiSpec = {
|
|
11
|
+
name: 'Test',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
baseUrl: '',
|
|
14
|
+
services: [],
|
|
15
|
+
models: [],
|
|
16
|
+
enums: [],
|
|
17
|
+
sdk: defaultSdkBehavior(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ctx: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: emptySpec };
|
|
21
|
+
|
|
22
|
+
// A pure `oneOf` discriminated by a boolean `active` const — the Pipes token
|
|
23
|
+
// response shape that previously collapsed into a flat all-optional interface.
|
|
24
|
+
// `RawSchema` is internal to the emitter; the loose typing mirrors the raw
|
|
25
|
+
// `components.schemas` shape `detectDiscriminatedShape` consumes at runtime.
|
|
26
|
+
const rawSchemas: Record<string, any> = {
|
|
27
|
+
DataIntegrationAccessTokenResponse: {
|
|
28
|
+
oneOf: [
|
|
29
|
+
{
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
active: { type: 'boolean', const: true },
|
|
33
|
+
access_token: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
object: { type: 'string', const: 'access_token' },
|
|
37
|
+
access_token: { type: 'string' },
|
|
38
|
+
expires_at: { type: ['string', 'null'], format: 'date-time' },
|
|
39
|
+
scopes: { type: 'array', items: { type: 'string' } },
|
|
40
|
+
missing_scopes: { type: 'array', items: { type: 'string' } },
|
|
41
|
+
},
|
|
42
|
+
required: ['object', 'access_token', 'expires_at', 'scopes', 'missing_scopes'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ['active', 'access_token'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
active: { type: 'boolean', const: false },
|
|
51
|
+
error: { type: 'string', enum: ['not_installed', 'needs_reauthorization'] },
|
|
52
|
+
},
|
|
53
|
+
required: ['active', 'error'],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe('detectDiscriminatedShape — pure oneOf with boolean discriminator', () => {
|
|
60
|
+
it('detects a two-variant inline union keyed on the boolean `active`', () => {
|
|
61
|
+
const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas);
|
|
62
|
+
expect(shape).not.toBeNull();
|
|
63
|
+
expect(shape!.inlineUnion).toBe(true);
|
|
64
|
+
expect(shape!.discriminatorProperty).toBe('active');
|
|
65
|
+
expect(shape!.variants).toHaveLength(2);
|
|
66
|
+
expect(shape!.variants.map((v) => v.discriminatorValue).sort()).toEqual(['false', 'true']);
|
|
67
|
+
expect(shape!.variants.every((v) => v.discriminatorIsBoolean)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('emits a discriminated union interface (not a flat optional bag)', () => {
|
|
71
|
+
const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas)!;
|
|
72
|
+
const plan: DiscriminatedPlan = { shape, modelDir: 'pipes', depDirMap: new Map() };
|
|
73
|
+
const files = generateDiscriminatedFiles(new Map([['DataIntegrationAccessTokenResponse', plan]]), ctx);
|
|
74
|
+
|
|
75
|
+
const iface = files.find((f) => f.path.endsWith('.interface.ts'))!;
|
|
76
|
+
expect(iface).toBeDefined();
|
|
77
|
+
// Union alias, two variants, boolean discriminator (unquoted), required fields.
|
|
78
|
+
expect(iface.content).toContain('export type DataIntegrationAccessTokenResponse =');
|
|
79
|
+
expect(iface.content).toContain('active: true');
|
|
80
|
+
expect(iface.content).toContain('active: false');
|
|
81
|
+
expect(iface.content).toContain('accessToken: DataIntegrationAccessTokenResponseAccessToken');
|
|
82
|
+
expect(iface.content).toContain("error: 'not_installed' | 'needs_reauthorization'");
|
|
83
|
+
// No optional discriminator — narrowing must work.
|
|
84
|
+
expect(iface.content).not.toContain('active?: true');
|
|
85
|
+
|
|
86
|
+
const ser = files.find((f) => f.path.endsWith('.serializer.ts'))!;
|
|
87
|
+
expect(ser.content).toContain('switch (response.active)');
|
|
88
|
+
expect(ser.content).toContain('case true:');
|
|
89
|
+
expect(ser.content).toContain('case false:');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('resolves a cross-service inline-object dep to a relative import path', () => {
|
|
93
|
+
// The nested `access_token` object is a synthetic IR model. Its dep is
|
|
94
|
+
// carried in snake form (`Parent_field`) but keyed in depDirMap under the
|
|
95
|
+
// PascalCase IR name. When that model resolves to a different service dir,
|
|
96
|
+
// collectImports must emit a cross-service path rather than defaulting to
|
|
97
|
+
// a same-dir import.
|
|
98
|
+
const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas)!;
|
|
99
|
+
const depDirMap = new Map<string, string>([['DataIntegrationAccessTokenResponseAccessToken', 'connect']]);
|
|
100
|
+
const plan: DiscriminatedPlan = { shape, modelDir: 'pipes', depDirMap };
|
|
101
|
+
const files = generateDiscriminatedFiles(new Map([['DataIntegrationAccessTokenResponse', plan]]), ctx);
|
|
102
|
+
|
|
103
|
+
const iface = files.find((f) => f.path.endsWith('.interface.ts'))!;
|
|
104
|
+
expect(iface.content).toContain(
|
|
105
|
+
"from '../../connect/interfaces/data-integration-access-token-response-access-token.interface'",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|