@workos/oagen-emitters 0.12.1 → 0.12.2

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.
Files changed (44) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +7 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-eCuvoL1T.mjs} +2508 -1474
  12. package/dist/plugin-eCuvoL1T.mjs.map +1 -0
  13. package/dist/plugin.mjs +1 -1
  14. package/package.json +6 -6
  15. package/renovate.json +46 -6
  16. package/src/node/client.ts +19 -32
  17. package/src/node/enums.ts +67 -30
  18. package/src/node/errors.ts +2 -8
  19. package/src/node/field-plan.ts +188 -52
  20. package/src/node/fixtures.ts +11 -33
  21. package/src/node/index.ts +345 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +540 -351
  24. package/src/node/naming.ts +119 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/resources.ts +455 -46
  28. package/src/node/sdk-errors.ts +0 -16
  29. package/src/node/tests.ts +108 -83
  30. package/src/node/type-map.ts +40 -18
  31. package/src/node/utils.ts +89 -102
  32. package/src/node/wrappers.ts +0 -20
  33. package/test/node/client.test.ts +106 -1201
  34. package/test/node/enums.test.ts +59 -130
  35. package/test/node/errors.test.ts +2 -3
  36. package/test/node/live-surface.test.ts +240 -0
  37. package/test/node/models.test.ts +396 -765
  38. package/test/node/naming.test.ts +69 -234
  39. package/test/node/resources.test.ts +376 -2036
  40. package/test/node/tests.test.ts +119 -0
  41. package/test/node/type-map.test.ts +49 -54
  42. package/test/node/utils.test.ts +29 -80
  43. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  44. package/test/node/serializers.test.ts +0 -444
@@ -1,7 +1,17 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { generateResources, resolveResourceClassName, hasCompatibleConstructor } from '../../src/node/resources.js';
3
- import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
4
7
  import { defaultSdkBehavior } from '@workos/oagen';
8
+ import { nodeEmitter } from '../../src/node/index.js';
9
+ import {
10
+ generateResources,
11
+ resolveResourceClassName,
12
+ resolveResourceDir,
13
+ hasCompatibleConstructor,
14
+ } from '../../src/node/resources.js';
5
15
 
