@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.
@@ -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, 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
+ });