@workos/oagen-emitters 0.16.0 → 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 +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-DuB1UozS.mjs → plugin-BLnR-FMi.mjs} +3687 -2393
- package/dist/plugin-BLnR-FMi.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/go/index.ts +6 -1
- package/src/kotlin/index.ts +9 -3
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +271 -5
- package/src/node/live-surface.ts +309 -0
- package/src/node/models.ts +69 -3
- package/src/node/naming.ts +204 -23
- package/src/node/resources.ts +166 -3
- package/src/node/utils.ts +140 -22
- 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/enums.test.ts +239 -2
- package/test/node/live-surface.test.ts +771 -1
- package/test/node/models.test.ts +738 -3
- package/test/node/naming.test.ts +159 -0
- package/test/node/resources.test.ts +611 -0
- package/test/node/utils.test.ts +157 -2
- package/test/rust/resources.test.ts +143 -3
- package/test/shared/union-flatten.test.ts +174 -0
- package/dist/plugin-DuB1UozS.mjs.map +0 -1
package/test/node/utils.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Model, Service } from '@workos/oagen';
|
|
3
3
|
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
-
import { modelHasNewFields } from '../../src/node/utils.js';
|
|
4
|
+
import { modelHasNewFields, createServiceDirResolver } from '../../src/node/utils.js';
|
|
5
|
+
import { emptyLiveSurface, setActiveLiveSurface } from '../../src/node/live-surface.js';
|
|
5
6
|
|
|
6
7
|
const emptySpec: ApiSpec = {
|
|
7
8
|
name: 'Test',
|
|
@@ -87,3 +88,157 @@ describe('modelHasNewFields', () => {
|
|
|
87
88
|
expect(modelHasNewFields(model, ctxWithSurface)).toBe(true);
|
|
88
89
|
});
|
|
89
90
|
});
|
|
91
|
+
|
|
92
|
+
describe('createServiceDirResolver owned-service dependency reassignment', () => {
|
|
93
|
+
const retentionModel: Model = {
|
|
94
|
+
name: 'AuditLogsRetention',
|
|
95
|
+
fields: [{ name: 'retention_period_in_days', type: { kind: 'primitive', type: 'integer' }, required: true }],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function retentionOp(name: string, path: string) {
|
|
99
|
+
return {
|
|
100
|
+
name,
|
|
101
|
+
httpMethod: 'get' as const,
|
|
102
|
+
path,
|
|
103
|
+
pathParams: [],
|
|
104
|
+
queryParams: [],
|
|
105
|
+
headerParams: [],
|
|
106
|
+
response: { kind: 'model' as const, name: 'AuditLogsRetention' },
|
|
107
|
+
errors: [],
|
|
108
|
+
injectIdempotencyKey: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Organizations comes first, so first-reference-wins assignment parks the
|
|
113
|
+
// model in `organizations/` even though only AuditLogs is owned this run.
|
|
114
|
+
const services: Service[] = [
|
|
115
|
+
{ name: 'Organizations', operations: [retentionOp('getRetention', '/organizations/{id}/retention')] },
|
|
116
|
+
{ name: 'AuditLogs', operations: [retentionOp('getAuditLogsRetention', '/audit_logs/retention')] },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
function makeCtx(): EmitterContext {
|
|
120
|
+
return {
|
|
121
|
+
...ctx,
|
|
122
|
+
spec: { ...emptySpec, models: [retentionModel], services },
|
|
123
|
+
emitterOptions: { ownedServices: ['AuditLogs'] },
|
|
124
|
+
} as EmitterContext;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
it('reassigns dependency models of owned services out of unemittable directories', () => {
|
|
128
|
+
const surface = emptyLiveSurface();
|
|
129
|
+
surface.files.add('src/workos.ts'); // existing SDK
|
|
130
|
+
setActiveLiveSurface(surface);
|
|
131
|
+
try {
|
|
132
|
+
const testCtx = makeCtx();
|
|
133
|
+
const { modelToService, resolveDir } = createServiceDirResolver([retentionModel], services, testCtx);
|
|
134
|
+
expect(resolveDir(modelToService.get('AuditLogsRetention'))).toBe('audit-logs');
|
|
135
|
+
} finally {
|
|
136
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('leaves the assignment alone when the interface already exists on disk', () => {
|
|
141
|
+
const surface = emptyLiveSurface();
|
|
142
|
+
surface.files.add('src/workos.ts');
|
|
143
|
+
surface.files.add('src/organizations/interfaces/audit-logs-retention.interface.ts');
|
|
144
|
+
setActiveLiveSurface(surface);
|
|
145
|
+
try {
|
|
146
|
+
const testCtx = makeCtx();
|
|
147
|
+
const { modelToService, resolveDir } = createServiceDirResolver([retentionModel], services, testCtx);
|
|
148
|
+
expect(resolveDir(modelToService.get('AuditLogsRetention'))).toBe('organizations');
|
|
149
|
+
} finally {
|
|
150
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not reassign when no services are owned', () => {
|
|
155
|
+
const surface = emptyLiveSurface();
|
|
156
|
+
surface.files.add('src/workos.ts');
|
|
157
|
+
setActiveLiveSurface(surface);
|
|
158
|
+
try {
|
|
159
|
+
const testCtx = { ...makeCtx(), emitterOptions: {} } as EmitterContext;
|
|
160
|
+
const { modelToService, resolveDir } = createServiceDirResolver([retentionModel], services, testCtx);
|
|
161
|
+
expect(resolveDir(modelToService.get('AuditLogsRetention'))).toBe('organizations');
|
|
162
|
+
} finally {
|
|
163
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('does not reassign in greenfield mode where every directory is emittable', () => {
|
|
168
|
+
const testCtx = makeCtx();
|
|
169
|
+
const { modelToService, resolveDir } = createServiceDirResolver([retentionModel], services, testCtx);
|
|
170
|
+
expect(resolveDir(modelToService.get('AuditLogsRetention'))).toBe('organizations');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('reassigns the full transitive closure when ops are re-mounted onto an owned service', () => {
|
|
174
|
+
// Real instance: GET/PUT /organizations/{organizationId}/audit_logs_retention
|
|
175
|
+
// live on the IR Organizations service but are MOUNTED on AuditLogs via
|
|
176
|
+
// resolvedOperations. Walking only IR services misses them entirely, so
|
|
177
|
+
// `AuditLogsRetention` (and everything it references) stays assigned to
|
|
178
|
+
// the unemittable organizations dir and is never emitted anywhere.
|
|
179
|
+
const nestedModel: Model = {
|
|
180
|
+
name: 'RetentionPolicy',
|
|
181
|
+
fields: [{ name: 'kind', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
182
|
+
};
|
|
183
|
+
const retentionWithNested: Model = {
|
|
184
|
+
...retentionModel,
|
|
185
|
+
fields: [
|
|
186
|
+
...retentionModel.fields,
|
|
187
|
+
{ name: 'policy', type: { kind: 'model', name: 'RetentionPolicy' }, required: true },
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
const listOrgsOp = {
|
|
191
|
+
name: 'listOrganizations',
|
|
192
|
+
httpMethod: 'get' as const,
|
|
193
|
+
path: '/organizations',
|
|
194
|
+
pathParams: [],
|
|
195
|
+
queryParams: [],
|
|
196
|
+
headerParams: [],
|
|
197
|
+
response: { kind: 'primitive' as const, type: 'unknown' as const },
|
|
198
|
+
errors: [],
|
|
199
|
+
injectIdempotencyKey: false,
|
|
200
|
+
};
|
|
201
|
+
const mountedRetentionOp = retentionOp(
|
|
202
|
+
'getAuditLogsRetention',
|
|
203
|
+
'/organizations/{organizationId}/audit_logs_retention',
|
|
204
|
+
);
|
|
205
|
+
const orgService: Service = { name: 'Organizations', operations: [listOrgsOp, mountedRetentionOp] };
|
|
206
|
+
const mountedServices: Service[] = [orgService];
|
|
207
|
+
|
|
208
|
+
const surface = emptyLiveSurface();
|
|
209
|
+
surface.files.add('src/workos.ts'); // existing SDK
|
|
210
|
+
setActiveLiveSurface(surface);
|
|
211
|
+
try {
|
|
212
|
+
const testCtx = {
|
|
213
|
+
...ctx,
|
|
214
|
+
spec: { ...emptySpec, models: [retentionWithNested, nestedModel], services: mountedServices },
|
|
215
|
+
emitterOptions: { ownedServices: ['AuditLogs'] },
|
|
216
|
+
resolvedOperations: [
|
|
217
|
+
{
|
|
218
|
+
operation: listOrgsOp,
|
|
219
|
+
service: orgService,
|
|
220
|
+
methodName: 'list_organizations',
|
|
221
|
+
mountOn: 'Organizations',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
operation: mountedRetentionOp,
|
|
225
|
+
service: orgService,
|
|
226
|
+
methodName: 'get_audit_logs_retention',
|
|
227
|
+
mountOn: 'AuditLogs',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
} as unknown as EmitterContext;
|
|
231
|
+
const { modelToService, resolveDir } = createServiceDirResolver(
|
|
232
|
+
[retentionWithNested, nestedModel],
|
|
233
|
+
mountedServices,
|
|
234
|
+
testCtx,
|
|
235
|
+
);
|
|
236
|
+
expect(resolveDir(modelToService.get('AuditLogsRetention'))).toBe('audit-logs');
|
|
237
|
+
// Nested dependency N follows M into the owned dir — the closure must
|
|
238
|
+
// not stop at the directly-referenced model.
|
|
239
|
+
expect(resolveDir(modelToService.get('RetentionPolicy'))).toBe('audit-logs');
|
|
240
|
+
} finally {
|
|
241
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -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
|
+
});
|