6
16
  const emptySpec: ApiSpec = {
7
17
  name: 'Test',
@@ -19,12 +29,50 @@ const ctx: EmitterContext = {
19
29
  spec: emptySpec,
20
30
  };
21
31
 
32
+ function createTrackedSdkRoot(): string {
33
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-adopt-surface-'));
34
+ fs.mkdirSync(path.join(tmpRoot, 'src'), { recursive: true });
35
+ fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), '// @oagen-ignore-file\nexport class WorkOS {}\n');
36
+ fs.writeFileSync(
37
+ path.join(tmpRoot, 'src', 'index.ts'),
38
+ '// @oagen-ignore-file\nexport { WorkOS } from "./workos";\n',
39
+ );
40
+ execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
41
+ execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
42
+ return tmpRoot;
43
+ }
44
+
45
+ const connectService: Service = {
46
+ name: 'Connect',
47
+ operations: [
48
+ {
49
+ name: 'getConnect',
50
+ httpMethod: 'get',
51
+ path: '/connect',
52
+ pathParams: [],
53
+ queryParams: [],
54
+ headerParams: [],
55
+ response: { kind: 'primitive', type: 'unknown' },
56
+ errors: [],
57
+ injectIdempotencyKey: false,
58
+ },
59
+ ],
60
+ };
61
+
22
62
  describe('generateResources', () => {
23
63
  it('returns empty for no services', () => {
24
64
  expect(generateResources([], ctx)).toEqual([]);
25
65
  });
26
66
 
27
67
  it('generates a resource class with GET method', () => {
68
+ const orgModel: Model = {
69
+ name: 'Organization',
70
+ fields: [
71
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
72
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
73
+ ],
74
+ };
75
+
28
76
  const services: Service[] = [
29
77
  {
30
78
  name: 'Organizations',
@@ -33,13 +81,7 @@ describe('generateResources', () => {
33
81
  name: 'getOrganization',
34
82
  httpMethod: 'get',
35
83
  path: '/organizations/{id}',
36
- pathParams: [
37
- {
38
- name: 'id',
39
- type: { kind: 'primitive', type: 'string' },
40
- required: true,
41
- },
42
- ],
84
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
43
85
  queryParams: [],
44
86
  headerParams: [],
45
87
  response: { kind: 'model', name: 'Organization' },
@@ -50,213 +92,191 @@ describe('generateResources', () => {
50
92
  },
51
93
  ];
52
94
 
53
- const files = generateResources(services, ctx);
54
- expect(files.length).toBe(1);
55
- expect(files[0].path).toBe('src/organizations/organizations.ts');
95
+ const spec: ApiSpec = { ...emptySpec, services, models: [orgModel] };
96
+ const ctxWithSpec: EmitterContext = { ...ctx, spec };
97
+ const result = generateResources(services, ctxWithSpec);
56
98
 
57
- const content = files[0].content;
58
- expect(content).toContain('export class Organizations {');
59
- expect(content).toContain('constructor(private readonly workos: WorkOS) {}');
60
- expect(content).toContain('async getOrganization(id: string): Promise<Organization>');
61
- expect(content).toContain('deserializeOrganization(data)');
99
+ expect(result.length).toBeGreaterThan(0);
100
+ const resourceFile = result.find((f) => f.path.includes('organizations.ts'));
101
+ expect(resourceFile).toBeDefined();
102
+ expect(resourceFile!.content).toContain('export class Organizations');
103
+ expect(resourceFile!.content).toContain('constructor(private readonly workos: WorkOS)');
104
+ expect(resourceFile!.content).toContain('async getOrganization(id: string): Promise<Organization>');
105
+ expect(resourceFile!.content).toContain('deserializeOrganization(data)');
62
106
  });
63
107
 
64
- it('generates paginated list method', () => {
108
+ it('generates DELETE method returning void', () => {
65
109
  const services: Service[] = [
66
110
  {
67
111
  name: 'Organizations',
68
112
  operations: [
69
113
  {
70
- name: 'listOrganizations',
71
- httpMethod: 'get',
72
- path: '/organizations',
73
- pathParams: [],
74
- queryParams: [
75
- {
76
- name: 'domains',
77
- type: {
78
- kind: 'array',
79
- items: { kind: 'primitive', type: 'string' },
80
- },
81
- required: false,
82
- },
83
- ],
114
+ name: 'deleteOrganization',
115
+ httpMethod: 'delete',
116
+ path: '/organizations/{id}',
117
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
118
+ queryParams: [],
84
119
  headerParams: [],
85
- response: { kind: 'model', name: 'Organization' },
120
+ response: { kind: 'primitive', type: 'unknown' },
86
121
  errors: [],
87
- pagination: {
88
- strategy: 'cursor',
89
- param: 'after',
90
- dataPath: 'data',
91
- itemType: { kind: 'model', name: 'Organization' },
92
- },
93
122
  injectIdempotencyKey: false,
94
123
  },
95
124
  ],
96
125
  },
97
126
  ];
98
127
 
99
- const files = generateResources(services, ctx);
100
- const content = files[0].content;
101
-
102
- // Should have AutoPaginatable value import and fetchAndDeserialize import
103
- expect(content).toContain("import { AutoPaginatable } from '../common/utils/pagination'");
104
- expect(content).toContain("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'");
105
- // Options interface lives in its own file under interfaces/ so the
106
- // per-service barrel picks it up.
107
- expect(content).toContain(
108
- "import type { ListOrganizationsOptions } from './interfaces/list-organizations-options.interface';",
109
- );
110
-
111
- // The options interface file is emitted separately.
112
- const optionsFile = files.find(
113
- (f) => f.path === 'src/organizations/interfaces/list-organizations-options.interface.ts',
114
- );
115
- expect(optionsFile).toBeDefined();
116
- expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
117
- expect(optionsFile!.content).toContain('domains?: string[];');
118
-
119
- // Should return AutoPaginatable
120
- expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
128
+ const spec: ApiSpec = { ...emptySpec, services };
129
+ const ctxWithSpec: EmitterContext = { ...ctx, spec };
130
+ const result = generateResources(services, ctxWithSpec);
121
131
 
122
- // `domains` has the same camelCase and snake_case spelling, so no wire
123
- // serializer should be emitted; options are passed directly.
124
- expect(content).not.toContain('serializeListOrganizationsOptions');
125
- expect(content).toContain('new AutoPaginatable(');
126
- expect(content).toContain('fetchAndDeserialize<OrganizationResponse, Organization>');
127
- expect(content).toContain('options,');
132
+ const resourceFile = result.find((f) => f.path.includes('organizations.ts'));
133
+ expect(resourceFile).toBeDefined();
134
+ expect(resourceFile!.content).toContain('Promise<void>');
128
135
  });
129
136
 
130
- it('emits wire-options serializer for paginated list with camelCase filter fields', () => {
131
- // Regression test for PR #1535 reviewer comment r3075477146: list
132
- // methods whose extended filter fields have divergent camelCase/snake_case
133
- // spellings (e.g. `organizationId` ↔ `organization_id`) must translate
134
- // keys before hitting the wire, otherwise the API silently ignores them.
135
- const services: Service[] = [
136
- {
137
- name: 'Applications',
138
- operations: [
139
- {
140
- name: 'listApplications',
141
- httpMethod: 'get',
142
- path: '/connect/applications',
143
- pathParams: [],
144
- queryParams: [
145
- {
146
- name: 'organization_id',
147
- type: { kind: 'primitive', type: 'string' },
148
- required: false,
149
- description: 'Filter by organization ID.',
150
- },
151
- ],
152
- headerParams: [],
153
- response: { kind: 'model', name: 'ConnectApplication' },
154
- errors: [],
155
- pagination: {
156
- strategy: 'cursor',
157
- param: 'after',
158
- dataPath: 'data',
159
- itemType: { kind: 'model', name: 'ConnectApplication' },
160
- },
161
- injectIdempotencyKey: false,
162
- },
163
- ],
164
- },
165
- ];
166
-
167
- const files = generateResources(services, ctx);
168
- const content = files[0].content;
169
-
170
- // Options interface uses camelCase (user-facing) and lives in its own file.
171
- const optionsFile = files.find(
172
- (f) => f.path === 'src/applications/interfaces/list-applications-options.interface.ts',
173
- );
174
- expect(optionsFile).toBeDefined();
175
- expect(optionsFile!.content).toContain('export interface ListApplicationsOptions extends PaginationOptions {');
176
- expect(optionsFile!.content).toContain('organizationId?: string;');
177
-
178
- // Resource class imports the options type from the interface file.
179
- expect(content).toContain(
180
- "import type { ListApplicationsOptions } from './interfaces/list-applications-options.interface';",
181
- );
182
-
183
- // Wire-options serializer emits snake_case key for the extension field
184
- // and leaves standard pagination fields unchanged.
185
- expect(content).toContain(
186
- 'const serializeListApplicationsOptions = (options: ListApplicationsOptions): PaginationOptions => {',
187
- );
188
- expect(content).toContain('wire.organization_id = options.organizationId');
189
- expect(content).not.toContain('wire.organizationId');
190
-
191
- // fetchAndDeserialize is invoked with the serialized options.
192
- expect(content).toContain('serializeListApplicationsOptions(options)');
193
- expect(content).toContain('new AutoPaginatable(');
194
- });
137
+ it('applies Node-only operation overrides without global operation hints', () => {
138
+ const operation = {
139
+ name: 'listOrganizationMembershipGroups',
140
+ httpMethod: 'get' as const,
141
+ path: '/user_management/organization_memberships/{omId}/groups',
142
+ pathParams: [{ name: 'omId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true }],
143
+ queryParams: [],
144
+ headerParams: [],
145
+ response: { kind: 'primitive' as const, type: 'unknown' as const },
146
+ errors: [],
147
+ injectIdempotencyKey: false,
148
+ };
149
+ const service: Service = {
150
+ name: 'UserManagementOrganizationMembershipGroups',
151
+ operations: [operation],
152
+ };
153
+ const spec: ApiSpec = { ...emptySpec, services: [service] };
154
+ const ctxWithResolved: EmitterContext = {
155
+ ...ctx,
156
+ spec,
157
+ resolvedOperations: [
158
+ {
159
+ operation,
160
+ service,
161
+ methodName: 'list_organization_membership_groups',
162
+ mountOn: 'UserManagementOrganizationMembershipGroups',
163
+ defaults: {},
164
+ inferFromClient: [],
165
+ urlBuilder: false,
166
+ },
167
+ ],
168
+ };
195
169
 
196
- it('uses item type not list wrapper type for paginated methods', () => {
197
- // The response model is the list wrapper (ConnectionList), but the pagination
198
- // itemType is the actual item (Connection). The generated code should use the
199
- // item type for fetchAndDeserialize, not the list wrapper.
200
- const services: Service[] = [
201
- {
202
- name: 'SSO',
203
- operations: [
204
- {
205
- name: 'listConnections',
206
- httpMethod: 'get',
207
- path: '/connections',
208
- pathParams: [],
170
+ const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
171
+
172
+ expect(result.some((f) => f.path.includes('user-management-organization-membership-groups'))).toBe(false);
173
+ const resourceFile = result.find((f) => f.path === 'src/user-management/user-management.ts');
174
+ expect(resourceFile).toBeDefined();
175
+ expect(resourceFile!.content).toContain('export class UserManagement');
176
+ expect(resourceFile!.content).toContain('async listGroupsForOrganizationMembership');
177
+ });
178
+
179
+ it('drops brand-new service paths in an existing SDK by default', () => {
180
+ const tmpRoot = createTrackedSdkRoot();
181
+ try {
182
+ const spec: ApiSpec = { ...emptySpec, services: [connectService] };
183
+ const result = nodeEmitter.generateResources(spec.services, { ...ctx, spec, outputDir: tmpRoot });
184
+
185
+ expect(result.some((f) => f.path === 'src/connect/connect.ts')).toBe(false);
186
+ } finally {
187
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ it('adopts brand-new service paths when configured', () => {
192
+ const tmpRoot = createTrackedSdkRoot();
193
+ try {
194
+ const spec: ApiSpec = { ...emptySpec, services: [connectService] };
195
+ const result = nodeEmitter.generateResources(spec.services, {
196
+ ...ctx,
197
+ spec,
198
+ outputDir: tmpRoot,
199
+ emitterOptions: { adoptMissingServices: true },
200
+ } as EmitterContext);
201
+
202
+ const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
203
+ expect(resourceFile).toBeDefined();
204
+ expect(resourceFile!.content).toContain('export class Connect');
205
+ } finally {
206
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
207
+ }
208
+ });
209
+
210
+ it('overwrites tracked files for explicitly owned services', () => {
211
+ const tmpRoot = createTrackedSdkRoot();
212
+ try {
213
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'groups'), { recursive: true });
214
+ fs.writeFileSync(
215
+ path.join(tmpRoot, 'src', 'groups', 'groups.ts'),
216
+ 'export class Groups { async createGroup() {} }\n',
217
+ );
218
+ execFileSync('git', ['add', 'src/groups/groups.ts'], { cwd: tmpRoot, stdio: 'ignore' });
219
+
220
+ const groupService: Service = {
221
+ name: 'Groups',
222
+ operations: [
223
+ {
224
+ name: 'createGroup',
225
+ httpMethod: 'post',
226
+ path: '/organizations/{organizationId}/groups',
227
+ pathParams: [{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true }],
209
228
  queryParams: [],
210
229
  headerParams: [],
211
- response: { kind: 'model', name: 'ConnectionList' },
230
+ response: { kind: 'primitive', type: 'unknown' },
212
231
  errors: [],
213
- pagination: {
214
- strategy: 'cursor',
215
- param: 'after',
216
- dataPath: 'data',
217
- itemType: { kind: 'model', name: 'Connection' },
218
- },
219
232
  injectIdempotencyKey: false,
220
233
  },
221
234
  ],
222
- },
223
- ];
224
-
225
- const testCtx: EmitterContext = {
226
- namespace: 'workos',
227
- namespacePascal: 'WorkOS',
228
- spec: { ...emptySpec, services, models: [] },
229
- };
230
-
231
- const files = generateResources(services, testCtx);
232
- const content = files[0].content;
233
-
234
- // Should use item type (Connection) not list wrapper (ConnectionList)
235
- expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
236
- expect(content).toContain('deserializeConnection');
237
- expect(content).toContain('Promise<AutoPaginatable<Connection,');
235
+ };
236
+ const spec: ApiSpec = { ...emptySpec, services: [groupService] };
237
+ const result = nodeEmitter.generateResources(spec.services, {
238
+ ...ctx,
239
+ spec,
240
+ outputDir: tmpRoot,
241
+ emitterOptions: { ownedServices: ['Groups'] },
242
+ apiSurface: {
243
+ classes: {
244
+ Groups: {
245
+ methods: {
246
+ createGroup: [{ name: 'createGroup', params: [], returnType: 'Promise<void>', async: true }],
247
+ },
248
+ },
249
+ },
250
+ } as any,
251
+ } as EmitterContext);
238
252
 
239
- // Should NOT reference the list wrapper type
240
- expect(content).not.toContain('ConnectionList');
241
- expect(content).not.toContain('deserializeConnectionList');
253
+ const resourceFile = result.find((f) => f.path === 'src/groups/groups.ts');
254
+ expect(resourceFile).toBeDefined();
255
+ expect(resourceFile!.overwriteExisting).toBe(true);
256
+ expect(resourceFile!.skipIfExists).toBe(false);
257
+ } finally {
258
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
259
+ }
242
260
  });
243
261
 
244
- it('generates DELETE method returning void', () => {
245
- const services: Service[] = [
246
- {
247
- name: 'Organizations',
262
+ it('does not overwrite protected files for explicitly owned services', () => {
263
+ const tmpRoot = createTrackedSdkRoot();
264
+ try {
265
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'groups'), { recursive: true });
266
+ fs.writeFileSync(
267
+ path.join(tmpRoot, 'src', 'groups', 'groups.ts'),
268
+ '// @oagen-ignore-file\nexport class Groups {}\n',
269
+ );
270
+ execFileSync('git', ['add', 'src/groups/groups.ts'], { cwd: tmpRoot, stdio: 'ignore' });
271
+
272
+ const groupService: Service = {
273
+ name: 'Groups',
248
274
  operations: [
249
275
  {
250
- name: 'deleteOrganization',
251
- httpMethod: 'delete',
252
- path: '/organizations/{id}',
253
- pathParams: [
254
- {
255
- name: 'id',
256
- type: { kind: 'primitive', type: 'string' },
257
- required: true,
258
- },
259
- ],
276
+ name: 'createGroup',
277
+ httpMethod: 'post',
278
+ path: '/organizations/{organizationId}/groups',
279
+ pathParams: [{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true }],
260
280
  queryParams: [],
261
281
  headerParams: [],
262
282
  response: { kind: 'primitive', type: 'unknown' },
@@ -264,1895 +284,215 @@ describe('generateResources', () => {
264
284
  injectIdempotencyKey: false,
265
285
  },
266
286
  ],
267
- },
268
- ];
269
-
270
- const files = generateResources(services, ctx);
271
- const content = files[0].content;
272
- expect(content).toContain('async deleteOrganization(id: string): Promise<void>');
273
- expect(content).toContain('await this.workos.delete(');
274
- });
275
-
276
- it('generates unpaginated GET returning an array of models', () => {
277
- // Regression test for PR #1535 reviewer comment (r3074705330): endpoints
278
- // whose OpenAPI response is `type: array` must return `Model[]` and map
279
- // the deserializer over each element, not treat the array as a single
280
- // object (which silently produces garbage at runtime).
281
- const services: Service[] = [
282
- {
283
- name: 'Secrets',
287
+ };
288
+ const spec: ApiSpec = { ...emptySpec, services: [groupService] };
289
+ const result = nodeEmitter.generateResources(spec.services, {
290
+ ...ctx,
291
+ spec,
292
+ outputDir: tmpRoot,
293
+ emitterOptions: { ownedServices: ['Groups'] },
294
+ } as EmitterContext);
295
+
296
+ expect(result.some((f) => f.path === 'src/groups/groups.ts')).toBe(false);
297
+ } finally {
298
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
299
+ }
300
+ });
301
+
302
+ it('treats prior-manifest generated files as managed before they are git-tracked', () => {
303
+ const tmpRoot = createTrackedSdkRoot();
304
+ try {
305
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'connect'), { recursive: true });
306
+ fs.writeFileSync(
307
+ path.join(tmpRoot, 'src', 'connect', 'connect.ts'),
308
+ ['// This file is auto-generated by oagen. Do not edit.', '', 'export class Connect {}'].join('\n'),
309
+ );
310
+
311
+ const spec: ApiSpec = { ...emptySpec, services: [connectService] };
312
+ const result = nodeEmitter.generateResources(spec.services, {
313
+ ...ctx,
314
+ spec,
315
+ outputDir: tmpRoot,
316
+ emitterOptions: { adoptMissingServices: true },
317
+ priorTargetManifestPaths: new Set(['src/connect/connect.ts']),
318
+ } as EmitterContext);
319
+
320
+ const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
321
+ expect(resourceFile).toBeDefined();
322
+ expect(resourceFile!.overwriteExisting).toBe(true);
323
+ } finally {
324
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
325
+ }
326
+ });
327
+
328
+ it('harvests serializer exports from prior-manifest generated files before they are git-tracked', () => {
329
+ const tmpRoot = createTrackedSdkRoot();
330
+ try {
331
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'connect', 'serializers'), { recursive: true });
332
+ fs.writeFileSync(
333
+ path.join(tmpRoot, 'src', 'connect', 'serializers', 'create-m2m-application.serializer.ts'),
334
+ [
335
+ '// This file is auto-generated by oagen. Do not edit.',
336
+ '',
337
+ 'export const serializeCreateM2MApplication = (payload: unknown): unknown => payload;',
338
+ ].join('\n'),
339
+ );
340
+
341
+ const service: Service = {
342
+ name: 'Connect',
284
343
  operations: [
285
344
  {
286
- name: 'listSecrets',
287
- httpMethod: 'get',
288
- path: '/applications/{id}/secrets',
289
- pathParams: [
290
- {
291
- name: 'id',
292
- type: { kind: 'primitive', type: 'string' },
293
- required: true,
294
- },
295
- ],
345
+ name: 'createApplication',
346
+ httpMethod: 'post',
347
+ path: '/connect/applications',
348
+ pathParams: [],
296
349
  queryParams: [],
297
350
  headerParams: [],
298
- response: { kind: 'array', items: { kind: 'model', name: 'Secret' } },
351
+ requestBody: { kind: 'model', name: 'CreateM2MApplication' },
352
+ response: { kind: 'primitive', type: 'unknown' },
299
353
  errors: [],
300
354
  injectIdempotencyKey: false,
301
355
  },
302
356
  ],
303
- },
304
- ];
305
-
306
- const files = generateResources(services, ctx);
307
- const content = files[0].content;
357
+ };
358
+ const spec: ApiSpec = {
359
+ ...emptySpec,
360
+ services: [service],
361
+ models: [
362
+ {
363
+ name: 'CreateM2MApplication',
364
+ fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
365
+ },
366
+ ],
367
+ };
368
+ const result = nodeEmitter.generateResources(spec.services, {
369
+ ...ctx,
370
+ spec,
371
+ outputDir: tmpRoot,
372
+ emitterOptions: { adoptMissingServices: true },
373
+ priorTargetManifestPaths: new Set([
374
+ 'src/connect/connect.ts',
375
+ 'src/connect/serializers/create-m2m-application.serializer.ts',
376
+ ]),
377
+ } as EmitterContext);
308
378
 
309
- expect(content).toContain('async listSecrets(id: string): Promise<Secret[]>');
310
- expect(content).toContain('this.workos.get<SecretResponse[]>');
311
- expect(content).toContain('return data.map(deserializeSecret);');
312
- // Should NOT produce the single-object form — that was the bug.
313
- expect(content).not.toMatch(/Promise<Secret>\s*\{/);
314
- expect(content).not.toContain('return deserializeSecret(data);');
379
+ const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
380
+ expect(resourceFile?.content).toContain(
381
+ "import { serializeCreateM2MApplication } from './serializers/create-m2m-application.serializer';",
382
+ );
383
+ } finally {
384
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
385
+ }
315
386
  });
387
+ });
316
388
 
317
- it('generates POST method with body and idempotency', () => {
318
- const services: Service[] = [
319
- {
320
- name: 'Organizations',
321
- operations: [
322
- {
323
- name: 'createOrganization',
324
- httpMethod: 'post',
325
- path: '/organizations',
326
- pathParams: [],
327
- queryParams: [],
328
- headerParams: [],
329
- requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
330
- response: { kind: 'model', name: 'Organization' },
331
- errors: [],
332
- injectIdempotencyKey: true,
389
+ describe('resolveResourceClassName', () => {
390
+ it('uses overlay name when baseline has compatible constructor', () => {
391
+ const service: Service = { name: 'Organizations', operations: [] };
392
+ const ctxWithBaseline: EmitterContext = {
393
+ ...ctx,
394
+ apiSurface: {
395
+ classes: {
396
+ Organizations: {
397
+ constructorParams: [{ name: 'workos', type: 'WorkOS' }],
333
398
  },
334
- ],
335
- },
336
- ];
337
-
338
- const files = generateResources(services, ctx);
339
- const content = files[0].content;
340
- expect(content).toContain(
341
- 'async createOrganization(payload: CreateOrganizationInput, requestOptions: PostOptions = {}): Promise<Organization>',
342
- );
343
- expect(content).toContain('serializeCreateOrganizationInput(payload)');
344
- expect(content).toContain('requestOptions,');
399
+ },
400
+ } as any,
401
+ };
402
+ expect(resolveResourceClassName(service, ctxWithBaseline)).toBe('Organizations');
345
403
  });
346
404
 
347
- it('uses overlay-resolved name for output path and class', () => {
348
- const mfaService: Service = {
349
- name: 'MultiFactorAuth',
405
+ it('appends Endpoints suffix when IR name collides with overlay name', () => {
406
+ const service: Service = {
407
+ name: 'Webhooks',
350
408
  operations: [
351
409
  {
352
- name: 'enrollFactor',
353
- httpMethod: 'post',
354
- path: '/auth/factors/enroll',
410
+ name: 'listWebhooks',
411
+ httpMethod: 'get',
412
+ path: '/webhooks',
355
413
  pathParams: [],
356
414
  queryParams: [],
357
415
  headerParams: [],
358
- requestBody: { kind: 'model', name: 'EnrollFactorInput' },
359
- response: { kind: 'model', name: 'AuthenticationFactor' },
416
+ response: { kind: 'primitive', type: 'unknown' },
360
417
  errors: [],
361
- injectIdempotencyKey: true,
418
+ injectIdempotencyKey: false,
362
419
  },
363
420
  ],
364
421
  };
365
-
366
- const overlayCtx: EmitterContext = {
367
- namespace: 'workos',
368
- namespacePascal: 'WorkOS',
369
- spec: { ...emptySpec, services: [mfaService], models: [] },
370
- overlayLookup: {
371
- methodByOperation: new Map([
372
- [
373
- 'POST /auth/factors/enroll',
374
- {
375
- className: 'Mfa',
376
- methodName: 'enrollFactor',
377
- params: [],
378
- returnType: 'void',
379
- },
380
- ],
381
- ]),
382
- httpKeyByMethod: new Map(),
383
- interfaceByName: new Map(),
384
- typeAliasByName: new Map(),
385
- requiredExports: new Map(),
386
- modelNameByIR: new Map(),
387
- fileBySymbol: new Map(),
388
- },
422
+ const ctxWithIncompat: EmitterContext = {
423
+ ...ctx,
424
+ apiSurface: {
425
+ classes: {
426
+ Webhooks: {
427
+ constructorParams: [{ name: 'crypto', type: 'CryptoProvider' }],
428
+ },
429
+ },
430
+ } as any,
431
+ resolvedOperations: [
432
+ {
433
+ operation: service.operations[0],
434
+ service,
435
+ methodName: 'list_webhooks',
436
+ mountOn: 'Webhooks',
437
+ defaults: {},
438
+ inferFromClient: [],
439
+ urlBuilder: false,
440
+ },
441
+ ],
389
442
  };
443
+ expect(resolveResourceClassName(service, ctxWithIncompat)).toBe('WebhooksEndpoints');
444
+ expect(resolveResourceDir(service, ctxWithIncompat)).toBe('webhooks');
390
445
 
391
- const files = generateResources([mfaService], overlayCtx);
392
- expect(files.length).toBe(1);
393
- expect(files[0].path).toBe('src/mfa/mfa.ts');
394
-
395
- const content = files[0].content;
396
- expect(content).toContain('export class Mfa {');
446
+ const result = generateResources([service], { ...ctxWithIncompat, spec: { ...emptySpec, services: [service] } });
447
+ expect(result.some((f) => f.path === 'src/webhooks/webhooks-endpoints.ts')).toBe(true);
448
+ expect(result.some((f) => f.path === 'src/webhooks-endpoints/webhooks-endpoints.ts')).toBe(false);
397
449
  });
450
+ });
398
451
 
399
- it('renders multiline description and @deprecated in method docstring', () => {
400
- const services: Service[] = [
401
- {
402
- name: 'Radar',
403
- operations: [
404
- {
405
- name: 'updateAttempt',
406
- description: 'Update a Radar attempt\n\nYou may optionally inform Radar that an attempt was successful.',
407
- httpMethod: 'put',
408
- path: '/radar/attempts/{id}',
409
- pathParams: [
410
- {
411
- name: 'id',
412
- type: { kind: 'primitive', type: 'string' },
413
- required: true,
414
- description: 'The unique identifier of the attempt.',
415
- },
416
- ],
417
- queryParams: [],
418
- headerParams: [],
419
- requestBody: { kind: 'model', name: 'UpdateAttemptInput' },
420
- response: { kind: 'model', name: 'RadarAttempt' },
421
- errors: [],
422
- injectIdempotencyKey: false,
423
- deprecated: true,
424
- },
425
- ],
426
- },
427
- ];
428
-
429
- const files = generateResources(services, ctx);
430
- const content = files[0].content;
431
-
432
- expect(content).toContain(' /**');
433
- expect(content).toContain(' * Update a Radar attempt');
434
- expect(content).toContain(' *');
435
- expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
436
- expect(content).toContain(' * @param id - The unique identifier of the attempt.');
437
- expect(content).toContain(' * @returns {Promise<RadarAttempt>}');
438
- expect(content).toContain(' * @deprecated');
439
- expect(content).toContain(' */');
452
+ describe('hasCompatibleConstructor', () => {
453
+ it('returns true when no baseline exists', () => {
454
+ expect(hasCompatibleConstructor('NewService', ctx)).toBe(true);
440
455
  });
441
456
 
442
- it('renders @returns for response model', () => {
443
- const services: Service[] = [
444
- {
445
- name: 'Organizations',
446
- operations: [
447
- {
448
- name: 'getOrganization',
449
- httpMethod: 'get',
450
- path: '/organizations/{id}',
451
- pathParams: [
452
- {
453
- name: 'id',
454
- type: { kind: 'primitive', type: 'string' },
455
- required: true,
456
- },
457
- ],
458
- queryParams: [],
459
- headerParams: [],
460
- response: { kind: 'model', name: 'Organization' },
461
- errors: [],
462
- injectIdempotencyKey: false,
457
+ it('returns true when baseline has workos: WorkOS param', () => {
458
+ const ctxWithBaseline: EmitterContext = {
459
+ ...ctx,
460
+ apiSurface: {
461
+ classes: {
462
+ Organizations: {
463
+ constructorParams: [{ name: 'workos', type: 'WorkOS' }],
463
464
  },
464
- ],
465
- },
466
- ];
467
-
468
- const files = generateResources(services, ctx);
469
- const content = files[0].content;
470
- expect(content).toContain('@returns {Promise<Organization>}');
465
+ },
466
+ } as any,
467
+ };
468
+ expect(hasCompatibleConstructor('Organizations', ctxWithBaseline)).toBe(true);
471
469
  });
472
470
 
473
- it('renders @returns from overlay return type when available', () => {
474
- const services: Service[] = [
475
- {
476
- name: 'Authorization',
477
- operations: [
478
- {
479
- name: 'createEnvironmentRole',
480
- httpMethod: 'post',
481
- path: '/authorization/roles',
482
- pathParams: [],
483
- queryParams: [],
484
- headerParams: [],
485
- requestBody: { kind: 'model', name: 'CreateRoleInput' },
486
- response: { kind: 'model', name: 'Role' },
487
- errors: [],
488
- injectIdempotencyKey: false,
471
+ it('returns false when baseline has incompatible constructor', () => {
472
+ const ctxWithIncompat: EmitterContext = {
473
+ ...ctx,
474
+ apiSurface: {
475
+ classes: {
476
+ Webhooks: {
477
+ constructorParams: [{ name: 'crypto', type: 'CryptoProvider' }],
489
478
  },
490
- ],
491
- },
492
- ];
493
-
494
- const overlayCtx: EmitterContext = {
495
- namespace: 'workos',
496
- namespacePascal: 'WorkOS',
497
- spec: { ...emptySpec, services, models: [] },
498
- overlayLookup: {
499
- methodByOperation: new Map([
500
- [
501
- 'POST /authorization/roles',
502
- {
503
- className: 'Authorization',
504
- methodName: 'createEnvironmentRole',
505
- params: [{ name: 'payload', type: 'CreateRoleInput', optional: false }],
506
- returnType: 'Promise<EnvironmentRole>',
507
- },
508
- ],
509
- ]),
510
- httpKeyByMethod: new Map(),
511
- interfaceByName: new Map(),
512
- typeAliasByName: new Map(),
513
- requiredExports: new Map(),
514
- modelNameByIR: new Map(),
515
- fileBySymbol: new Map(),
516
- },
479
+ },
480
+ } as any,
517
481
  };
518
-
519
- const files = generateResources(services, overlayCtx);
520
- const content = files[0].content;
521
- // JSDoc should use the overlay return type, not the spec schema name
522
- expect(content).toContain('@returns {Promise<EnvironmentRole>}');
523
- expect(content).not.toContain('@returns {Role}');
524
- expect(content).not.toContain('@returns {Promise<Role>}');
482
+ expect(hasCompatibleConstructor('Webhooks', ctxWithIncompat)).toBe(false);
525
483
  });
526
484
 
527
- it('renders query param docs for non-paginated operations', () => {
528
- const services: Service[] = [
529
- {
530
- name: 'Organizations',
531
- operations: [
532
- {
533
- name: 'getOrganization',
534
- httpMethod: 'get',
535
- path: '/organizations/{id}',
536
- pathParams: [
537
- {
538
- name: 'id',
539
- type: { kind: 'primitive', type: 'string' },
540
- required: true,
541
- },
542
- ],
543
- queryParams: [
544
- {
545
- name: 'include_fields',
546
- type: { kind: 'primitive', type: 'string' },
547
- required: false,
548
- description: 'Comma-separated list of fields to include.',
549
- },
550
- ],
551
- headerParams: [],
552
- response: { kind: 'model', name: 'Organization' },
553
- errors: [],
554
- injectIdempotencyKey: false,
485
+ it('returns true when baseline has no constructor params', () => {
486
+ const ctxWithEmptyCtor: EmitterContext = {
487
+ ...ctx,
488
+ apiSurface: {
489
+ classes: {
490
+ Utils: {
491
+ constructorParams: [],
555
492
  },
556
- ],
557
- },
558
- ];
559
-
560
- const files = generateResources(services, ctx);
561
- const content = files[0].content;
562
- expect(content).toContain('@param options.includeFields - Comma-separated list of fields to include.');
563
- });
564
-
565
- it('renders header and cookie param docs', () => {
566
- const services: Service[] = [
567
- {
568
- name: 'Sessions',
569
- operations: [
570
- {
571
- name: 'getSession',
572
- httpMethod: 'get',
573
- path: '/sessions/{id}',
574
- pathParams: [
575
- {
576
- name: 'id',
577
- type: { kind: 'primitive', type: 'string' },
578
- required: true,
579
- },
580
- ],
581
- queryParams: [],
582
- headerParams: [
583
- {
584
- name: 'X-Request-Id',
585
- type: { kind: 'primitive', type: 'string' },
586
- required: false,
587
- description: 'Unique request identifier.',
588
- },
589
- ],
590
- cookieParams: [
591
- {
592
- name: 'session_token',
593
- type: { kind: 'primitive', type: 'string' },
594
- required: true,
595
- description: 'The session cookie.',
596
- },
597
- ],
598
- response: { kind: 'model', name: 'Session' },
599
- errors: [],
600
- injectIdempotencyKey: false,
601
- },
602
- ],
603
- },
604
- ];
605
-
606
- const files = generateResources(services, ctx);
607
- const content = files[0].content;
608
- // Header and cookie params are intentionally NOT documented in JSDoc —
609
- // they are not exposed in the method signature (handled internally by the SDK).
610
- expect(content).not.toContain('@param xRequestId');
611
- expect(content).not.toContain('@param sessionToken');
612
- });
613
-
614
- it('renders single @returns without status-code duplicates', () => {
615
- const services: Service[] = [
616
- {
617
- name: 'Organizations',
618
- operations: [
619
- {
620
- name: 'createOrganization',
621
- httpMethod: 'post',
622
- path: '/organizations',
623
- pathParams: [],
624
- queryParams: [],
625
- headerParams: [],
626
- requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
627
- response: { kind: 'model', name: 'Organization' },
628
- successResponses: [
629
- {
630
- statusCode: 200,
631
- type: { kind: 'model', name: 'Organization' },
632
- },
633
- {
634
- statusCode: 201,
635
- type: { kind: 'model', name: 'Organization' },
636
- },
637
- ],
638
- errors: [],
639
- injectIdempotencyKey: false,
640
- },
641
- ],
642
- },
643
- ];
644
-
645
- const files = generateResources(services, ctx);
646
- const content = files[0].content;
647
- // Only emit a single @returns for the primary response model (no status-code variants)
648
- expect(content).toContain('@returns {Promise<Organization>}');
649
- expect(content).not.toContain('@returns {Promise<Organization>} 200');
650
- expect(content).not.toContain('@returns {Promise<Organization>} 201');
651
- });
652
-
653
- it('generates DELETE-with-body method using deleteWithBody', () => {
654
- const services: Service[] = [
655
- {
656
- name: 'Radar',
657
- operations: [
658
- {
659
- name: 'deleteRadarListEntry',
660
- httpMethod: 'delete',
661
- path: '/radar/lists/{listId}/entries',
662
- pathParams: [
663
- {
664
- name: 'listId',
665
- type: { kind: 'primitive', type: 'string' },
666
- required: true,
667
- },
668
- ],
669
- queryParams: [],
670
- headerParams: [],
671
- requestBody: { kind: 'model', name: 'DeleteRadarListEntryInput' },
672
- response: { kind: 'primitive', type: 'unknown' },
673
- errors: [],
674
- injectIdempotencyKey: false,
675
- },
676
- ],
677
- },
678
- ];
679
-
680
- const files = generateResources(services, ctx);
681
- const content = files[0].content;
682
- expect(content).toContain(
683
- 'async deleteRadarListEntry(listId: string, payload: DeleteRadarListEntryInput): Promise<void>',
684
- );
685
- expect(content).toContain('await this.workos.deleteWithBody(');
686
- expect(content).toContain('serializeDeleteRadarListEntryInput(payload)');
687
- });
688
-
689
- it('renders deprecated path params', () => {
690
- const services: Service[] = [
691
- {
692
- name: 'Organizations',
693
- operations: [
694
- {
695
- name: 'getOrganization',
696
- httpMethod: 'get',
697
- path: '/organizations/{slug}',
698
- pathParams: [
699
- {
700
- name: 'slug',
701
- type: { kind: 'primitive', type: 'string' },
702
- required: true,
703
- description: 'The organization slug.',
704
- deprecated: true,
705
- },
706
- ],
707
- queryParams: [],
708
- headerParams: [],
709
- response: { kind: 'model', name: 'Organization' },
710
- errors: [],
711
- injectIdempotencyKey: false,
712
- },
713
- ],
714
- },
715
- ];
716
-
717
- const files = generateResources(services, ctx);
718
- const content = files[0].content;
719
- expect(content).toContain('@param slug - (deprecated) The organization slug.');
720
- });
721
-
722
- it('generates typed options interface for non-paginated GET with query params', () => {
723
- const services: Service[] = [
724
- {
725
- name: 'Organizations',
726
- operations: [
727
- {
728
- name: 'getOrganization',
729
- httpMethod: 'get',
730
- path: '/organizations/{id}',
731
- pathParams: [
732
- {
733
- name: 'id',
734
- type: { kind: 'primitive', type: 'string' },
735
- required: true,
736
- },
737
- ],
738
- queryParams: [
739
- {
740
- name: 'include_fields',
741
- type: { kind: 'primitive', type: 'string' },
742
- required: false,
743
- description: 'Comma-separated list of fields to include.',
744
- },
745
- ],
746
- headerParams: [],
747
- response: { kind: 'model', name: 'Organization' },
748
- errors: [],
749
- injectIdempotencyKey: false,
750
- },
751
- ],
752
- },
753
- ];
754
-
755
- const files = generateResources(services, ctx);
756
- const content = files[0].content;
757
-
758
- // Should generate a typed options interface
759
- expect(content).toContain('export interface GetOrganizationOptions {');
760
- expect(content).toContain('includeFields?: string;');
761
-
762
- // Should use the typed options in the method signature
763
- expect(content).toContain(
764
- 'async getOrganization(id: string, options?: GetOrganizationOptions): Promise<Organization>',
765
- );
766
-
767
- // Should NOT use Record<string, unknown>
768
- expect(content).not.toContain('Record<string, unknown>');
769
- });
770
-
771
- it('generates typed options interface for void GET with query params', () => {
772
- const services: Service[] = [
773
- {
774
- name: 'Auth',
775
- operations: [
776
- {
777
- name: 'authorize',
778
- httpMethod: 'get',
779
- path: '/user_management/authorize',
780
- pathParams: [],
781
- queryParams: [
782
- {
783
- name: 'client_id',
784
- type: { kind: 'primitive', type: 'string' },
785
- required: true,
786
- },
787
- {
788
- name: 'redirect_uri',
789
- type: { kind: 'primitive', type: 'string' },
790
- required: true,
791
- },
792
- {
793
- name: 'response_type',
794
- type: { kind: 'primitive', type: 'string' },
795
- required: true,
796
- },
797
- ],
798
- headerParams: [],
799
- response: { kind: 'primitive', type: 'unknown' },
800
- errors: [],
801
- injectIdempotencyKey: false,
802
- },
803
- ],
804
- },
805
- ];
806
-
807
- const files = generateResources(services, ctx);
808
- const content = files[0].content;
809
-
810
- // Should generate a typed options interface
811
- expect(content).toContain('export interface AuthorizeOptions {');
812
- expect(content).toContain('clientId: string;');
813
- expect(content).toContain('redirectUri: string;');
814
- expect(content).toContain('responseType: string;');
815
-
816
- // Should use the typed options in the method signature
817
- expect(content).toContain('async authorize(options?: AuthorizeOptions): Promise<void>');
818
-
819
- // Should pass options as query params
820
- expect(content).toContain('query: options');
821
- });
822
-
823
- it('falls back to pass-through for non-discriminated union when models not in spec', () => {
824
- const services: Service[] = [
825
- {
826
- name: 'Auth',
827
- operations: [
828
- {
829
- name: 'authenticate',
830
- httpMethod: 'post',
831
- path: '/user_management/authenticate',
832
- pathParams: [],
833
- queryParams: [],
834
- headerParams: [],
835
- requestBody: {
836
- kind: 'union',
837
- variants: [
838
- { kind: 'model', name: 'AuthByPassword' },
839
- { kind: 'model', name: 'AuthByCode' },
840
- { kind: 'model', name: 'AuthByMagicAuth' },
841
- ],
842
- },
843
- response: { kind: 'model', name: 'AuthenticateResponse' },
844
- errors: [],
845
- injectIdempotencyKey: false,
846
- },
847
- ],
848
- },
849
- ];
850
-
851
- const files = generateResources(services, ctx);
852
- const content = files[0].content;
853
-
854
- // Should use the union type for the payload parameter
855
- expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
856
-
857
- // Should NOT use Record<string, unknown>
858
- expect(content).not.toContain('Record<string, unknown>');
859
-
860
- // Models not in spec → falls back to pass-through
861
- expect(content).toContain("'/user_management/authenticate',");
862
- expect(content).toContain('payload,');
863
-
864
- // Should import all union variant types
865
- expect(content).toContain('AuthByPassword');
866
- expect(content).toContain('AuthByCode');
867
- expect(content).toContain('AuthByMagicAuth');
868
- });
869
-
870
- it('generates field-guard serializer dispatch for non-discriminated union with models', () => {
871
- const services: Service[] = [
872
- {
873
- name: 'Applications',
874
- operations: [
875
- {
876
- name: 'createApplication',
877
- httpMethod: 'post',
878
- path: '/connect/applications',
879
- pathParams: [],
880
- queryParams: [],
881
- headerParams: [],
882
- requestBody: {
883
- kind: 'union',
884
- variants: [
885
- { kind: 'model', name: 'CreateOAuthApplication' },
886
- { kind: 'model', name: 'CreateM2MApplication' },
887
- ],
888
- },
889
- response: { kind: 'model', name: 'ConnectApplication' },
890
- errors: [],
891
- injectIdempotencyKey: false,
892
- },
893
- ],
894
- },
895
- ];
896
-
897
- const testCtx: EmitterContext = {
898
- namespace: 'workos',
899
- namespacePascal: 'WorkOS',
900
- spec: {
901
- ...emptySpec,
902
- services,
903
- models: [
904
- {
905
- name: 'CreateOAuthApplication',
906
- fields: [
907
- {
908
- name: 'name',
909
- type: { kind: 'primitive', type: 'string' },
910
- required: true,
911
- },
912
- {
913
- name: 'redirect_uris',
914
- type: {
915
- kind: 'array',
916
- items: { kind: 'primitive', type: 'string' },
917
- },
918
- required: true,
919
- },
920
- {
921
- name: 'uses_pkce',
922
- type: { kind: 'primitive', type: 'boolean' },
923
- required: false,
924
- },
925
- ],
926
- },
927
- {
928
- name: 'CreateM2MApplication',
929
- fields: [
930
- {
931
- name: 'name',
932
- type: { kind: 'primitive', type: 'string' },
933
- required: true,
934
- },
935
- {
936
- name: 'scopes',
937
- type: {
938
- kind: 'array',
939
- items: { kind: 'primitive', type: 'string' },
940
- },
941
- required: true,
942
- },
943
- ],
944
- },
945
- {
946
- name: 'ConnectApplication',
947
- fields: [
948
- {
949
- name: 'id',
950
- type: { kind: 'primitive', type: 'string' },
951
- required: true,
952
- },
953
- ],
954
- },
955
- ],
956
- },
957
- };
958
-
959
- const files = generateResources(services, testCtx);
960
- const content = files[0].content;
961
-
962
- // Should use the union type for the payload parameter
963
- expect(content).toContain('payload: CreateOAuthApplication | CreateM2MApplication');
964
-
965
- // Should dispatch via unique required field guards
966
- expect(content).toContain("'redirectUris' in payload");
967
- expect(content).toContain('serializeCreateOAuthApplication(payload as any)');
968
- expect(content).toContain('serializeCreateM2MApplication(payload as any)');
969
-
970
- // Should import serializers for all union variants
971
- expect(content).toContain('serializeCreateOAuthApplication');
972
- expect(content).toContain('serializeCreateM2MApplication');
973
- });
974
-
975
- it('generates discriminated union serializer dispatch for request body', () => {
976
- const services: Service[] = [
977
- {
978
- name: 'Auth',
979
- operations: [
980
- {
981
- name: 'authenticate',
982
- httpMethod: 'post',
983
- path: '/user_management/authenticate',
984
- pathParams: [],
985
- queryParams: [],
986
- headerParams: [],
987
- requestBody: {
988
- kind: 'union',
989
- variants: [
990
- { kind: 'model', name: 'AuthByPassword' },
991
- { kind: 'model', name: 'AuthByCode' },
992
- { kind: 'model', name: 'AuthByMagicAuth' },
993
- ],
994
- discriminator: {
995
- property: 'grant_type',
996
- mapping: {
997
- password: 'AuthByPassword',
998
- authorization_code: 'AuthByCode',
999
- 'urn:workos:oauth:grant-type:magic-auth:code': 'AuthByMagicAuth',
1000
- },
1001
- },
1002
- },
1003
- response: { kind: 'model', name: 'AuthenticateResponse' },
1004
- errors: [],
1005
- injectIdempotencyKey: false,
1006
- },
1007
- ],
1008
- },
1009
- ];
1010
-
1011
- const files = generateResources(services, ctx);
1012
- const content = files[0].content;
1013
-
1014
- // Should use the union type for the payload parameter
1015
- expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
1016
-
1017
- // Should dispatch to the correct serializer based on the discriminator,
1018
- // using the typed discriminator so TS narrows payload per case.
1019
- expect(content).toContain('switch (payload.grantType)');
1020
- expect(content).toContain("case 'password': return serializeAuthByPassword(payload)");
1021
- expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload)");
1022
- expect(content).toContain(
1023
- "case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload)",
1024
- );
1025
-
1026
- // Should not use `as any` casts — TS discriminated-union narrowing makes
1027
- // them unnecessary and they suppress real type mismatches.
1028
- expect(content).not.toContain('switch ((payload as any)');
1029
- expect(content).not.toMatch(/return serialize\w+\(payload as any\)/);
1030
-
1031
- // Should import serializers for all union variants
1032
- expect(content).toContain('serializeAuthByPassword');
1033
- expect(content).toContain('serializeAuthByCode');
1034
- expect(content).toContain('serializeAuthByMagicAuth');
1035
-
1036
- // Should NOT pass payload directly without serialization
1037
- expect(content).not.toMatch(/,\n\s+payload,\n/);
1038
-
1039
- // Default branch must throw — silently forwarding unserialized camelCase
1040
- // to the API produces malformed requests when the discriminator is unknown.
1041
- expect(content).toContain('default:');
1042
- expect(content).toContain('const _unknown: never = payload');
1043
- expect(content).toContain('throw new Error');
1044
- expect(content).not.toMatch(/default:\s*return payload/);
1045
- });
1046
-
1047
- it('generates discriminated union serializer dispatch for void method', () => {
1048
- const services: Service[] = [
1049
- {
1050
- name: 'Auth',
1051
- operations: [
1052
- {
1053
- name: 'sendToken',
1054
- httpMethod: 'post',
1055
- path: '/auth/token',
1056
- pathParams: [],
1057
- queryParams: [],
1058
- headerParams: [],
1059
- requestBody: {
1060
- kind: 'union',
1061
- variants: [
1062
- { kind: 'model', name: 'TokenByCode' },
1063
- { kind: 'model', name: 'TokenByRefresh' },
1064
- ],
1065
- discriminator: {
1066
- property: 'grant_type',
1067
- mapping: {
1068
- authorization_code: 'TokenByCode',
1069
- refresh_token: 'TokenByRefresh',
1070
- },
1071
- },
1072
- },
1073
- response: { kind: 'primitive', type: 'unknown' },
1074
- errors: [],
1075
- injectIdempotencyKey: false,
1076
- },
1077
- ],
1078
- },
1079
- ];
1080
-
1081
- const files = generateResources(services, ctx);
1082
- const content = files[0].content;
1083
-
1084
- // Should dispatch to the correct serializer using the typed discriminator.
1085
- expect(content).toContain('switch (payload.grantType)');
1086
- expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload)");
1087
- expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload)");
1088
- });
1089
-
1090
- it('uses AutoPaginatable pattern in paginated methods', () => {
1091
- const services: Service[] = [
1092
- {
1093
- name: 'Connections',
1094
- operations: [
1095
- {
1096
- name: 'listConnections',
1097
- httpMethod: 'get',
1098
- path: '/connections',
1099
- pathParams: [],
1100
- queryParams: [],
1101
- headerParams: [],
1102
- response: { kind: 'model', name: 'Connection' },
1103
- errors: [],
1104
- pagination: {
1105
- strategy: 'cursor',
1106
- param: 'after',
1107
- dataPath: 'data',
1108
- itemType: { kind: 'model', name: 'Connection' },
1109
- },
1110
- injectIdempotencyKey: false,
1111
- },
1112
- ],
1113
- },
1114
- ];
1115
-
1116
- const files = generateResources(services, ctx);
1117
- const content = files[0].content;
1118
-
1119
- // Should use AutoPaginatable + fetchAndDeserialize pattern for paginated methods
1120
- expect(content).toContain('new AutoPaginatable(');
1121
- expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
1122
- expect(content).toContain('deserializeConnection');
1123
- });
1124
-
1125
- it('prefixes ListOptions with service name when method is "list"', () => {
1126
- const services: Service[] = [
1127
- {
1128
- name: 'Payments',
1129
- operations: [
1130
- {
1131
- name: 'list',
1132
- httpMethod: 'get',
1133
- path: '/payments',
1134
- pathParams: [],
1135
- queryParams: [
1136
- {
1137
- name: 'connection_type',
1138
- type: { kind: 'primitive', type: 'string' },
1139
- required: false,
1140
- },
1141
- ],
1142
- headerParams: [],
1143
- response: { kind: 'model', name: 'Connection' },
1144
- errors: [],
1145
- pagination: {
1146
- strategy: 'cursor',
1147
- param: 'after',
1148
- dataPath: 'data',
1149
- itemType: { kind: 'model', name: 'Connection' },
1150
- },
1151
- injectIdempotencyKey: false,
1152
- },
1153
- ],
1154
- },
1155
- ];
1156
-
1157
- // Use overlay to resolve method name to "list"
1158
- const overlayCtx: EmitterContext = {
1159
- namespace: 'workos',
1160
- namespacePascal: 'WorkOS',
1161
- spec: { ...emptySpec, services, models: [] },
1162
- overlayLookup: {
1163
- methodByOperation: new Map([
1164
- [
1165
- 'GET /payments',
1166
- {
1167
- className: 'Payments',
1168
- methodName: 'list',
1169
- params: [],
1170
- returnType: 'void',
1171
- },
1172
- ],
1173
- ]),
1174
- httpKeyByMethod: new Map(),
1175
- interfaceByName: new Map(),
1176
- typeAliasByName: new Map(),
1177
- requiredExports: new Map(),
1178
- modelNameByIR: new Map(),
1179
- fileBySymbol: new Map(),
1180
- },
1181
- };
1182
-
1183
- const files = generateResources(services, overlayCtx);
1184
- const content = files[0].content;
1185
-
1186
- // Should use service-prefixed options name instead of generic "ListOptions"
1187
- const optionsFile = files.find((f) => f.path.endsWith('payments-list-options.interface.ts'));
1188
- expect(optionsFile).toBeDefined();
1189
- expect(optionsFile!.content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
1190
- expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
1191
- // Should NOT use the generic "ListOptions"
1192
- expect(content).not.toContain('export interface ListOptions ');
1193
- expect(files.every((f) => !f.path.endsWith('/list-options.interface.ts'))).toBe(true);
1194
- });
1195
-
1196
- it('does not prefix ListOptions when method is not "list"', () => {
1197
- const services: Service[] = [
1198
- {
1199
- name: 'Organizations',
1200
- operations: [
1201
- {
1202
- name: 'listOrganizations',
1203
- httpMethod: 'get',
1204
- path: '/organizations',
1205
- pathParams: [],
1206
- queryParams: [
1207
- {
1208
- name: 'domains',
1209
- type: {
1210
- kind: 'array',
1211
- items: { kind: 'primitive', type: 'string' },
1212
- },
1213
- required: false,
1214
- },
1215
- ],
1216
- headerParams: [],
1217
- response: { kind: 'model', name: 'Organization' },
1218
- errors: [],
1219
- pagination: {
1220
- strategy: 'cursor',
1221
- param: 'after',
1222
- dataPath: 'data',
1223
- itemType: { kind: 'model', name: 'Organization' },
1224
- },
1225
- injectIdempotencyKey: false,
1226
- },
1227
- ],
1228
- },
1229
- ];
1230
-
1231
- const files = generateResources(services, ctx);
1232
-
1233
- // Method is "listOrganizations", not "list", so options name should be normal
1234
- const optionsFile = files.find((f) => f.path.endsWith('list-organizations-options.interface.ts'));
1235
- expect(optionsFile).toBeDefined();
1236
- expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
1237
- });
1238
-
1239
- it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
1240
- const services: Service[] = [
1241
- {
1242
- name: 'SSOService',
1243
- operations: [
1244
- {
1245
- name: 'getAuthorizationUrl',
1246
- httpMethod: 'get',
1247
- path: '/sso/authorize',
1248
- pathParams: [],
1249
- queryParams: [],
1250
- headerParams: [],
1251
- response: { kind: 'model', name: 'AuthorizationUrl' },
1252
- errors: [],
1253
- injectIdempotencyKey: false,
1254
- },
1255
- {
1256
- name: 'logout',
1257
- httpMethod: 'get',
1258
- path: '/sso/logout',
1259
- pathParams: [],
1260
- queryParams: [],
1261
- headerParams: [],
1262
- response: { kind: 'model', name: 'LogoutResult' },
1263
- errors: [],
1264
- injectIdempotencyKey: false,
1265
- },
1266
- ],
1267
- },
1268
- ];
1269
-
1270
- // Overlay maps both operations to SSO class
1271
- // Baseline SSO class exists but only has getAuthorizationUrl (logout is missing)
1272
- const overlayCtx: EmitterContext = {
1273
- namespace: 'workos',
1274
- namespacePascal: 'WorkOS',
1275
- spec: { ...emptySpec, services, models: [] },
1276
- overlayLookup: {
1277
- methodByOperation: new Map([
1278
- [
1279
- 'GET /sso/authorize',
1280
- {
1281
- className: 'SSO',
1282
- methodName: 'getAuthorizationUrl',
1283
- params: [],
1284
- returnType: 'void',
1285
- },
1286
- ],
1287
- [
1288
- 'GET /sso/logout',
1289
- {
1290
- className: 'SSO',
1291
- methodName: 'logout',
1292
- params: [],
1293
- returnType: 'void',
1294
- },
1295
- ],
1296
- ]),
1297
- httpKeyByMethod: new Map(),
1298
- interfaceByName: new Map(),
1299
- typeAliasByName: new Map(),
1300
- requiredExports: new Map(),
1301
- modelNameByIR: new Map(),
1302
- fileBySymbol: new Map(),
1303
- },
1304
- apiSurface: {
1305
- language: 'node',
1306
- extractedFrom: 'test',
1307
- extractedAt: '2024-01-01',
1308
- classes: {
1309
- SSO: {
1310
- name: 'SSO',
1311
- methods: {
1312
- getAuthorizationUrl: [
1313
- {
1314
- name: 'getAuthorizationUrl',
1315
- params: [],
1316
- returnType: 'void',
1317
- async: true,
1318
- },
1319
- ],
1320
- // logout method is intentionally ABSENT
1321
- },
1322
- properties: {},
1323
- constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1324
- },
1325
- },
1326
- interfaces: {},
1327
- typeAliases: {},
1328
- enums: {},
1329
- exports: {},
1330
- },
1331
- };
1332
-
1333
- const files = generateResources(services, overlayCtx);
1334
- expect(files.length).toBe(1);
1335
-
1336
- // skipIfExists should be removed because 'logout' is absent from baseline
1337
- expect(files[0].skipIfExists).toBeUndefined();
1338
- });
1339
-
1340
- it('keeps skipIfExists when fully-covered service has all methods in baseline', () => {
1341
- const services: Service[] = [
1342
- {
1343
- name: 'SSOService',
1344
- operations: [
1345
- {
1346
- name: 'getAuthorizationUrl',
1347
- httpMethod: 'get',
1348
- path: '/sso/authorize',
1349
- pathParams: [],
1350
- queryParams: [],
1351
- headerParams: [],
1352
- response: { kind: 'model', name: 'AuthorizationUrl' },
1353
- errors: [],
1354
- injectIdempotencyKey: false,
1355
- },
1356
- ],
1357
- },
1358
- ];
1359
-
1360
- const overlayCtx: EmitterContext = {
1361
- namespace: 'workos',
1362
- namespacePascal: 'WorkOS',
1363
- spec: { ...emptySpec, services, models: [] },
1364
- overlayLookup: {
1365
- methodByOperation: new Map([
1366
- [
1367
- 'GET /sso/authorize',
1368
- {
1369
- className: 'SSO',
1370
- methodName: 'getAuthorizationUrl',
1371
- params: [],
1372
- returnType: 'void',
1373
- },
1374
- ],
1375
- ]),
1376
- httpKeyByMethod: new Map(),
1377
- interfaceByName: new Map(),
1378
- typeAliasByName: new Map(),
1379
- requiredExports: new Map(),
1380
- modelNameByIR: new Map(),
1381
- fileBySymbol: new Map(),
1382
- },
1383
- apiSurface: {
1384
- language: 'node',
1385
- extractedFrom: 'test',
1386
- extractedAt: '2024-01-01',
1387
- classes: {
1388
- SSO: {
1389
- name: 'SSO',
1390
- methods: {
1391
- getAuthorizationUrl: [
1392
- {
1393
- name: 'getAuthorizationUrl',
1394
- params: [],
1395
- returnType: 'void',
1396
- async: true,
1397
- },
1398
- ],
1399
- },
1400
- properties: {},
1401
- constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1402
- },
1403
- },
1404
- interfaces: {},
1405
- typeAliases: {},
1406
- enums: {},
1407
- exports: {},
1408
- },
1409
- };
1410
-
1411
- const files = generateResources(services, overlayCtx);
1412
- // Fully covered services with no new methods are skipped entirely
1413
- expect(files.length).toBe(0);
1414
- });
1415
-
1416
- it('removes skipIfExists for purely oagen-managed services (no baseline)', () => {
1417
- const services: Service[] = [
1418
- {
1419
- name: 'Applications',
1420
- operations: [
1421
- {
1422
- name: 'create',
1423
- httpMethod: 'post',
1424
- path: '/connect/applications',
1425
- pathParams: [],
1426
- queryParams: [],
1427
- headerParams: [],
1428
- response: { kind: 'model', name: 'ConnectApplication' },
1429
- errors: [],
1430
- injectIdempotencyKey: false,
1431
- },
1432
- ],
1433
- },
1434
- ];
1435
-
1436
- const overlayCtx: EmitterContext = {
1437
- namespace: 'workos',
1438
- namespacePascal: 'WorkOS',
1439
- spec: { ...emptySpec, services, models: [] },
1440
- overlayLookup: {
1441
- methodByOperation: new Map([
1442
- [
1443
- 'POST /connect/applications',
1444
- {
1445
- className: 'Applications',
1446
- methodName: 'create',
1447
- params: [],
1448
- returnType: 'ConnectApplication',
1449
- },
1450
- ],
1451
- ]),
1452
- httpKeyByMethod: new Map(),
1453
- interfaceByName: new Map(),
1454
- typeAliasByName: new Map(),
1455
- requiredExports: new Map(),
1456
- modelNameByIR: new Map(),
1457
- fileBySymbol: new Map(),
1458
- },
1459
- apiSurface: {
1460
- language: 'node',
1461
- extractedFrom: 'test',
1462
- extractedAt: '2024-01-01',
1463
- // No baseline class for Applications — purely oagen-managed.
1464
- classes: {},
1465
- interfaces: {},
1466
- typeAliases: {},
1467
- enums: {},
1468
- exports: {},
1469
- },
1470
- };
1471
-
1472
- const files = generateResources(services, overlayCtx);
1473
- expect(files.length).toBe(1);
1474
-
1475
- // skipIfExists must be removed so emitter improvements always overwrite.
1476
- expect(files[0].skipIfExists).toBeUndefined();
1477
- });
1478
- });
1479
-
1480
- describe('resolveResourceClassName', () => {
1481
- const webhooksService: Service = {
1482
- name: 'WebhookEvents',
1483
- operations: [
1484
- {
1485
- name: 'listWebhookEvents',
1486
- httpMethod: 'get',
1487
- path: '/webhook_events',
1488
- pathParams: [],
1489
- queryParams: [],
1490
- headerParams: [],
1491
- response: { kind: 'model', name: 'WebhookEvent' },
1492
- errors: [],
1493
- injectIdempotencyKey: false,
1494
- },
1495
- ],
1496
- };
1497
-
1498
- it('generates separate class when baseline has incompatible constructor', () => {
1499
- const overlayCtx: EmitterContext = {
1500
- namespace: 'workos',
1501
- namespacePascal: 'WorkOS',
1502
- spec: { ...emptySpec, services: [webhooksService] },
1503
- overlayLookup: {
1504
- methodByOperation: new Map([
1505
- [
1506
- 'GET /webhook_events',
1507
- {
1508
- className: 'Webhooks',
1509
- methodName: 'listWebhookEvents',
1510
- params: [],
1511
- returnType: 'void',
1512
- },
1513
- ],
1514
- ]),
1515
- httpKeyByMethod: new Map(),
1516
- interfaceByName: new Map(),
1517
- typeAliasByName: new Map(),
1518
- requiredExports: new Map(),
1519
- modelNameByIR: new Map(),
1520
- fileBySymbol: new Map(),
1521
- },
1522
- apiSurface: {
1523
- language: 'node',
1524
- extractedFrom: 'test',
1525
- extractedAt: '2024-01-01',
1526
- classes: {
1527
- Webhooks: {
1528
- name: 'Webhooks',
1529
- methods: {},
1530
- properties: {},
1531
- constructorParams: [
1532
- {
1533
- name: 'cryptoProvider',
1534
- type: 'CryptoProvider',
1535
- optional: false,
1536
- },
1537
- ],
1538
- },
1539
- },
1540
- interfaces: {},
1541
- typeAliases: {},
1542
- enums: {},
1543
- exports: {},
1544
- },
1545
- };
1546
-
1547
- const result = resolveResourceClassName(webhooksService, overlayCtx);
1548
- // Falls back to IR name since overlay name has incompatible constructor
1549
- expect(result).toBe('WebhookEvents');
1550
- });
1551
-
1552
- it('uses overlay name when baseline has compatible constructor', () => {
1553
- const overlayCtx: EmitterContext = {
1554
- namespace: 'workos',
1555
- namespacePascal: 'WorkOS',
1556
- spec: { ...emptySpec, services: [webhooksService] },
1557
- overlayLookup: {
1558
- methodByOperation: new Map([
1559
- [
1560
- 'GET /webhook_events',
1561
- {
1562
- className: 'Webhooks',
1563
- methodName: 'listWebhookEvents',
1564
- params: [],
1565
- returnType: 'void',
1566
- },
1567
- ],
1568
- ]),
1569
- httpKeyByMethod: new Map(),
1570
- interfaceByName: new Map(),
1571
- typeAliasByName: new Map(),
1572
- requiredExports: new Map(),
1573
- modelNameByIR: new Map(),
1574
- fileBySymbol: new Map(),
1575
- },
1576
- apiSurface: {
1577
- language: 'node',
1578
- extractedFrom: 'test',
1579
- extractedAt: '2024-01-01',
1580
- classes: {
1581
- Webhooks: {
1582
- name: 'Webhooks',
1583
- methods: {},
1584
- properties: {},
1585
- constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1586
- },
1587
- },
1588
- interfaces: {},
1589
- typeAliases: {},
1590
- enums: {},
1591
- exports: {},
1592
- },
1593
- };
1594
-
1595
- const result = resolveResourceClassName(webhooksService, overlayCtx);
1596
- expect(result).toBe('Webhooks');
1597
- });
1598
-
1599
- it('appends Endpoints suffix when IR name collides with overlay name', () => {
1600
- const collisionService: Service = {
1601
- name: 'Webhooks',
1602
- operations: [
1603
- {
1604
- name: 'listWebhooks',
1605
- httpMethod: 'get',
1606
- path: '/webhooks',
1607
- pathParams: [],
1608
- queryParams: [],
1609
- headerParams: [],
1610
- response: { kind: 'model', name: 'Webhook' },
1611
- errors: [],
1612
- injectIdempotencyKey: false,
1613
- },
1614
- ],
1615
- };
1616
-
1617
- const overlayCtx: EmitterContext = {
1618
- namespace: 'workos',
1619
- namespacePascal: 'WorkOS',
1620
- spec: { ...emptySpec, services: [collisionService] },
1621
- overlayLookup: {
1622
- methodByOperation: new Map([
1623
- [
1624
- 'GET /webhooks',
1625
- {
1626
- className: 'Webhooks',
1627
- methodName: 'listWebhooks',
1628
- params: [],
1629
- returnType: 'void',
1630
- },
1631
- ],
1632
- ]),
1633
- httpKeyByMethod: new Map(),
1634
- interfaceByName: new Map(),
1635
- typeAliasByName: new Map(),
1636
- requiredExports: new Map(),
1637
- modelNameByIR: new Map(),
1638
- fileBySymbol: new Map(),
1639
- },
1640
- apiSurface: {
1641
- language: 'node',
1642
- extractedFrom: 'test',
1643
- extractedAt: '2024-01-01',
1644
- classes: {
1645
- Webhooks: {
1646
- name: 'Webhooks',
1647
- methods: {},
1648
- properties: {},
1649
- constructorParams: [
1650
- {
1651
- name: 'cryptoProvider',
1652
- type: 'CryptoProvider',
1653
- optional: false,
1654
- },
1655
- ],
1656
- },
1657
- },
1658
- interfaces: {},
1659
- typeAliases: {},
1660
- enums: {},
1661
- exports: {},
1662
- },
1663
- };
1664
-
1665
- const result = resolveResourceClassName(collisionService, overlayCtx);
1666
- // IR name "Webhooks" collides with overlay name "Webhooks", so append Endpoints
1667
- expect(result).toBe('WebhooksEndpoints');
1668
- });
1669
- });
1670
-
1671
- describe('hasCompatibleConstructor', () => {
1672
- it('returns true when no baseline exists', () => {
1673
- expect(hasCompatibleConstructor('NewService', ctx)).toBe(true);
1674
- });
1675
-
1676
- it('returns true when baseline has workos: WorkOS param', () => {
1677
- const ctxWithSurface: EmitterContext = {
1678
- ...ctx,
1679
- apiSurface: {
1680
- language: 'node',
1681
- extractedFrom: 'test',
1682
- extractedAt: '2024-01-01',
1683
- classes: {
1684
- Organizations: {
1685
- name: 'Organizations',
1686
- methods: {},
1687
- properties: {},
1688
- constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1689
- },
1690
- },
1691
- interfaces: {},
1692
- typeAliases: {},
1693
- enums: {},
1694
- exports: {},
1695
- },
1696
- };
1697
-
1698
- expect(hasCompatibleConstructor('Organizations', ctxWithSurface)).toBe(true);
1699
- });
1700
-
1701
- it('returns false when baseline has incompatible constructor', () => {
1702
- const ctxWithSurface: EmitterContext = {
1703
- ...ctx,
1704
- apiSurface: {
1705
- language: 'node',
1706
- extractedFrom: 'test',
1707
- extractedAt: '2024-01-01',
1708
- classes: {
1709
- Webhooks: {
1710
- name: 'Webhooks',
1711
- methods: {},
1712
- properties: {},
1713
- constructorParams: [
1714
- {
1715
- name: 'cryptoProvider',
1716
- type: 'CryptoProvider',
1717
- optional: false,
1718
- },
1719
- ],
1720
- },
1721
- },
1722
- interfaces: {},
1723
- typeAliases: {},
1724
- enums: {},
1725
- exports: {},
1726
- },
1727
- };
1728
-
1729
- expect(hasCompatibleConstructor('Webhooks', ctxWithSurface)).toBe(false);
1730
- });
1731
-
1732
- it('returns true when baseline has no constructor params', () => {
1733
- const ctxWithSurface: EmitterContext = {
1734
- ...ctx,
1735
- apiSurface: {
1736
- language: 'node',
1737
- extractedFrom: 'test',
1738
- extractedAt: '2024-01-01',
1739
- classes: {
1740
- EmptyService: {
1741
- name: 'EmptyService',
1742
- methods: {},
1743
- properties: {},
1744
- constructorParams: [],
1745
- },
1746
- },
1747
- interfaces: {},
1748
- typeAliases: {},
1749
- enums: {},
1750
- exports: {},
1751
- },
1752
- };
1753
-
1754
- expect(hasCompatibleConstructor('EmptyService', ctxWithSurface)).toBe(true);
1755
- });
1756
- });
1757
-
1758
- describe('partial service coverage', () => {
1759
- it('generates methods for uncovered operations in partially covered services', () => {
1760
- const services: Service[] = [
1761
- {
1762
- name: 'AuditLogs',
1763
- operations: [
1764
- {
1765
- name: 'createEvent',
1766
- httpMethod: 'post',
1767
- path: '/audit_logs/events',
1768
- pathParams: [],
1769
- queryParams: [],
1770
- headerParams: [],
1771
- response: { kind: 'model', name: 'AuditLogEvent' },
1772
- errors: [],
1773
- injectIdempotencyKey: false,
1774
- },
1775
- {
1776
- name: 'getRetention',
1777
- httpMethod: 'get',
1778
- path: '/audit_logs/retention',
1779
- pathParams: [],
1780
- queryParams: [],
1781
- headerParams: [],
1782
- response: { kind: 'model', name: 'AuditLogRetention' },
1783
- errors: [],
1784
- injectIdempotencyKey: false,
1785
- },
1786
- ],
1787
- },
1788
- ];
1789
-
1790
- // createEvent is covered by existing AuditLogs class, getRetention is NOT
1791
- const ctxPartial: EmitterContext = {
1792
- ...ctx,
1793
- spec: { ...emptySpec, services, models: [] },
1794
- overlayLookup: {
1795
- methodByOperation: new Map([
1796
- [
1797
- 'POST /audit_logs/events',
1798
- {
1799
- className: 'AuditLogs',
1800
- methodName: 'createEvent',
1801
- params: [],
1802
- returnType: 'AuditLogEvent',
1803
- },
1804
- ],
1805
- ]),
1806
- httpKeyByMethod: new Map(),
1807
- interfaceByName: new Map(),
1808
- typeAliasByName: new Map(),
1809
- requiredExports: new Map(),
1810
- modelNameByIR: new Map(),
1811
- fileBySymbol: new Map(),
1812
- },
1813
- apiSurface: {
1814
- language: 'node',
1815
- extractedFrom: 'test',
1816
- extractedAt: '2024-01-01',
1817
- classes: {
1818
- AuditLogs: {
1819
- name: 'AuditLogs',
1820
- methods: {
1821
- createEvent: [
1822
- {
1823
- name: 'createEvent',
1824
- params: [],
1825
- returnType: 'AuditLogEvent',
1826
- async: true,
1827
- },
1828
- ],
1829
- },
1830
- properties: {},
1831
- constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1832
- },
1833
- },
1834
- interfaces: {},
1835
- typeAliases: {},
1836
- enums: {},
1837
- exports: {},
1838
- },
1839
- };
1840
-
1841
- const files = generateResources(services, ctxPartial);
1842
- expect(files.length).toBe(1);
1843
- const content = files[0].content;
1844
-
1845
- // Should generate method for uncovered operation
1846
- expect(content).toContain('async getRetention');
1847
- // Should also generate covered operation so the merger can apply JSDoc
1848
- expect(content).toContain('async createEvent');
1849
- });
1850
-
1851
- it('skips fully covered services with no new methods', () => {
1852
- const services: Service[] = [
1853
- {
1854
- name: 'Permissions',
1855
- operations: [
1856
- {
1857
- name: 'listPermissions',
1858
- description: 'List all permissions.',
1859
- httpMethod: 'get',
1860
- path: '/authorization/permissions',
1861
- pathParams: [],
1862
- queryParams: [],
1863
- headerParams: [],
1864
- response: { kind: 'model', name: 'PermissionList' },
1865
- errors: [{ statusCode: 404 }],
1866
- injectIdempotencyKey: false,
1867
- },
1868
- ],
1869
- },
1870
- ];
1871
-
1872
- const ctxCovered: EmitterContext = {
1873
- ...ctx,
1874
- spec: { ...emptySpec, services, models: [] },
1875
- overlayLookup: {
1876
- methodByOperation: new Map([
1877
- [
1878
- 'GET /authorization/permissions',
1879
- {
1880
- className: 'Permissions',
1881
- methodName: 'listPermissions',
1882
- params: [],
1883
- returnType: 'void',
1884
- },
1885
- ],
1886
- ]),
1887
- httpKeyByMethod: new Map(),
1888
- interfaceByName: new Map(),
1889
- typeAliasByName: new Map(),
1890
- requiredExports: new Map(),
1891
- modelNameByIR: new Map(),
1892
- fileBySymbol: new Map(),
1893
- },
1894
- apiSurface: {
1895
- language: 'node',
1896
- extractedFrom: 'test',
1897
- extractedAt: '2024-01-01',
1898
- classes: {
1899
- Permissions: {
1900
- name: 'Permissions',
1901
- methods: {
1902
- listPermissions: [
1903
- {
1904
- name: 'listPermissions',
1905
- params: [],
1906
- returnType: 'void',
1907
- async: true,
1908
- },
1909
- ],
1910
- },
1911
- properties: {},
1912
- constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1913
- },
1914
- },
1915
- interfaces: {},
1916
- typeAliases: {},
1917
- enums: {},
1918
- exports: {},
1919
- },
1920
- };
1921
-
1922
- const files = generateResources(services, ctxCovered);
1923
- // Fully covered services with no new methods are skipped entirely
1924
- // (JSDoc-only updates are deferred until a structural change touches the file)
1925
- expect(files.length).toBe(0);
1926
- });
1927
-
1928
- it('uses resolved operation method names when provided', () => {
1929
- const op = {
1930
- name: 'listRolesOrganizations',
1931
- httpMethod: 'get' as const,
1932
- path: '/authorization/organizations/{organizationId}/roles',
1933
- pathParams: [
1934
- {
1935
- name: 'organizationId',
1936
- type: { kind: 'primitive' as const, type: 'string' as const },
1937
- required: true,
1938
493
  },
1939
- ],
1940
- queryParams: [],
1941
- headerParams: [],
1942
- response: { kind: 'model' as const, name: 'RoleList' },
1943
- errors: [],
1944
- pagination: {
1945
- strategy: 'cursor' as const,
1946
- param: 'after',
1947
- itemType: { kind: 'model' as const, name: 'RoleList' },
1948
- },
1949
- injectIdempotencyKey: false,
1950
- };
1951
- const services: Service[] = [
1952
- {
1953
- name: 'Authorization',
1954
- operations: [op],
1955
- },
1956
- ];
1957
-
1958
- const ctxResolved: EmitterContext = {
1959
- ...ctx,
1960
- spec: {
1961
- ...emptySpec,
1962
- services,
1963
- models: [{ name: 'RoleList', fields: [] }],
1964
- },
1965
- resolvedOperations: [
1966
- {
1967
- operation: op,
1968
- service: services[0],
1969
- methodName: 'list_organization_roles',
1970
- mountOn: 'Authorization',
1971
- } as any,
1972
- ],
1973
- };
1974
-
1975
- const files = generateResources(services, ctxResolved);
1976
- expect(files.length).toBe(1);
1977
- const content = files[0].content;
1978
- // Should use the resolved operation name (converted to camelCase)
1979
- expect(content).toContain('async listOrganizationRoles');
1980
- expect(content).not.toContain('async listRolesOrganizations');
1981
- });
1982
-
1983
- it('deduplicates method names for operations on different paths', () => {
1984
- const services: Service[] = [
1985
- {
1986
- name: 'Organizations',
1987
- operations: [
1988
- {
1989
- name: 'create',
1990
- httpMethod: 'post',
1991
- path: '/organization_domains',
1992
- pathParams: [],
1993
- queryParams: [],
1994
- headerParams: [],
1995
- requestBody: { kind: 'model', name: 'CreateOrgDomain' },
1996
- response: { kind: 'model', name: 'OrgDomain' },
1997
- errors: [],
1998
- injectIdempotencyKey: false,
1999
- },
2000
- {
2001
- name: 'create',
2002
- httpMethod: 'post',
2003
- path: '/organizations',
2004
- pathParams: [],
2005
- queryParams: [],
2006
- headerParams: [],
2007
- requestBody: { kind: 'model', name: 'CreateOrg' },
2008
- response: { kind: 'model', name: 'Organization' },
2009
- errors: [],
2010
- injectIdempotencyKey: false,
2011
- },
2012
- ],
2013
- },
2014
- ];
2015
-
2016
- const ctxDedup: EmitterContext = {
2017
- ...ctx,
2018
- spec: {
2019
- ...emptySpec,
2020
- services,
2021
- models: [
2022
- { name: 'CreateOrgDomain', fields: [] },
2023
- { name: 'OrgDomain', fields: [] },
2024
- { name: 'CreateOrg', fields: [] },
2025
- { name: 'Organization', fields: [] },
2026
- ],
2027
- },
2028
- };
2029
-
2030
- const files = generateResources(services, ctxDedup);
2031
- expect(files.length).toBe(1);
2032
- const content = files[0].content;
2033
- // The best-scoring plan keeps the name; the other gets disambiguated.
2034
- // "create" matches "organizations" path better (the word "create" doesn't
2035
- // appear in either path, but scoring is equal — first wins).
2036
- // The other gets a path suffix.
2037
- const createMatches = content.match(/async create\b/g);
2038
- // At most one un-suffixed "create"
2039
- expect(createMatches?.length ?? 0).toBeLessThanOrEqual(1);
2040
- // The two methods should have different names
2041
- const methodNames = [...content.matchAll(/async (\w+)\(/g)].map((m) => m[1]);
2042
- const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
2043
- expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
2044
- });
2045
-
2046
- it('omits @param payload when overlay method has no payload param', () => {
2047
- const services: Service[] = [
2048
- {
2049
- name: 'AuditLogs',
2050
- operations: [
2051
- {
2052
- name: 'createEvent',
2053
- httpMethod: 'post',
2054
- path: '/audit_logs/events',
2055
- pathParams: [],
2056
- queryParams: [],
2057
- headerParams: [],
2058
- requestBody: { kind: 'model', name: 'CreateAuditLogEvent' },
2059
- response: { kind: 'primitive', type: 'unknown' },
2060
- errors: [],
2061
- injectIdempotencyKey: false,
2062
- },
2063
- ],
2064
- },
2065
- ];
2066
-
2067
- const overlayCtx: EmitterContext = {
2068
- namespace: 'workos',
2069
- namespacePascal: 'WorkOS',
2070
- spec: { ...emptySpec, services, models: [] },
2071
- overlayLookup: {
2072
- methodByOperation: new Map([
2073
- [
2074
- 'POST /audit_logs/events',
2075
- {
2076
- className: 'AuditLogs',
2077
- methodName: 'createEvent',
2078
- params: [
2079
- { name: 'organization', type: 'string', optional: false },
2080
- { name: 'event', type: 'CreateAuditLogEventOptions', optional: false },
2081
- { name: 'options', type: 'CreateAuditLogEventRequestOptions', optional: true },
2082
- ],
2083
- returnType: 'Promise<void>',
2084
- },
2085
- ],
2086
- ]),
2087
- httpKeyByMethod: new Map(),
2088
- interfaceByName: new Map(),
2089
- typeAliasByName: new Map(),
2090
- requiredExports: new Map(),
2091
- modelNameByIR: new Map(),
2092
- fileBySymbol: new Map(),
2093
- },
2094
- };
2095
-
2096
- const files = generateResources(services, overlayCtx);
2097
- const content = files[0].content;
2098
- // Overlay has (organization, event, options) — no payload param
2099
- expect(content).not.toContain('@param payload');
2100
- });
2101
-
2102
- it('documents @param options when overlay folds path params into options', () => {
2103
- const services: Service[] = [
2104
- {
2105
- name: 'FeatureFlags',
2106
- operations: [
2107
- {
2108
- name: 'addFeatureFlagTarget',
2109
- httpMethod: 'post',
2110
- path: '/feature-flags/{slug}/targets/{target_id}',
2111
- pathParams: [
2112
- { name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true },
2113
- { name: 'target_id', type: { kind: 'primitive', type: 'string' }, required: true },
2114
- ],
2115
- queryParams: [],
2116
- headerParams: [],
2117
- response: { kind: 'primitive', type: 'unknown' },
2118
- errors: [],
2119
- injectIdempotencyKey: false,
2120
- },
2121
- ],
2122
- },
2123
- ];
2124
-
2125
- const overlayCtx: EmitterContext = {
2126
- namespace: 'workos',
2127
- namespacePascal: 'WorkOS',
2128
- spec: { ...emptySpec, services, models: [] },
2129
- overlayLookup: {
2130
- methodByOperation: new Map([
2131
- [
2132
- 'POST /feature-flags/{slug}/targets/{target_id}',
2133
- {
2134
- className: 'FeatureFlags',
2135
- methodName: 'addFlagTarget',
2136
- params: [{ name: 'options', type: 'AddFlagTargetOptions', optional: false }],
2137
- returnType: 'Promise<void>',
2138
- },
2139
- ],
2140
- ]),
2141
- httpKeyByMethod: new Map(),
2142
- interfaceByName: new Map(),
2143
- typeAliasByName: new Map(),
2144
- requiredExports: new Map(),
2145
- modelNameByIR: new Map(),
2146
- fileBySymbol: new Map(),
2147
- },
494
+ } as any,
2148
495
  };
2149
-
2150
- const files = generateResources(services, overlayCtx);
2151
- const content = files[0].content;
2152
- // Path params (slug, targetId) are folded into options — should not appear as top-level @param
2153
- expect(content).not.toContain('@param slug');
2154
- expect(content).not.toContain('@param targetId');
2155
- // Should have @param options since it's in the overlay signature
2156
- expect(content).toContain('@param options');
496
+ expect(hasCompatibleConstructor('Utils', ctxWithEmptyCtor)).toBe(true);
2157
497
  });
2158
498
  });