@workos/oagen-emitters 0.16.1 → 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.
@@ -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
+ });
@@ -186,6 +186,153 @@ describe('generateResources', () => {
186
186
  expect(resourceFile!.content).toContain('async listGroupsForOrganizationMembership');
187
187
  });
188
188
 
189
+ it('urlBuilder: emits a synchronous string method that builds the URL via toQueryString', () => {
190
+ // Operations marked `urlBuilder` (e.g. GET /sso/authorize) are client-side
191
+ // URL constructors: the generated method must return a string synchronously,
192
+ // serialize visible query params + defaults + inferred client fields via
193
+ // toQueryString, and concatenate onto the client base URL — no HTTP call.
194
+ const operation = {
195
+ name: 'getAuthorizationUrl',
196
+ httpMethod: 'get' as const,
197
+ path: '/sso/authorize',
198
+ pathParams: [],
199
+ queryParams: [
200
+ { name: 'connection', type: { kind: 'primitive' as const, type: 'string' as const }, required: false },
201
+ { name: 'organization', type: { kind: 'primitive' as const, type: 'string' as const }, required: false },
202
+ ],
203
+ headerParams: [],
204
+ response: { kind: 'primitive' as const, type: 'unknown' as const },
205
+ errors: [],
206
+ injectIdempotencyKey: false,
207
+ };
208
+ const service: Service = { name: 'Sso', operations: [operation] };
209
+ const spec: ApiSpec = { ...emptySpec, services: [service] };
210
+ const ctxWithResolved: EmitterContext = {
211
+ ...ctx,
212
+ spec,
213
+ emitterOptions: { ownedServices: ['Sso'] },
214
+ resolvedOperations: [
215
+ {
216
+ operation,
217
+ service,
218
+ methodName: 'get_authorization_url',
219
+ mountOn: 'Sso',
220
+ defaults: { response_type: 'code' },
221
+ inferFromClient: ['client_id'],
222
+ urlBuilder: true,
223
+ },
224
+ ],
225
+ };
226
+
227
+ const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
228
+ const resourceFile = result.find((f) => f.path === 'src/sso/sso.ts');
229
+ expect(resourceFile).toBeDefined();
230
+ const content = resourceFile!.content;
231
+
232
+ // Synchronous, string-returning — not an async HTTP wrapper.
233
+ expect(content).toMatch(/getAuthorizationUrl\(options\??: [^)]*\): string \{/);
234
+ expect(content).not.toContain('async getAuthorizationUrl');
235
+ expect(content).not.toContain('this.workos.get(');
236
+ // Query assembled client-side: visible params + constant default + inferred field.
237
+ expect(content).toContain('const query = toQueryString(');
238
+ expect(content).toContain("response_type: 'code'");
239
+ expect(content).toContain('client_id: this.workos.options.clientId');
240
+ // URL is base URL + path + query.
241
+ expect(content).toContain('return `${this.workos.baseURL}/sso/authorize?${query}`;');
242
+ // The serializer helper is imported.
243
+ expect(content).toContain("import { toQueryString } from '../common/utils/query-string';");
244
+ });
245
+
246
+ it('urlBuilder: positional convention emits a no-arg method when only injected fields supply the query', () => {
247
+ // A url builder with no path params and no visible query params takes the
248
+ // positional branch (operationHasOptionsInput is false), so the signature
249
+ // is argument-less; the query is assembled purely from inferFromClient
250
+ // (and defaults) rather than a options object.
251
+ const operation = {
252
+ name: 'getLogoutUrl',
253
+ httpMethod: 'get' as const,
254
+ path: '/sso/logout',
255
+ pathParams: [],
256
+ queryParams: [],
257
+ headerParams: [],
258
+ response: { kind: 'primitive' as const, type: 'unknown' as const },
259
+ errors: [],
260
+ injectIdempotencyKey: false,
261
+ };
262
+ const service: Service = { name: 'Sso', operations: [operation] };
263
+ const spec: ApiSpec = { ...emptySpec, services: [service] };
264
+ const ctxWithResolved: EmitterContext = {
265
+ ...ctx,
266
+ spec,
267
+ emitterOptions: { ownedServices: ['Sso'] },
268
+ resolvedOperations: [
269
+ {
270
+ operation,
271
+ service,
272
+ methodName: 'get_logout_url',
273
+ mountOn: 'Sso',
274
+ defaults: {},
275
+ inferFromClient: ['client_id'],
276
+ urlBuilder: true,
277
+ },
278
+ ],
279
+ };
280
+
281
+ const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
282
+ const content = result.find((f) => f.path === 'src/sso/sso.ts')!.content;
283
+
284
+ // No options object and no path params: the signature takes no arguments.
285
+ expect(content).toMatch(/getLogoutUrl\(\): string \{/);
286
+ expect(content).not.toContain('async getLogoutUrl');
287
+ // Query built entirely from the injected client field.
288
+ expect(content).toContain('const query = toQueryString(');
289
+ expect(content).toContain('client_id: this.workos.options.clientId');
290
+ expect(content).toContain('return `${this.workos.baseURL}/sso/logout?${query}`;');
291
+ });
292
+
293
+ it('urlBuilder: with no query at all returns the bare base URL + path and skips the toQueryString import', () => {
294
+ // hasQuery is false (no visible params, defaults, or inferFromClient), so
295
+ // the method returns base URL + path with no `?${query}` segment, and the
296
+ // serializer import must not appear when nothing in the service uses it.
297
+ const operation = {
298
+ name: 'getJwksUrl',
299
+ httpMethod: 'get' as const,
300
+ path: '/sso/jwks',
301
+ pathParams: [],
302
+ queryParams: [],
303
+ headerParams: [],
304
+ response: { kind: 'primitive' as const, type: 'unknown' as const },
305
+ errors: [],
306
+ injectIdempotencyKey: false,
307
+ };
308
+ const service: Service = { name: 'Sso', operations: [operation] };
309
+ const spec: ApiSpec = { ...emptySpec, services: [service] };
310
+ const ctxWithResolved: EmitterContext = {
311
+ ...ctx,
312
+ spec,
313
+ emitterOptions: { ownedServices: ['Sso'] },
314
+ resolvedOperations: [
315
+ {
316
+ operation,
317
+ service,
318
+ methodName: 'get_jwks_url',
319
+ mountOn: 'Sso',
320
+ defaults: {},
321
+ inferFromClient: [],
322
+ urlBuilder: true,
323
+ },
324
+ ],
325
+ };
326
+
327
+ const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
328
+ const content = result.find((f) => f.path === 'src/sso/sso.ts')!.content;
329
+
330
+ expect(content).toMatch(/getJwksUrl\(\): string \{/);
331
+ expect(content).toContain('return `${this.workos.baseURL}/sso/jwks`;');
332
+ expect(content).not.toContain('toQueryString');
333
+ expect(content).not.toContain("import { toQueryString } from '../common/utils/query-string';");
334
+ });
335
+
189
336
  it('options-object: URL template binds to the SDK field name, not the spec path-param name', () => {
190
337
  // When the spec uses `omId` as a path-param name but the baseline options
191
338
  // interface exposes `organizationMembershipId`, both the destructure and
@@ -214,15 +214,66 @@ describe('rust/resources', () => {
214
214
  const f = generateResources(services, ctxWithWrapper, new UnionRegistry()).find(
215
215
  (x) => x.path === 'src/resources/user_management.rs',
216
216
  )!;
217
- // Inferred fields read from the runtime client, not empty literals.
218
- expect(f.content).toContain('"client_id": self.client.client_id()');
219
- expect(f.content).toContain('"client_secret": self.client.api_key()');
217
+ // Inferred fields read from the runtime client and inserted only when
218
+ // non-empty, so secretless clients (PKCE flows) omit them entirely.
219
+ expect(f.content).toContain('let mut body = serde_json::json!({');
220
+ expect(f.content).toContain('if !self.client.client_id().is_empty() {');
221
+ expect(f.content).toContain('body["client_id"] = serde_json::Value::String(self.client.client_id().to_string());');
222
+ expect(f.content).toContain('if !self.client.api_key().is_empty() {');
223
+ expect(f.content).toContain(
224
+ 'body["client_secret"] = serde_json::Value::String(self.client.api_key().to_string());',
225
+ );
220
226
  expect(f.content).not.toContain('"client_id": "",');
221
227
  expect(f.content).not.toContain('"client_secret": "",');
228
+ expect(f.content).not.toContain('"client_secret": self.client.api_key()');
222
229
  // Defaults are still emitted as literal JSON values.
223
230
  expect(f.content).toContain('"grant_type": "authorization_code"');
224
231
  });
225
232
 
233
+ it('throws on an unrecognized inferFromClient field instead of dropping it', () => {
234
+ // With the `if !expr.is_empty()` guard, an unknown field falling back to an
235
+ // empty literal would silently vanish from every request body (and emit
236
+ // dead `if !"".is_empty()` Rust). Generation must fail loud instead so a
237
+ // missing accessor is caught at build time, not shipped in a broken SDK.
238
+ const services: Service[] = [
239
+ {
240
+ name: 'UserManagement',
241
+ operations: [
242
+ {
243
+ name: 'authenticate',
244
+ httpMethod: 'post',
245
+ path: '/user_management/authenticate',
246
+ pathParams: [],
247
+ queryParams: [],
248
+ headerParams: [],
249
+ response: { kind: 'model', name: 'AuthenticateResponse' },
250
+ errors: [],
251
+ injectIdempotencyKey: false,
252
+ },
253
+ ],
254
+ },
255
+ ];
256
+ const baseCtx = ctxWithResolved(services);
257
+ const ctxWithWrapper: EmitterContext = {
258
+ ...baseCtx,
259
+ resolvedOperations: baseCtx.resolvedOperations!.map((r) => ({
260
+ ...r,
261
+ wrappers: [
262
+ {
263
+ name: 'authenticate_with_code',
264
+ targetVariant: 'AuthorizationCodeSessionAuthenticateRequest',
265
+ defaults: { grant_type: 'authorization_code' },
266
+ inferFromClient: ['client_id', 'tenant_id'],
267
+ exposedParams: ['code'],
268
+ optionalParams: [],
269
+ responseModelName: null,
270
+ },
271
+ ],
272
+ })),
273
+ };
274
+ expect(() => generateResources(services, ctxWithWrapper, new UnionRegistry())).toThrow(/tenant_id/);
275
+ });
276
+
226
277
  it('renders spec-level parameter defaults as doc comments', () => {
227
278
  const services: Service[] = [
228
279
  {
@@ -663,6 +714,95 @@ describe('rust/resources', () => {
663
714
  expect(f.content).not.toContain('list_events_auto_paging');
664
715
  });
665
716
 
717
+ it('decodes inline-envelope list responses into Page<T> instead of Vec<T>', () => {
718
+ // Spec responses shaped `{ object, data: [...], list_metadata }` without a
719
+ // named component reach the IR as a bare array plus `pagination.dataPath`.
720
+ // The wire format is still the envelope, so the method must decode
721
+ // `crate::pagination::Page<T>` — `Vec<T>` fails with a serde type error.
722
+ const services: Service[] = [
723
+ {
724
+ name: 'Memberships',
725
+ operations: [
726
+ {
727
+ name: 'listMemberships',
728
+ httpMethod: 'get',
729
+ path: '/memberships',
730
+ pathParams: [],
731
+ queryParams: [
732
+ {
733
+ name: 'after',
734
+ type: { kind: 'primitive', type: 'string' },
735
+ required: false,
736
+ },
737
+ ],
738
+ headerParams: [],
739
+ response: { kind: 'array', items: { kind: 'model', name: 'Membership' } },
740
+ errors: [],
741
+ injectIdempotencyKey: false,
742
+ pagination: {
743
+ strategy: 'cursor',
744
+ param: 'after',
745
+ dataPath: 'data',
746
+ itemType: { kind: 'model', name: 'Membership' },
747
+ },
748
+ },
749
+ ],
750
+ },
751
+ ];
752
+ const baseCtx = ctxWithResolved(services);
753
+ const ctx: EmitterContext = {
754
+ ...baseCtx,
755
+ spec: { ...baseCtx.spec, models: [{ name: 'Membership', fields: [] }] },
756
+ };
757
+ const f = generateResources(services, ctx, new UnionRegistry()).find(
758
+ (x) => x.path === 'src/resources/memberships.rs',
759
+ )!;
760
+ expect(f.content).toContain('Result<crate::pagination::Page<Membership>, Error>');
761
+ expect(f.content).not.toContain('Result<Vec<Membership>, Error>');
762
+ // Page<T> carries `data` + `list_metadata.after`, so auto-paging works too.
763
+ expect(f.content).toContain('list_memberships_auto_paging');
764
+ expect(f.content).toContain('Ok((page.data, page.list_metadata.after))');
765
+ });
766
+
767
+ it('keeps Vec<T> for true bare-array responses without an envelope', () => {
768
+ // A response that really is a JSON array (e.g. /users/{id}/identities)
769
+ // has no pagination dataPath — it must keep decoding into Vec<T>.
770
+ const services: Service[] = [
771
+ {
772
+ name: 'Identities',
773
+ operations: [
774
+ {
775
+ name: 'getUserIdentities',
776
+ httpMethod: 'get',
777
+ path: '/users/{id}/identities',
778
+ pathParams: [
779
+ {
780
+ name: 'id',
781
+ type: { kind: 'primitive', type: 'string' },
782
+ required: true,
783
+ },
784
+ ],
785
+ queryParams: [],
786
+ headerParams: [],
787
+ response: { kind: 'array', items: { kind: 'model', name: 'Identity' } },
788
+ errors: [],
789
+ injectIdempotencyKey: false,
790
+ },
791
+ ],
792
+ },
793
+ ];
794
+ const baseCtx = ctxWithResolved(services);
795
+ const ctx: EmitterContext = {
796
+ ...baseCtx,
797
+ spec: { ...baseCtx.spec, models: [{ name: 'Identity', fields: [] }] },
798
+ };
799
+ const f = generateResources(services, ctx, new UnionRegistry()).find(
800
+ (x) => x.path === 'src/resources/identities.rs',
801
+ )!;
802
+ expect(f.content).toContain('Result<Vec<Identity>, Error>');
803
+ expect(f.content).not.toContain('crate::pagination::Page');
804
+ });
805
+
666
806
  it('adds serialize_with attribute on Vec query params with explode=false', () => {
667
807
  const services: Service[] = [
668
808
  {
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Model, TypeRef, UnionType } from '@workos/oagen';
3
+ import { flattenDiscriminatedUnionFields } from '../../src/shared/union-flatten.js';
4
+
5
+ /** The `ApiKey.owner` discriminated-union shape from the WorkOS spec. */
6
+ function ownerUnion(): UnionType {
7
+ return {
8
+ kind: 'union',
9
+ compositionKind: 'oneOf',
10
+ discriminator: { property: 'type', mapping: { organization: 'ApiKeyOwner', user: 'UserApiKeyOwner' } },
11
+ variants: [
12
+ { kind: 'model', name: 'ApiKeyOwner' },
13
+ { kind: 'model', name: 'UserApiKeyOwner' },
14
+ ],
15
+ };
16
+ }
17
+
18
+ function baseModels(ownerType: TypeRef): Model[] {
19
+ return [
20
+ {
21
+ name: 'ApiKey',
22
+ fields: [{ name: 'owner', type: ownerType, required: true }],
23
+ },
24
+ {
25
+ name: 'ApiKeyOwner',
26
+ fields: [
27
+ { name: 'type', type: { kind: 'literal', value: 'organization' }, required: true },
28
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
29
+ ],
30
+ },
31
+ {
32
+ name: 'UserApiKeyOwner',
33
+ fields: [
34
+ { name: 'type', type: { kind: 'literal', value: 'user' }, required: true },
35
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
36
+ { name: 'organization_id', type: { kind: 'primitive', type: 'string' }, required: true },
37
+ ],
38
+ },
39
+ ];
40
+ }
41
+
42
+ describe('flattenDiscriminatedUnionFields', () => {
43
+ it('rewrites a discriminated-union field to a ref to the first variant', () => {
44
+ const out = flattenDiscriminatedUnionFields(baseModels(ownerUnion()));
45
+ const apiKey = out.find((m) => m.name === 'ApiKey')!;
46
+ expect(apiKey.fields[0].type).toEqual({ kind: 'model', name: 'ApiKeyOwner' });
47
+ });
48
+
49
+ it('merges later-variant fields into the canonical model as optional', () => {
50
+ const out = flattenDiscriminatedUnionFields(baseModels(ownerUnion()));
51
+ const canonical = out.find((m) => m.name === 'ApiKeyOwner')!;
52
+ const orgId = canonical.fields.find((f) => f.name === 'organization_id');
53
+ expect(orgId).toBeDefined();
54
+ expect(orgId!.required).toBe(false);
55
+ // Fields shared (and required) by every variant stay required.
56
+ expect(canonical.fields.find((f) => f.name === 'id')!.required).toBe(true);
57
+ });
58
+
59
+ it('widens the discriminator property to a union of its literal values', () => {
60
+ const out = flattenDiscriminatedUnionFields(baseModels(ownerUnion()));
61
+ const canonical = out.find((m) => m.name === 'ApiKeyOwner')!;
62
+ const typeField = canonical.fields.find((f) => f.name === 'type')!;
63
+ expect(typeField.type).toEqual({
64
+ kind: 'union',
65
+ variants: [
66
+ { kind: 'literal', value: 'organization' },
67
+ { kind: 'literal', value: 'user' },
68
+ ],
69
+ });
70
+ });
71
+
72
+ it('flattens the union when wrapped in nullable', () => {
73
+ const out = flattenDiscriminatedUnionFields(baseModels({ kind: 'nullable', inner: ownerUnion() }));
74
+ const apiKey = out.find((m) => m.name === 'ApiKey')!;
75
+ expect(apiKey.fields[0].type).toEqual({ kind: 'nullable', inner: { kind: 'model', name: 'ApiKeyOwner' } });
76
+ });
77
+
78
+ it('leaves a non-discriminated (untagged) primitive union untouched', () => {
79
+ // AuditLogEvent actor metadata: string | number | boolean — no discriminator.
80
+ const union: TypeRef = {
81
+ kind: 'union',
82
+ compositionKind: 'anyOf',
83
+ variants: [
84
+ { kind: 'primitive', type: 'string' },
85
+ { kind: 'primitive', type: 'number' },
86
+ { kind: 'primitive', type: 'boolean' },
87
+ ],
88
+ };
89
+ const models: Model[] = [{ name: 'Meta', fields: [{ name: 'value', type: union, required: false }] }];
90
+ expect(flattenDiscriminatedUnionFields(models)).toBe(models);
91
+ });
92
+
93
+ it('skips a union whose variants are dispatcher (discriminated) models', () => {
94
+ // Event-style union: each variant is itself a discriminated base. Must not flatten.
95
+ const union: UnionType = {
96
+ kind: 'union',
97
+ discriminator: { property: 'event', mapping: { a: 'EventA', b: 'EventB' } },
98
+ variants: [
99
+ { kind: 'model', name: 'EventA' },
100
+ { kind: 'model', name: 'EventB' },
101
+ ],
102
+ };
103
+ const models: Model[] = [
104
+ { name: 'Envelope', fields: [{ name: 'data', type: union, required: true }] },
105
+ { name: 'EventA', fields: [], discriminator: { property: 'x', mapping: {} } },
106
+ { name: 'EventB', fields: [], discriminator: { property: 'x', mapping: {} } },
107
+ ];
108
+ const out = flattenDiscriminatedUnionFields(models);
109
+ expect(out.find((m) => m.name === 'Envelope')!.fields[0].type).toBe(union);
110
+ });
111
+
112
+ it('does not throw when the same union is referenced by multiple container fields', () => {
113
+ // Re-planning the same union (canonical re-seen with an identical merge) is
114
+ // harmless and must not trip the collision guard.
115
+ const models = baseModels(ownerUnion());
116
+ models.push({ name: 'ApiKeyList', fields: [{ name: 'owner', type: ownerUnion(), required: false }] });
117
+ const out = flattenDiscriminatedUnionFields(models);
118
+ expect(out.find((m) => m.name === 'ApiKey')!.fields[0].type).toEqual({ kind: 'model', name: 'ApiKeyOwner' });
119
+ expect(out.find((m) => m.name === 'ApiKeyList')!.fields[0].type).toEqual({ kind: 'model', name: 'ApiKeyOwner' });
120
+ });
121
+
122
+ it('throws when two distinct unions share a first variant with differing merges', () => {
123
+ const unionA: UnionType = {
124
+ kind: 'union',
125
+ discriminator: { property: 'type', mapping: { shared: 'Shared', a: 'VariantA' } },
126
+ variants: [
127
+ { kind: 'model', name: 'Shared' },
128
+ { kind: 'model', name: 'VariantA' },
129
+ ],
130
+ };
131
+ const unionB: UnionType = {
132
+ kind: 'union',
133
+ discriminator: { property: 'type', mapping: { shared: 'Shared', b: 'VariantB' } },
134
+ variants: [
135
+ { kind: 'model', name: 'Shared' },
136
+ { kind: 'model', name: 'VariantB' },
137
+ ],
138
+ };
139
+ const models: Model[] = [
140
+ { name: 'C1', fields: [{ name: 'value', type: unionA, required: true }] },
141
+ { name: 'C2', fields: [{ name: 'value', type: unionB, required: true }] },
142
+ {
143
+ name: 'Shared',
144
+ fields: [{ name: 'type', type: { kind: 'literal', value: 'shared' }, required: true }],
145
+ },
146
+ {
147
+ name: 'VariantA',
148
+ fields: [{ name: 'foo', type: { kind: 'primitive', type: 'string' }, required: true }],
149
+ },
150
+ {
151
+ name: 'VariantB',
152
+ fields: [{ name: 'bar', type: { kind: 'primitive', type: 'string' }, required: true }],
153
+ },
154
+ ];
155
+ expect(() => flattenDiscriminatedUnionFields(models)).toThrow(/first variant of two distinct/);
156
+ });
157
+
158
+ it('throws when a field shared across variants has conflicting types', () => {
159
+ const models = baseModels(ownerUnion());
160
+ // Make `id` diverge: string on the org variant, number on the user variant.
161
+ const user = models.find((m) => m.name === 'UserApiKeyOwner')!;
162
+ user.fields = user.fields.map((f) => (f.name === 'id' ? { ...f, type: { kind: 'primitive', type: 'number' } } : f));
163
+ expect(() => flattenDiscriminatedUnionFields(models)).toThrow(/conflicting types/);
164
+ });
165
+
166
+ it('does not mutate the input models', () => {
167
+ const models = baseModels(ownerUnion());
168
+ const canonicalBefore = models.find((m) => m.name === 'ApiKeyOwner')!;
169
+ const fieldCountBefore = canonicalBefore.fields.length;
170
+ flattenDiscriminatedUnionFields(models);
171
+ expect(canonicalBefore.fields.length).toBe(fieldCountBefore);
172
+ expect(models.find((m) => m.name === 'ApiKey')!.fields[0].type).toEqual(ownerUnion());
173
+ });
174
+ });