@workos/oagen-emitters 0.16.1 → 0.17.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +13 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-CpO8rePT.mjs → plugin-BLnR-FMi.mjs} +2513 -1893
- package/dist/plugin-BLnR-FMi.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/go/index.ts +6 -1
- package/src/kotlin/index.ts +9 -3
- package/src/node/index.ts +7 -1
- package/src/node/resources.ts +127 -0
- package/src/rust/resources.ts +78 -29
- package/src/rust/tests.ts +15 -4
- package/src/shared/union-flatten.ts +201 -0
- package/test/node/resources.test.ts +147 -0
- package/test/rust/resources.test.ts +143 -3
- package/test/shared/union-flatten.test.ts +174 -0
- package/dist/plugin-CpO8rePT.mjs.map +0 -1
|
@@ -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
|
|
218
|
-
|
|
219
|
-
expect(f.content).toContain('
|
|
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
|
+
});
|