@workos/oagen-emitters 0.12.1 → 0.12.3

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 (45) 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 +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
  12. package/dist/plugin-D2N2ZT5W.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 +354 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +547 -351
  24. package/src/node/naming.ts +122 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/path-expression.ts +11 -4
  28. package/src/node/resources.ts +473 -48
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +152 -93
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/test/node/client.test.ts +106 -1201
  35. package/test/node/enums.test.ts +59 -130
  36. package/test/node/errors.test.ts +2 -3
  37. package/test/node/live-surface.test.ts +240 -0
  38. package/test/node/models.test.ts +396 -765
  39. package/test/node/naming.test.ts +69 -234
  40. package/test/node/resources.test.ts +435 -2025
  41. package/test/node/tests.test.ts +214 -0
  42. package/test/node/type-map.test.ts +49 -54
  43. package/test/node/utils.test.ts +29 -80
  44. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  45. 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,209 @@ 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;
128
+ const spec: ApiSpec = { ...emptySpec, services };
129
+ const ctxWithSpec: EmitterContext = { ...ctx, spec };
130
+ const result = generateResources(services, ctxWithSpec);
101
131
 
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
- );
132
+ const resourceFile = result.find((f) => f.path.includes('organizations.ts'));
133
+ expect(resourceFile).toBeDefined();
134
+ expect(resourceFile!.content).toContain('Promise<void>');
135
+ });
110
136
 
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[];');
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
+ };
118
169
 
119
- // Should return AutoPaginatable
120
- expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
170
+ const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
121
171
 
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,');
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');
128
177
  });
