@workos/oagen-emitters 0.12.1 → 0.12.3
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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint-pr-title.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
- package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +354 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +547 -351
- package/src/node/naming.ts +122 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/path-expression.ts +11 -4
- package/src/node/resources.ts +473 -48
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +152 -93
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +435 -2025
- package/test/node/tests.test.ts +214 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/dist/plugin-CmfzawTp.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
package/src/node/field-plan.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { Model, Field, EmitterContext, TypeRef, UnionType, PrimitiveType } from '@workos/oagen';
|
|
2
2
|
import { mapTypeRef as tsMapTypeRef } from './type-map.js';
|
|
3
3
|
import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
relativeImport,
|
|
6
|
+
buildKnownTypeNames,
|
|
7
|
+
isBaselineGeneric,
|
|
8
|
+
createServiceDirResolver,
|
|
9
|
+
modelHasNewFields,
|
|
10
|
+
} from './utils.js';
|
|
11
|
+
import { liveSurfaceHasFunction, liveSurfaceHasFile, liveSurfaceFunctionPath } from './live-surface.js';
|
|
5
12
|
|
|
6
13
|
// ---------------------------------------------------------------------------
|
|
7
|
-
// Guard strategy
|
|
14
|
+
// Guard strategy
|
|
8
15
|
// ---------------------------------------------------------------------------
|
|
9
16
|
|
|
10
17
|
type GuardStrategy =
|
|
@@ -13,10 +20,6 @@ type GuardStrategy =
|
|
|
13
20
|
| { kind: 'coalesce'; fallback: string }
|
|
14
21
|
| { kind: 'non-null-assert' };
|
|
15
22
|
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Baseline types used by planning functions
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
23
|
interface BaselineFieldInfo {
|
|
21
24
|
type: string;
|
|
22
25
|
optional: boolean;
|
|
@@ -32,9 +35,25 @@ interface BaselineInterface {
|
|
|
32
35
|
// ---------------------------------------------------------------------------
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
38
|
+
* Decide whether a `deserialize${X}` / `serialize${X}` helper will be
|
|
39
|
+
* resolvable at compile time. A helper is callable when:
|
|
40
|
+
* - the live SDK already exports it (live-surface knows), OR
|
|
41
|
+
* - the emitter is producing the dep model's serializer this run, which
|
|
42
|
+
* happens when `modelHasNewFields(dep, ctx)` says the dep needs
|
|
43
|
+
* regeneration.
|
|
44
|
+
*
|
|
45
|
+
* When neither condition holds, expression builders fall back to passing
|
|
46
|
+
* the value through unchanged — `deserializeX(wire)` and `serializeX(model)`
|
|
47
|
+
* become `wire` / `model` respectively. Safe because elided cases imply
|
|
48
|
+
* the wire and domain shapes are identical (no IR additions).
|
|
37
49
|
*/
|
|
50
|
+
function helperExists(helperName: string, depModelName: string, ctx: EmitterContext): boolean {
|
|
51
|
+
if (liveSurfaceHasFunction(helperName)) return true;
|
|
52
|
+
const depModel = ctx.spec.models.find((m) => m.name === depModelName);
|
|
53
|
+
if (!depModel) return false;
|
|
54
|
+
return modelHasNewFields(depModel, ctx);
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
export function deserializeExpression(
|
|
39
58
|
ref: TypeRef,
|
|
40
59
|
wireExpr: string,
|
|
@@ -49,11 +68,17 @@ export function deserializeExpression(
|
|
|
49
68
|
return wireExpr;
|
|
50
69
|
case 'model': {
|
|
51
70
|
const name = resolveInterfaceName(ref.name, ctx);
|
|
71
|
+
// The deserialize helper may not exist if its serializer file was
|
|
72
|
+
// elided (no baseline serializer + no new fields ⇒ no generation).
|
|
73
|
+
// Fall back to passing the wire value through — the runtime shape
|
|
74
|
+
// is identical to the domain shape in those cases.
|
|
75
|
+
if (!helperExists(`deserialize${name}`, ref.name, ctx)) return wireExpr;
|
|
52
76
|
return `deserialize${name}(${wireExpr})`;
|
|
53
77
|
}
|
|
54
78
|
case 'array':
|
|
55
79
|
if (ref.items.kind === 'model') {
|
|
56
80
|
const name = resolveInterfaceName(ref.items.name, ctx);
|
|
81
|
+
if (!helperExists(`deserialize${name}`, ref.items.name, ctx)) return wireExpr;
|
|
57
82
|
return `${wireExpr}.map(deserialize${name})`;
|
|
58
83
|
}
|
|
59
84
|
return wireExpr;
|
|
@@ -83,10 +108,6 @@ export function deserializeExpression(
|
|
|
83
108
|
}
|
|
84
109
|
}
|
|
85
110
|
|
|
86
|
-
/**
|
|
87
|
-
* Build a serialization expression for a type reference.
|
|
88
|
-
* @param nullFallback - fallback value for nullable inner expressions (default 'null')
|
|
89
|
-
*/
|
|
90
111
|
export function serializeExpression(
|
|
91
112
|
ref: TypeRef,
|
|
92
113
|
domainExpr: string,
|
|
@@ -101,11 +122,13 @@ export function serializeExpression(
|
|
|
101
122
|
return domainExpr;
|
|
102
123
|
case 'model': {
|
|
103
124
|
const name = resolveInterfaceName(ref.name, ctx);
|
|
125
|
+
if (!helperExists(`serialize${name}`, ref.name, ctx)) return domainExpr;
|
|
104
126
|
return `serialize${name}(${domainExpr})`;
|
|
105
127
|
}
|
|
106
128
|
case 'array':
|
|
107
129
|
if (ref.items.kind === 'model') {
|
|
108
130
|
const name = resolveInterfaceName(ref.items.name, ctx);
|
|
131
|
+
if (!helperExists(`serialize${name}`, ref.items.name, ctx)) return domainExpr;
|
|
109
132
|
return `${domainExpr}.map(serialize${name})`;
|
|
110
133
|
}
|
|
111
134
|
return domainExpr;
|
|
@@ -155,7 +178,6 @@ function serializePrimitive(ref: PrimitiveType, domainExpr: string): string {
|
|
|
155
178
|
// Union helpers
|
|
156
179
|
// ---------------------------------------------------------------------------
|
|
157
180
|
|
|
158
|
-
/** Extract unique model names from a union's variants. */
|
|
159
181
|
export function uniqueModelVariants(ref: UnionType): string[] {
|
|
160
182
|
const modelNames = new Set<string>();
|
|
161
183
|
for (const v of ref.variants) {
|
|
@@ -177,6 +199,10 @@ function renderDiscriminatorSwitch(
|
|
|
177
199
|
const fn = `${direction}${resolved}`;
|
|
178
200
|
cases.push(`case '${value}': return ${fn}(${expr} as any)`);
|
|
179
201
|
}
|
|
202
|
+
// No mapping → passthrough. Without this guard, an empty `disc.mapping`
|
|
203
|
+
// emits `switch { ; default: ... }` which is invalid TypeScript syntax
|
|
204
|
+
// (the leading `;` looks like a stray statement before the first case).
|
|
205
|
+
if (cases.length === 0) return expr;
|
|
180
206
|
return `(() => { switch ((${expr} as any).${disc.property}) { ${cases.join('; ')}; default: return ${expr} } })()`;
|
|
181
207
|
}
|
|
182
208
|
|
|
@@ -199,7 +225,6 @@ function renderAllOfMerge(
|
|
|
199
225
|
// Type inspection helpers
|
|
200
226
|
// ---------------------------------------------------------------------------
|
|
201
227
|
|
|
202
|
-
/** Check whether a TypeRef involves a function call in serialization. */
|
|
203
228
|
export function needsNullGuard(ref: TypeRef): boolean {
|
|
204
229
|
switch (ref.kind) {
|
|
205
230
|
case 'model':
|
|
@@ -241,11 +266,6 @@ export function hasDateTimeConversion(ref: TypeRef): boolean {
|
|
|
241
266
|
}
|
|
242
267
|
}
|
|
243
268
|
|
|
244
|
-
/**
|
|
245
|
-
* Collect model names that will actually be called in serialize/deserialize expressions.
|
|
246
|
-
* Unlike collectModelRefs (which walks all union variants), this only includes models
|
|
247
|
-
* that the expression functions will actually invoke a serializer/deserializer for.
|
|
248
|
-
*/
|
|
249
269
|
export function collectSerializedModelRefs(ref: TypeRef): string[] {
|
|
250
270
|
switch (ref.kind) {
|
|
251
271
|
case 'model':
|
|
@@ -270,7 +290,6 @@ export function collectSerializedModelRefs(ref: TypeRef): string[] {
|
|
|
270
290
|
}
|
|
271
291
|
}
|
|
272
292
|
|
|
273
|
-
/** Return a TypeScript default value expression for a type. */
|
|
274
293
|
export function defaultForType(ref: TypeRef): string | null {
|
|
275
294
|
switch (ref.kind) {
|
|
276
295
|
case 'literal':
|
|
@@ -414,10 +433,9 @@ export function serializerHasBaselineIncompatibility(
|
|
|
414
433
|
}
|
|
415
434
|
|
|
416
435
|
// ---------------------------------------------------------------------------
|
|
417
|
-
// Field assignment planning
|
|
436
|
+
// Field assignment planning
|
|
418
437
|
// ---------------------------------------------------------------------------
|
|
419
438
|
|
|
420
|
-
/** Plan a single deserializer field assignment. */
|
|
421
439
|
export function planDeserializeField(
|
|
422
440
|
field: Field,
|
|
423
441
|
baselineDomain: BaselineInterface | undefined,
|
|
@@ -429,8 +447,40 @@ export function planDeserializeField(
|
|
|
429
447
|
const wire = wireFieldName(field.name);
|
|
430
448
|
const wireAccess = `response.${wire}`;
|
|
431
449
|
const skip = skipFormatFields.has(field.name);
|
|
432
|
-
|
|
433
|
-
|
|
450
|
+
|
|
451
|
+
// Fallback selection considers both the IR field type and the baseline
|
|
452
|
+
// domain field. When baseline declares the field as `optional`
|
|
453
|
+
// (undefined-permitting) but the IR is nullable (null-only), prefer
|
|
454
|
+
// `undefined` so the deserialize output matches the baseline interface
|
|
455
|
+
// signature. Otherwise the assignment becomes
|
|
456
|
+
// `Record<...> | null → Record<...> | undefined` (TS2322).
|
|
457
|
+
const baselineDomainField = baselineDomain?.fields?.[domain];
|
|
458
|
+
const baselineDomainAcceptsNull = baselineDomainField?.type?.includes('null') ?? false;
|
|
459
|
+
let fallbackForNullable: string;
|
|
460
|
+
if (field.type.kind === 'nullable') {
|
|
461
|
+
fallbackForNullable =
|
|
462
|
+
baselineDomainField && baselineDomainField.optional && !baselineDomainAcceptsNull ? 'undefined' : 'null';
|
|
463
|
+
} else {
|
|
464
|
+
fallbackForNullable = 'undefined';
|
|
465
|
+
}
|
|
466
|
+
let expr = skip ? wireAccess : deserializeExpression(field.type, wireAccess, ctx, fallbackForNullable);
|
|
467
|
+
|
|
468
|
+
// Baseline-declared Date for an IR `string` field: the interface body
|
|
469
|
+
// uses the baseline's `Date` (line 392 in models.ts), so the serializer
|
|
470
|
+
// must convert with `new Date(...)`. The IR type doesn't carry the
|
|
471
|
+
// `format: date-time` here (the spec just said `type: string`), so
|
|
472
|
+
// `deserializeExpression` would otherwise return the raw wire access.
|
|
473
|
+
const baselineField = baselineDomain?.fields?.[domain];
|
|
474
|
+
if (
|
|
475
|
+
!skip &&
|
|
476
|
+
expr === wireAccess &&
|
|
477
|
+
baselineField?.type === 'Date' &&
|
|
478
|
+
field.type.kind === 'primitive' &&
|
|
479
|
+
field.type.type === 'string'
|
|
480
|
+
) {
|
|
481
|
+
expr = `new Date(${wireAccess})`;
|
|
482
|
+
}
|
|
483
|
+
|
|
434
484
|
const isNewField = baselineDomain && !baselineDomain.fields?.[domain];
|
|
435
485
|
const effectivelyOptional = !field.required || isNewField;
|
|
436
486
|
|
|
@@ -446,13 +496,11 @@ function planDeserializeGuard(
|
|
|
446
496
|
isNewField: boolean | null | undefined,
|
|
447
497
|
baselineResponse: BaselineInterface | undefined,
|
|
448
498
|
): GuardStrategy {
|
|
449
|
-
// Optional field with function-call expression → null check
|
|
450
499
|
if (effectivelyOptional && expr !== wireAccess && needsNullGuard(field.type)) {
|
|
451
500
|
const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
|
|
452
501
|
return { kind: 'null-check', fallback };
|
|
453
502
|
}
|
|
454
503
|
|
|
455
|
-
// Required field with direct assignment — may need coalesce fallback
|
|
456
504
|
if (field.required && expr === wireAccess) {
|
|
457
505
|
const wire = wireFieldName(field.name);
|
|
458
506
|
const responseFieldInfo = baselineResponse?.fields?.[wire];
|
|
@@ -467,7 +515,6 @@ function planDeserializeGuard(
|
|
|
467
515
|
return { kind: 'direct' };
|
|
468
516
|
}
|
|
469
517
|
|
|
470
|
-
/** Plan a single serializer field assignment. */
|
|
471
518
|
export function planSerializeField(
|
|
472
519
|
field: Field,
|
|
473
520
|
baselineDomain: BaselineInterface | undefined,
|
|
@@ -479,8 +526,37 @@ export function planSerializeField(
|
|
|
479
526
|
const domain = fieldName(field.name);
|
|
480
527
|
const domainAccess = `model.${domain}`;
|
|
481
528
|
const skip = skipFormatFields.has(field.name);
|
|
482
|
-
|
|
483
|
-
|
|
529
|
+
|
|
530
|
+
// Symmetric to `planDeserializeField`: when the baseline wire is
|
|
531
|
+
// `optional` (undefined) but the IR is nullable, fall back to
|
|
532
|
+
// `undefined` so the serialized output matches the baseline wire shape.
|
|
533
|
+
const baselineWireField = baselineResponse?.fields?.[wire];
|
|
534
|
+
const baselineWireAcceptsNull = baselineWireField?.type?.includes('null') ?? false;
|
|
535
|
+
let fallbackForNullable: string;
|
|
536
|
+
if (field.type.kind === 'nullable') {
|
|
537
|
+
fallbackForNullable =
|
|
538
|
+
baselineWireField && baselineWireField.optional && !baselineWireAcceptsNull ? 'undefined' : 'null';
|
|
539
|
+
} else {
|
|
540
|
+
fallbackForNullable = 'undefined';
|
|
541
|
+
}
|
|
542
|
+
let expr = skip ? domainAccess : serializeExpression(field.type, domainAccess, ctx, fallbackForNullable);
|
|
543
|
+
|
|
544
|
+
// Symmetric to `planDeserializeField`: when the baseline declares the
|
|
545
|
+
// domain field as `Date` but the IR carries a plain `string`, the
|
|
546
|
+
// serializer must call `.toISOString()` so the wire form gets a string
|
|
547
|
+
// back. Without this, the serializer assigns a `Date` model field into
|
|
548
|
+
// a `string` wire field — TS2322.
|
|
549
|
+
const baselineField = baselineDomain?.fields?.[domain];
|
|
550
|
+
if (
|
|
551
|
+
!skip &&
|
|
552
|
+
expr === domainAccess &&
|
|
553
|
+
baselineField?.type === 'Date' &&
|
|
554
|
+
field.type.kind === 'primitive' &&
|
|
555
|
+
field.type.type === 'string'
|
|
556
|
+
) {
|
|
557
|
+
expr = field.required ? `${domainAccess}.toISOString()` : `${domainAccess}?.toISOString()`;
|
|
558
|
+
}
|
|
559
|
+
|
|
484
560
|
const isNewSerField = baselineDomain && !baselineDomain.fields?.[domain];
|
|
485
561
|
const effectivelyOptionalSer = !field.required || isNewSerField;
|
|
486
562
|
|
|
@@ -508,14 +584,24 @@ function planSerializeGuard(
|
|
|
508
584
|
const wire = wireFieldName(field.name);
|
|
509
585
|
const domain = fieldName(field.name);
|
|
510
586
|
|
|
511
|
-
// Function-call expression for optional/nullable fields → null check
|
|
512
587
|
const shouldGuardSer = effectivelyOptionalSer || field.type.kind === 'nullable';
|
|
513
588
|
if (expr !== domainAccess && needsNullGuard(field.type) && shouldGuardSer) {
|
|
514
|
-
|
|
589
|
+
let fallback: string = field.type.kind === 'nullable' ? 'null' : 'undefined';
|
|
590
|
+
// If the wire side is required but the field guard would otherwise emit
|
|
591
|
+
// `undefined`, the assignment becomes `string | undefined → string`.
|
|
592
|
+
// Pick a non-undefined fallback that satisfies the wire type:
|
|
593
|
+
// - `null` when the wire type accepts null
|
|
594
|
+
// - the string-defaulting `defaultForType(field.type)` (e.g. `''`)
|
|
595
|
+
// for required-string wires
|
|
596
|
+
const baselineWireField = baselineResponse?.fields?.[wire];
|
|
597
|
+
const wireRequired = baselineWireField ? !baselineWireField.optional : field.required;
|
|
598
|
+
if (fallback === 'undefined' && wireRequired) {
|
|
599
|
+
const wireAcceptsNull = baselineWireField?.type?.includes('null');
|
|
600
|
+
fallback = wireAcceptsNull ? 'null' : (defaultForType(field.type) ?? 'undefined');
|
|
601
|
+
}
|
|
515
602
|
return { kind: 'null-check', fallback };
|
|
516
603
|
}
|
|
517
604
|
|
|
518
|
-
// Optional domain → required wire: needs coalesce or assert
|
|
519
605
|
const baselineWireField = baselineResponse?.fields?.[wire];
|
|
520
606
|
const baselineDomainField = baselineDomain?.fields?.[domain];
|
|
521
607
|
const isNewFieldOnExistingDomain = baselineDomain && !baselineDomainField;
|
|
@@ -532,7 +618,6 @@ function planSerializeGuard(
|
|
|
532
618
|
return { kind: 'non-null-assert' };
|
|
533
619
|
}
|
|
534
620
|
|
|
535
|
-
// Nullable with direct assignment → may need coalesce for domain-response mismatch
|
|
536
621
|
if (field.type.kind === 'nullable' && expr === domainAccess) {
|
|
537
622
|
const domainWireField2 = wireFieldName(field.name);
|
|
538
623
|
const responseBaselineField2 = baselineResponse?.fields?.[domainWireField2];
|
|
@@ -544,26 +629,23 @@ function planSerializeGuard(
|
|
|
544
629
|
responseBaselineField2.optional;
|
|
545
630
|
const fieldEffectivelyOptional = !field.required || !!isNewSerField || !!domainResponseMismatch;
|
|
546
631
|
if (fieldEffectivelyOptional) {
|
|
547
|
-
|
|
632
|
+
// The wire side may not accept `null` (e.g. `metadata?: Record<...>`).
|
|
633
|
+
// Fall back to `undefined` in that case so the assignment matches the
|
|
634
|
+
// baseline wire field's actual type.
|
|
635
|
+
const wireAcceptsNull = responseBaselineField2?.type?.includes('null') ?? true;
|
|
636
|
+
const fallback = wireAcceptsNull ? 'null' : 'undefined';
|
|
637
|
+
return { kind: 'coalesce', fallback };
|
|
548
638
|
}
|
|
549
639
|
}
|
|
550
640
|
|
|
551
641
|
return { kind: 'direct' };
|
|
552
642
|
}
|
|
553
643
|
|
|
554
|
-
// ---------------------------------------------------------------------------
|
|
555
|
-
// Field assignment emission — single function for all guard strategies
|
|
556
|
-
// ---------------------------------------------------------------------------
|
|
557
|
-
|
|
558
644
|
function emitAssignment(lhs: string, expr: string, accessExpr: string, guard: GuardStrategy): string {
|
|
559
645
|
switch (guard.kind) {
|
|
560
646
|
case 'direct':
|
|
561
647
|
return ` ${lhs}: ${expr},`;
|
|
562
648
|
case 'null-check':
|
|
563
|
-
// If the expression already contains a null guard from nullable type handling
|
|
564
|
-
// (e.g., `response.x != null ? deserializeFoo(response.x) : null`),
|
|
565
|
-
// emit it directly — the fallback was baked into the expression.
|
|
566
|
-
// Otherwise, wrap with an outer null check using the accessor.
|
|
567
649
|
if (expr.includes(`${accessExpr} != null ?`)) {
|
|
568
650
|
return ` ${lhs}: ${expr},`;
|
|
569
651
|
}
|
|
@@ -587,7 +669,6 @@ interface SerializerContext {
|
|
|
587
669
|
ctx: EmitterContext;
|
|
588
670
|
}
|
|
589
671
|
|
|
590
|
-
/** Build the import block for a serializer file. */
|
|
591
672
|
export function buildSerializerImports(
|
|
592
673
|
model: Model,
|
|
593
674
|
serializerPath: string,
|
|
@@ -598,7 +679,10 @@ export function buildSerializerImports(
|
|
|
598
679
|
): string[] {
|
|
599
680
|
const lines: string[] = [];
|
|
600
681
|
const interfacePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
601
|
-
|
|
682
|
+
// Single-form baselines (`wireInterfaceName` returns the same name as
|
|
683
|
+
// `domainName`) only export one symbol — don't duplicate the import.
|
|
684
|
+
const symbols = domainName === responseName ? domainName : `${domainName}, ${responseName}`;
|
|
685
|
+
lines.push(`import type { ${symbols} } from '${relativeImport(serializerPath, interfacePath)}';`);
|
|
602
686
|
|
|
603
687
|
const nestedModelRefs = new Set<string>();
|
|
604
688
|
for (const field of model.fields) {
|
|
@@ -610,13 +694,71 @@ export function buildSerializerImports(
|
|
|
610
694
|
for (const dep of nestedModelRefs) {
|
|
611
695
|
const depService = sctx.modelToService.get(dep);
|
|
612
696
|
const depDir = sctx.resolveDir(depService);
|
|
613
|
-
const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
|
|
614
697
|
const depName = resolveInterfaceName(dep, sctx.ctx);
|
|
698
|
+
|
|
699
|
+
// Locate the serializer file, in priority order:
|
|
700
|
+
// 1. The actual file containing `deserialize${depName}` per
|
|
701
|
+
// live-surface (e.g. `deserializeAuditLogSchema` lives in
|
|
702
|
+
// `create-audit-log-schema.serializer.ts`, not in the predictable
|
|
703
|
+
// `audit-log-schema.serializer.ts`).
|
|
704
|
+
// 2. The baseline interface's adjacent serializer file path.
|
|
705
|
+
// 3. The IR-name path — this is where the emitter writes the
|
|
706
|
+
// serializer it's producing this run.
|
|
707
|
+
const baselineSrc = (sctx.ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
|
|
708
|
+
const baselineSerializerPath = baselineSrc
|
|
709
|
+
? baselineSrc.replace('/interfaces/', '/serializers/').replace('.interface.ts', '.serializer.ts')
|
|
710
|
+
: null;
|
|
711
|
+
const irNameSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
|
|
712
|
+
|
|
713
|
+
const liveDeserPath = liveSurfaceFunctionPath(`deserialize${depName}`);
|
|
714
|
+
const liveSerPath = liveSurfaceFunctionPath(`serialize${depName}`);
|
|
715
|
+
const depSerializerPath =
|
|
716
|
+
liveDeserPath ??
|
|
717
|
+
liveSerPath ??
|
|
718
|
+
(baselineSerializerPath && liveSurfaceHasFile(baselineSerializerPath)
|
|
719
|
+
? baselineSerializerPath
|
|
720
|
+
: irNameSerializerPath);
|
|
721
|
+
|
|
615
722
|
const rel = relativeImport(serializerPath, depSerializerPath);
|
|
616
|
-
// Check the canonical name for dedup'd models
|
|
617
723
|
const canon = sctx.dedup.get(dep);
|
|
618
724
|
const depSkipSerialize =
|
|
619
725
|
sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
|
|
726
|
+
|
|
727
|
+
// Decide whether this serializer is reachable at runtime:
|
|
728
|
+
// - file on disk → honor what it exports (hasDeser/hasSer)
|
|
729
|
+
// - file NOT on disk → only safe to import if the emitter is
|
|
730
|
+
// producing the dep's serializer this run, which only happens
|
|
731
|
+
// when `modelHasNewFields` says the dep needs regeneration.
|
|
732
|
+
//
|
|
733
|
+
// Skip the import otherwise. The serializer body falls back to a
|
|
734
|
+
// pass-through expression when it can't call the helper.
|
|
735
|
+
const hasDeser = liveSurfaceHasFunction(`deserialize${depName}`);
|
|
736
|
+
const hasSer = liveSurfaceHasFunction(`serialize${depName}`);
|
|
737
|
+
const fileExists = liveSurfaceHasFile(depSerializerPath);
|
|
738
|
+
if (fileExists && !hasDeser && !hasSer) continue;
|
|
739
|
+
if (!fileExists) {
|
|
740
|
+
const depModel = sctx.ctx.spec.models.find((m) => m.name === dep);
|
|
741
|
+
const willGenerateSerializer = depModel ? modelHasNewFields(depModel, sctx.ctx) : true;
|
|
742
|
+
if (!willGenerateSerializer) continue;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Mixed: file exists, only one of the pair is exported. Import only
|
|
746
|
+
// what's present so we don't synthesize a missing symbol. The body
|
|
747
|
+
// emitter's `bodyArgExpr` already falls through when it sees a missing
|
|
748
|
+
// serialize function.
|
|
749
|
+
if (fileExists && depSkipSerialize) {
|
|
750
|
+
if (hasDeser) lines.push(`import { deserialize${depName} } from '${rel}';`);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (fileExists && !hasSer) {
|
|
754
|
+
lines.push(`import { deserialize${depName} } from '${rel}';`);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (fileExists && !hasDeser) {
|
|
758
|
+
lines.push(`import { serialize${depName} } from '${rel}';`);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
|
|
620
762
|
if (depSkipSerialize) {
|
|
621
763
|
lines.push(`import { deserialize${depName} } from '${rel}';`);
|
|
622
764
|
} else {
|
|
@@ -627,7 +769,6 @@ export function buildSerializerImports(
|
|
|
627
769
|
return lines;
|
|
628
770
|
}
|
|
629
771
|
|
|
630
|
-
/** Build the set of field names where format conversion should be skipped. */
|
|
631
772
|
export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInterface | undefined): Set<string> {
|
|
632
773
|
const skipFormatFields = new Set<string>();
|
|
633
774
|
if (baselineDomain) {
|
|
@@ -635,7 +776,6 @@ export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInte
|
|
|
635
776
|
if (skipFormatFields.has(field.name)) continue;
|
|
636
777
|
const baselineField = baselineDomain.fields?.[fieldName(field.name)];
|
|
637
778
|
if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
|
|
638
|
-
// Always convert date-time fields to Date regardless of baseline
|
|
639
779
|
if (hasDateTimeConversion(field.type)) continue;
|
|
640
780
|
skipFormatFields.add(field.name);
|
|
641
781
|
}
|
|
@@ -644,7 +784,6 @@ export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInte
|
|
|
644
784
|
return skipFormatFields;
|
|
645
785
|
}
|
|
646
786
|
|
|
647
|
-
/** Check if serialize should be skipped (baseline incompat or cascading dependency). */
|
|
648
787
|
export function shouldSkipSerializeForModel(
|
|
649
788
|
model: Model,
|
|
650
789
|
baselineResponse: BaselineInterface | undefined,
|
|
@@ -673,7 +812,6 @@ export function shouldSkipSerializeForModel(
|
|
|
673
812
|
return shouldSkip;
|
|
674
813
|
}
|
|
675
814
|
|
|
676
|
-
/** Emit deserializer + serializer body lines for a model. */
|
|
677
815
|
export function emitSerializerBody(
|
|
678
816
|
model: Model,
|
|
679
817
|
domainName: string,
|
|
@@ -687,7 +825,6 @@ export function emitSerializerBody(
|
|
|
687
825
|
): string[] {
|
|
688
826
|
const lines: string[] = [];
|
|
689
827
|
|
|
690
|
-
// Deserialize function (wire → domain)
|
|
691
828
|
const seenDeserFields = new Set<string>();
|
|
692
829
|
const deserParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
693
830
|
lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
|
|
@@ -702,7 +839,6 @@ export function emitSerializerBody(
|
|
|
702
839
|
}
|
|
703
840
|
lines.push('});');
|
|
704
841
|
|
|
705
|
-
// Serialize function (domain → wire)
|
|
706
842
|
if (!shouldSkipSerialize) {
|
|
707
843
|
const serParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
708
844
|
lines.push('');
|
package/src/node/fixtures.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
|
|
2
2
|
import { wireFieldName, fileName, resolveServiceDir } from './naming.js';
|
|
3
|
-
import { resolveResourceClassName } from './resources.js';
|
|
3
|
+
import { resolveResourceClassName, resolveResourceDir } from './resources.js';
|
|
4
4
|
import { createServiceDirResolver, assignModelsToServices, isListMetadataModel, isListWrapperModel } from './utils.js';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Prefix mapping for generating realistic ID fixture values.
|
|
8
|
-
* When a field named "id" belongs to a model whose name matches a key here,
|
|
9
|
-
* the generated ID will be prefixed accordingly (e.g. "conn_01234").
|
|
10
|
-
*/
|
|
11
6
|
export const ID_PREFIXES: Record<string, string> = {
|
|
12
7
|
Connection: 'conn_',
|
|
13
8
|
Organization: 'org_',
|
|
@@ -24,10 +19,6 @@ export const ID_PREFIXES: Record<string, string> = {
|
|
|
24
19
|
PasswordReset: 'password_reset_',
|
|
25
20
|
};
|
|
26
21
|
|
|
27
|
-
/**
|
|
28
|
-
* Generate JSON fixture files for test data.
|
|
29
|
-
* Each model that appears as a response gets a fixture in wire format (snake_case).
|
|
30
|
-
*/
|
|
31
22
|
export function generateFixtures(
|
|
32
23
|
spec: {
|
|
33
24
|
models: Model[];
|
|
@@ -48,12 +39,11 @@ export function generateFixtures(
|
|
|
48
39
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
49
40
|
const files: { path: string; content: string }[] = [];
|
|
50
41
|
|
|
51
|
-
// Only generate fixtures for models reachable from non-event operations
|
|
52
42
|
const fixtureSeeds = new Set<string>();
|
|
53
43
|
for (const svc of spec.services) {
|
|
54
44
|
if (svc.name.toLowerCase() === 'events') continue;
|
|
55
45
|
for (const op of svc.operations) {
|
|
56
|
-
const collectFromRef = (t:
|
|
46
|
+
const collectFromRef = (t: TypeRef | undefined): void => {
|
|
57
47
|
if (!t) return;
|
|
58
48
|
if (t.kind === 'model') fixtureSeeds.add(t.name);
|
|
59
49
|
if (t.kind === 'array') collectFromRef(t.items);
|
|
@@ -75,7 +65,7 @@ export function generateFixtures(
|
|
|
75
65
|
const m = fixtureModelMap.get(name);
|
|
76
66
|
if (!m) continue;
|
|
77
67
|
for (const field of m.fields) {
|
|
78
|
-
const walk = (t:
|
|
68
|
+
const walk = (t: TypeRef): void => {
|
|
79
69
|
if (t.kind === 'model' && !fixtureReachable.has(t.name)) fixtureQueue.push(t.name);
|
|
80
70
|
if (t.kind === 'array') walk(t.items);
|
|
81
71
|
if (t.kind === 'nullable') walk(t.inner);
|
|
@@ -84,6 +74,7 @@ export function generateFixtures(
|
|
|
84
74
|
walk(field.type);
|
|
85
75
|
}
|
|
86
76
|
}
|
|
77
|
+
|
|
87
78
|
const seenFixturePaths = new Set<string>();
|
|
88
79
|
for (const model of spec.models) {
|
|
89
80
|
if (!fixtureReachable.has(model.name)) continue;
|
|
@@ -94,8 +85,6 @@ export function generateFixtures(
|
|
|
94
85
|
const dirName = resolveDir(service);
|
|
95
86
|
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
|
|
96
87
|
|
|
97
|
-
// After noise suffix stripping, multiple models may resolve to the same
|
|
98
|
-
// fixture path (e.g., OrganizationDto and Organization). Skip duplicates.
|
|
99
88
|
if (seenFixturePaths.has(fixturePath)) continue;
|
|
100
89
|
seenFixturePaths.add(fixturePath);
|
|
101
90
|
|
|
@@ -107,16 +96,13 @@ export function generateFixtures(
|
|
|
107
96
|
});
|
|
108
97
|
}
|
|
109
98
|
|
|
110
|
-
// Generate list fixtures for models that appear in paginated responses
|
|
111
99
|
for (const service of spec.services) {
|
|
112
100
|
const resolvedName = ctx ? resolveResourceClassName(service, ctx) : service.name;
|
|
113
|
-
const serviceDir = resolveServiceDir(resolvedName);
|
|
101
|
+
const serviceDir = ctx ? resolveResourceDir(service, ctx) : resolveServiceDir(resolvedName);
|
|
114
102
|
for (const op of service.operations) {
|
|
115
103
|
if (op.pagination) {
|
|
116
104
|
let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
|
|
117
105
|
if (itemModel) {
|
|
118
|
-
// Detect if the "item" model is actually a list wrapper (has `data` array + `list_metadata`).
|
|
119
|
-
// If so, unwrap to the actual item type to avoid double-nesting in fixtures.
|
|
120
106
|
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
121
107
|
if (unwrapped) {
|
|
122
108
|
itemModel = unwrapped;
|
|
@@ -141,12 +127,6 @@ export function generateFixtures(
|
|
|
141
127
|
return files;
|
|
142
128
|
}
|
|
143
129
|
|
|
144
|
-
/**
|
|
145
|
-
* Detect if a model is a list wrapper (has a `data` array field and a `list_metadata` field).
|
|
146
|
-
* If so, return the inner item model from the `data` array. Otherwise return null.
|
|
147
|
-
* This prevents double-nesting when the pagination itemType points to a list wrapper
|
|
148
|
-
* instead of the actual item model.
|
|
149
|
-
*/
|
|
150
130
|
export function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
|
|
151
131
|
const dataField = model.fields.find((f) => f.name === 'data');
|
|
152
132
|
const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
|
|
@@ -159,7 +139,7 @@ export function unwrapListModel(model: Model, modelMap: Map<string, Model>): Mod
|
|
|
159
139
|
return null;
|
|
160
140
|
}
|
|
161
141
|
|
|
162
|
-
function generateModelFixture(
|
|
142
|
+
export function generateModelFixture(
|
|
163
143
|
model: Model,
|
|
164
144
|
modelMap: Map<string, Model>,
|
|
165
145
|
enumMap: Map<string, Enum>,
|
|
@@ -168,7 +148,6 @@ function generateModelFixture(
|
|
|
168
148
|
|
|
169
149
|
for (const field of model.fields) {
|
|
170
150
|
const wireName = wireFieldName(field.name);
|
|
171
|
-
// Prefer the OpenAPI example value when available on the field
|
|
172
151
|
if (field.example !== undefined) {
|
|
173
152
|
fixture[wireName] = field.example;
|
|
174
153
|
} else {
|
|
@@ -181,14 +160,14 @@ function generateModelFixture(
|
|
|
181
160
|
|
|
182
161
|
function generateFieldValue(
|
|
183
162
|
ref: TypeRef,
|
|
184
|
-
|
|
163
|
+
fName: string,
|
|
185
164
|
modelName: string,
|
|
186
165
|
modelMap: Map<string, Model>,
|
|
187
166
|
enumMap: Map<string, Enum>,
|
|
188
167
|
): any {
|
|
189
168
|
switch (ref.kind) {
|
|
190
169
|
case 'primitive':
|
|
191
|
-
return generatePrimitiveValue(ref.type, ref.format,
|
|
170
|
+
return generatePrimitiveValue(ref.type, ref.format, fName, modelName);
|
|
192
171
|
case 'literal':
|
|
193
172
|
return ref.value;
|
|
194
173
|
case 'enum': {
|
|
@@ -201,21 +180,20 @@ function generateFieldValue(
|
|
|
201
180
|
return {};
|
|
202
181
|
}
|
|
203
182
|
case 'array': {
|
|
204
|
-
// For array<enum>, use actual enum values instead of a single generated item
|
|
205
183
|
if (ref.items.kind === 'enum') {
|
|
206
184
|
const e = enumMap.get(ref.items.name);
|
|
207
185
|
if (e && e.values.length > 0) {
|
|
208
186
|
return e.values.map((v) => v.value);
|
|
209
187
|
}
|
|
210
188
|
}
|
|
211
|
-
const item = generateFieldValue(ref.items,
|
|
189
|
+
const item = generateFieldValue(ref.items, fName, modelName, modelMap, enumMap);
|
|
212
190
|
return [item];
|
|
213
191
|
}
|
|
214
192
|
case 'nullable':
|
|
215
|
-
return generateFieldValue(ref.inner,
|
|
193
|
+
return generateFieldValue(ref.inner, fName, modelName, modelMap, enumMap);
|
|
216
194
|
case 'union':
|
|
217
195
|
if (ref.variants.length > 0) {
|
|
218
|
-
return generateFieldValue(ref.variants[0],
|
|
196
|
+
return generateFieldValue(ref.variants[0], fName, modelName, modelMap, enumMap);
|
|
219
197
|
}
|
|
220
198
|
return null;
|
|
221
199
|
case 'map':
|