129
178
 
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' },
179
+ it('options-object: URL template binds to the SDK field name, not the spec path-param name', () => {
180
+ // When the spec uses `omId` as a path-param name but the baseline options
181
+ // interface exposes `organizationMembershipId`, both the destructure and
182
+ // the URL template should reference `organizationMembershipId` directly
183
+ // no `organizationMembershipId: omId` rename indirection in the body.
184
+ const operation = {
185
+ name: 'removeOrganizationMembership',
186
+ httpMethod: 'delete' as const,
187
+ path: '/organizations/{organizationId}/groups/{groupId}/organization-memberships/{omId}',
188
+ pathParams: [
189
+ { name: 'organizationId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
190
+ { name: 'groupId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
191
+ { name: 'omId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
192
+ ],
193
+ queryParams: [],
194
+ headerParams: [],
195
+ response: { kind: 'primitive' as const, type: 'unknown' as const },
196
+ errors: [],
197
+ injectIdempotencyKey: false,
198
+ };
199
+ const service: Service = { name: 'Groups', operations: [operation] };
200
+ const spec: ApiSpec = { ...emptySpec, services: [service] };
201
+ const ctxWithBaseline: EmitterContext = {
202
+ ...ctx,
203
+ spec,
204
+ emitterOptions: { ownedServices: ['Groups'] },
205
+ apiSurface: {
206
+ classes: {
207
+ Groups: {
208
+ constructorParams: [{ name: 'workos', type: 'WorkOS' }],
209
+ methods: {
210
+ removeOrganizationMembership: [
211
+ {
212
+ name: 'removeOrganizationMembership',
213
+ params: [
214
+ {
215
+ name: 'options',
216
+ type: 'RemoveGroupOrganizationMembershipOptions',
217
+ passingStyle: 'options_object',
218
+ },
219
+ ],
220
+ returnType: 'Promise<void>',
221
+ async: true,
222
+ },
223
+ ],
160
224
  },
161
- injectIdempotencyKey: false,
162
225
  },
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
- });
195
-
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: [],
209
- queryParams: [],
210
- headerParams: [],
211
- response: { kind: 'model', name: 'ConnectionList' },
212
- errors: [],
213
- pagination: {
214
- strategy: 'cursor',
215
- param: 'after',
216
- dataPath: 'data',
217
- itemType: { kind: 'model', name: 'Connection' },
226
+ },
227
+ interfaces: {
228
+ RemoveGroupOrganizationMembershipOptions: {
229
+ fields: {
230
+ organizationId: { type: 'string', required: true },
231
+ groupId: { type: 'string', required: true },
232
+ organizationMembershipId: { type: 'string', required: true },
218
233
  },
219
- injectIdempotencyKey: false,
220
234
  },
221
- ],
222
- },
223
- ];
224
-
225
- const testCtx: EmitterContext = {
226
- namespace: 'workos',
227
- namespacePascal: 'WorkOS',
228
- spec: { ...emptySpec, services, models: [] },
235
+ },
236
+ } as any,
229
237
  };
230
238
 
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,');
238
-
239
- // Should NOT reference the list wrapper type
240
- expect(content).not.toContain('ConnectionList');
241
- expect(content).not.toContain('deserializeConnectionList');
242
- });
243
-
244
- it('generates DELETE method returning void', () => {
245
- const services: Service[] = [
246
- {
247
- name: 'Organizations',
248
- operations: [
249
- {
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
- ],
239
+ const result = generateResources([service], ctxWithBaseline);
240
+ const resourceFile = result.find((f) => f.path === 'src/groups/groups.ts');
241
+ expect(resourceFile).toBeDefined();
242
+ const content = resourceFile!.content;
243
+ expect(content).toContain('const { organizationId, groupId, organizationMembershipId } = options;');
244
+ expect(content).toContain('${encodeURIComponent(organizationMembershipId)}');
245
+ expect(content).not.toContain('organizationMembershipId: omId');
246
+ expect(content).not.toContain('encodeURIComponent(omId)');
247
+ });
248
+
249
+ it('drops brand-new service paths in an existing SDK by default', () => {
250
+ const tmpRoot = createTrackedSdkRoot();
251
+ try {
252
+ const spec: ApiSpec = { ...emptySpec, services: [connectService] };
253
+ const result = nodeEmitter.generateResources(spec.services, { ...ctx, spec, outputDir: tmpRoot });
254
+
255
+ expect(result.some((f) => f.path === 'src/connect/connect.ts')).toBe(false);
256
+ } finally {
257
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ it('adopts brand-new service paths when configured', () => {
262
+ const tmpRoot = createTrackedSdkRoot();
263
+ try {
264
+ const spec: ApiSpec = { ...emptySpec, services: [connectService] };
265
+ const result = nodeEmitter.generateResources(spec.services, {
266
+ ...ctx,
267
+ spec,
268
+ outputDir: tmpRoot,
269
+ emitterOptions: { adoptMissingServices: true },
270
+ } as EmitterContext);
271
+
272
+ const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
273
+ expect(resourceFile).toBeDefined();
274
+ expect(resourceFile!.content).toContain('export class Connect');
275
+ } finally {
276
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
277
+ }
278
+ });
279
+
280
+ it('overwrites tracked files for explicitly owned services', () => {
281
+ const tmpRoot = createTrackedSdkRoot();
282
+ try {
283
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'groups'), { recursive: true });
284
+ fs.writeFileSync(
285
+ path.join(tmpRoot, 'src', 'groups', 'groups.ts'),
286
+ 'export class Groups { async createGroup() {} }\n',
287
+ );
288
+ execFileSync('git', ['add', 'src/groups/groups.ts'], { cwd: tmpRoot, stdio: 'ignore' });
289
+
290
+ const groupService: Service = {
291
+ name: 'Groups',
292
+ operations: [
293
+ {
294
+ name: 'createGroup',
295
+ httpMethod: 'post',
296
+ path: '/organizations/{organizationId}/groups',
297
+ pathParams: [{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true }],
260
298
  queryParams: [],
261
299
  headerParams: [],
262
300
  response: { kind: 'primitive', type: 'unknown' },
@@ -264,1895 +302,267 @@ describe('generateResources', () => {
264
302
  injectIdempotencyKey: false,
265
303
  },
266
304
  ],
267
- },
268
- ];
305
+ };
306
+ const spec: ApiSpec = { ...emptySpec, services: [groupService] };
307
+ const result = nodeEmitter.generateResources(spec.services, {
308
+ ...ctx,
309
+ spec,
310
+ outputDir: tmpRoot,
311
+ emitterOptions: { ownedServices: ['Groups'] },
312
+ apiSurface: {
313
+ classes: {
314
+ Groups: {
315
+ methods: {
316
+ createGroup: [{ name: 'createGroup', params: [], returnType: 'Promise<void>', async: true }],
317
+ },
318
+ },
319
+ },
320
+ } as any,
321
+ } as EmitterContext);
269
322
 
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(');
323
+ const resourceFile = result.find((f) => f.path === 'src/groups/groups.ts');
324
+ expect(resourceFile).toBeDefined();
325
+ expect(resourceFile!.overwriteExisting).toBe(true);
326
+ expect(resourceFile!.skipIfExists).toBe(false);
327
+ } finally {
328
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
329
+ }
274
330
  });
275
331
 
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',
332
+ it('does not overwrite protected files for explicitly owned services', () => {
333
+ const tmpRoot = createTrackedSdkRoot();
334
+ try {
335
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'groups'), { recursive: true });
336
+ fs.writeFileSync(
337
+ path.join(tmpRoot, 'src', 'groups', 'groups.ts'),
338
+ '// @oagen-ignore-file\nexport class Groups {}\n',
339
+ );
340
+ execFileSync('git', ['add', 'src/groups/groups.ts'], { cwd: tmpRoot, stdio: 'ignore' });
341
+
342
+ const groupService: Service = {
343
+ name: 'Groups',
284
344
  operations: [
285
345
  {
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
- ],
346
+ name: 'createGroup',
347
+ httpMethod: 'post',
348
+ path: '/organizations/{organizationId}/groups',
349
+ pathParams: [{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true }],
296
350
  queryParams: [],
297
351
  headerParams: [],
298
- response: { kind: 'array', items: { kind: 'model', name: 'Secret' } },
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;
308
-
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);');
315
- });
316
-
317
- it('generates POST method with body and idempotency', () => {
318
- const services: Service[] = [
319
- {
320
- name: 'Organizations',
357
+ };
358
+ const spec: ApiSpec = { ...emptySpec, services: [groupService] };
359
+ const result = nodeEmitter.generateResources(spec.services, {
360
+ ...ctx,
361
+ spec,
362
+ outputDir: tmpRoot,
363
+ emitterOptions: { ownedServices: ['Groups'] },
364
+ } as EmitterContext);
365
+
366
+ expect(result.some((f) => f.path === 'src/groups/groups.ts')).toBe(false);
367
+ } finally {
368
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
369
+ }
370
+ });
371
+
372
+ it('treats prior-manifest generated files as managed before they are git-tracked', () => {
373
+ const tmpRoot = createTrackedSdkRoot();
374
+ try {
375
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'connect'), { recursive: true });
376
+ fs.writeFileSync(
377
+ path.join(tmpRoot, 'src', 'connect', 'connect.ts'),
378
+ ['// This file is auto-generated by oagen. Do not edit.', '', 'export class Connect {}'].join('\n'),
379
+ );
380
+
381
+ const spec: ApiSpec = { ...emptySpec, services: [connectService] };
382
+ const result = nodeEmitter.generateResources(spec.services, {
383
+ ...ctx,
384
+ spec,
385
+ outputDir: tmpRoot,
386
+ emitterOptions: { adoptMissingServices: true },
387
+ priorTargetManifestPaths: new Set(['src/connect/connect.ts']),
388
+ } as EmitterContext);
389
+
390
+ const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
391
+ expect(resourceFile).toBeDefined();
392
+ expect(resourceFile!.overwriteExisting).toBe(true);
393
+ } finally {
394
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
395
+ }
396
+ });
397
+
398
+ it('harvests serializer exports from prior-manifest generated files before they are git-tracked', () => {
399
+ const tmpRoot = createTrackedSdkRoot();
400
+ try {
401
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'connect', 'serializers'), { recursive: true });
402
+ fs.writeFileSync(
403
+ path.join(tmpRoot, 'src', 'connect', 'serializers', 'create-m2m-application.serializer.ts'),
404
+ [
405
+ '// This file is auto-generated by oagen. Do not edit.',
406
+ '',
407
+ 'export const serializeCreateM2MApplication = (payload: unknown): unknown => payload;',
408
+ ].join('\n'),
409
+ );
410
+
411
+ const service: Service = {
412
+ name: 'Connect',
321
413
  operations: [
322
414
  {
323
- name: 'createOrganization',
415
+ name: 'createApplication',
324
416
  httpMethod: 'post',
325
- path: '/organizations',
417
+ path: '/connect/applications',
326
418
  pathParams: [],
327
419
  queryParams: [],
328
420
  headerParams: [],
329
- requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
330
- response: { kind: 'model', name: 'Organization' },
421
+ requestBody: { kind: 'model', name: 'CreateM2MApplication' },
422
+ response: { kind: 'primitive', type: 'unknown' },
331
423
  errors: [],
332
- injectIdempotencyKey: true,
424
+ injectIdempotencyKey: false,
333
425
  },
334
426
  ],
335
- },
336
- ];
427
+ };
428
+ const spec: ApiSpec = {
429
+ ...emptySpec,
430
+ services: [service],
431
+ models: [
432
+ {
433
+ name: 'CreateM2MApplication',
434
+ fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
435
+ },
436
+ ],
437
+ };
438
+ const result = nodeEmitter.generateResources(spec.services, {
439
+ ...ctx,
440
+ spec,
441
+ outputDir: tmpRoot,
442
+ emitterOptions: { adoptMissingServices: true },
443
+ priorTargetManifestPaths: new Set([
444
+ 'src/connect/connect.ts',
445
+ 'src/connect/serializers/create-m2m-application.serializer.ts',
446
+ ]),
447
+ } as EmitterContext);
448
+
449
+ const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
450
+ expect(resourceFile?.content).toContain(
451
+ "import { serializeCreateM2MApplication } from './serializers/create-m2m-application.serializer';",
452
+ );
453
+ } finally {
454
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
455
+ }
456
+ });
457
+ });
337
458
 
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,');
459
+ describe('resolveResourceClassName', () => {
460
+ it('uses overlay name when baseline has compatible constructor', () => {
461
+ const service: Service = { name: 'Organizations', operations: [] };
462
+ const ctxWithBaseline: EmitterContext = {
463
+ ...ctx,
464
+ apiSurface: {
465
+ classes: {
466
+ Organizations: {
467
+ constructorParams: [{ name: 'workos', type: 'WorkOS' }],
468
+ },
469
+ },
470
+ } as any,
471
+ };
472
+ expect(resolveResourceClassName(service, ctxWithBaseline)).toBe('Organizations');
345
473
  });
346
474
 
347
- it('uses overlay-resolved name for output path and class', () => {
348
- const mfaService: Service = {
349
- name: 'MultiFactorAuth',
475
+ it('appends Endpoints suffix when IR name collides with overlay name', () => {
476
+ const service: Service = {
477
+ name: 'Webhooks',
350
478
  operations: [
351
479
  {
352
- name: 'enrollFactor',
353
- httpMethod: 'post',
354
- path: '/auth/factors/enroll',
480
+ name: 'listWebhooks',
481
+ httpMethod: 'get',
482
+ path: '/webhooks',
355
483
  pathParams: [],
356
484
  queryParams: [],
357
485
  headerParams: [],
358
- requestBody: { kind: 'model', name: 'EnrollFactorInput' },
359
- response: { kind: 'model', name: 'AuthenticationFactor' },
486
+ response: { kind: 'primitive', type: 'unknown' },
360
487
  errors: [],
361
- injectIdempotencyKey: true,
488
+ injectIdempotencyKey: false,
362
489
  },
363
490
  ],
364
491
  };
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
- },
492
+ const ctxWithIncompat: EmitterContext = {
493
+ ...ctx,
494
+ apiSurface: {
495
+ classes: {
496
+ Webhooks: {
497
+ constructorParams: [{ name: 'crypto', type: 'CryptoProvider' }],
498
+ },
499
+ },
500
+ } as any,
501
+ resolvedOperations: [
502
+ {
503
+ operation: service.operations[0],
504
+ service,
505
+ methodName: 'list_webhooks',
506
+ mountOn: 'Webhooks',
507
+ defaults: {},
508
+ inferFromClient: [],
509
+ urlBuilder: false,
510
+ },
511
+ ],
389
512
  };
513
+ expect(resolveResourceClassName(service, ctxWithIncompat)).toBe('WebhooksEndpoints');
514
+ expect(resolveResourceDir(service, ctxWithIncompat)).toBe('webhooks');
390
515
 
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 {');
516
+ const result = generateResources([service], { ...ctxWithIncompat, spec: { ...emptySpec, services: [service] } });
517
+ expect(result.some((f) => f.path === 'src/webhooks/webhooks-endpoints.ts')).toBe(true);
518
+ expect(result.some((f) => f.path === 'src/webhooks-endpoints/webhooks-endpoints.ts')).toBe(false);
397
519
  });
520
+ });
398
521
 
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(' */');
522
+ describe('hasCompatibleConstructor', () => {
523
+ it('returns true when no baseline exists', () => {
524
+ expect(hasCompatibleConstructor('NewService', ctx)).toBe(true);
440
525
  });
441
526
 
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,
527
+ it('returns true when baseline has workos: WorkOS param', () => {
528
+ const ctxWithBaseline: EmitterContext = {
529
+ ...ctx,
530
+ apiSurface: {
531
+ classes: {
532
+ Organizations: {
533
+ constructorParams: [{ name: 'workos', type: 'WorkOS' }],
463
534
  },
464
- ],
465
- },
466
- ];
467
-
468
- const files = generateResources(services, ctx);
469
- const content = files[0].content;
470
- expect(content).toContain('@returns {Promise<Organization>}');
535
+ },
536
+ } as any,
537
+ };
538
+ expect(hasCompatibleConstructor('Organizations', ctxWithBaseline)).toBe(true);
471
539
  });
472
540
 
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,
541
+ it('returns false when baseline has incompatible constructor', () => {
542
+ const ctxWithIncompat: EmitterContext = {
543
+ ...ctx,
544
+ apiSurface: {
545
+ classes: {
546
+ Webhooks: {
547
+ constructorParams: [{ name: 'crypto', type: 'CryptoProvider' }],
489
548
  },
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
- },
549
+ },
550
+ } as any,
517
551
  };
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>}');
525
- });
526
-
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,
555
- },
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);
552
+ expect(hasCompatibleConstructor('Webhooks', ctxWithIncompat)).toBe(false);
1730
553
  });
1731
554
 
1732
555
  it('returns true when baseline has no constructor params', () => {
1733
- const ctxWithSurface: EmitterContext = {
556
+ const ctxWithEmptyCtor: EmitterContext = {
1734
557
  ...ctx,
1735
558
  apiSurface: {
1736
- language: 'node',
1737
- extractedFrom: 'test',
1738
- extractedAt: '2024-01-01',
1739
559
  classes: {
1740
- EmptyService: {
1741
- name: 'EmptyService',
1742
- methods: {},
1743
- properties: {},
560
+ Utils: {
1744
561
  constructorParams: [],
1745
562
  },
1746
563
  },
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
- },
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
- },
564
+ } as any,
2148
565
  };
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');
566
+ expect(hasCompatibleConstructor('Utils', ctxWithEmptyCtor)).toBe(true);
2157
567
  });
2158
568
  });