@workos/oagen-emitters 0.0.1 → 0.2.1

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 (49) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.oxfmtrc.json +8 -1
  6. package/.prettierignore +1 -0
  7. package/.release-please-manifest.json +3 -0
  8. package/.vscode/settings.json +3 -0
  9. package/CHANGELOG.md +61 -0
  10. package/README.md +2 -2
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.d.mts.map +1 -0
  13. package/dist/index.mjs +4070 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +14 -18
  16. package/release-please-config.json +11 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +21 -4
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-ruby.ts +17 -3
  23. package/smoke/sdk-rust.ts +16 -3
  24. package/src/node/client.ts +521 -206
  25. package/src/node/common.ts +74 -4
  26. package/src/node/config.ts +1 -0
  27. package/src/node/enums.ts +53 -9
  28. package/src/node/errors.ts +82 -3
  29. package/src/node/fixtures.ts +87 -16
  30. package/src/node/index.ts +66 -10
  31. package/src/node/manifest.ts +4 -2
  32. package/src/node/models.ts +251 -124
  33. package/src/node/naming.ts +107 -3
  34. package/src/node/resources.ts +1162 -108
  35. package/src/node/serializers.ts +512 -52
  36. package/src/node/tests.ts +650 -110
  37. package/src/node/type-map.ts +89 -11
  38. package/src/node/utils.ts +426 -113
  39. package/test/node/client.test.ts +1083 -20
  40. package/test/node/enums.test.ts +73 -4
  41. package/test/node/errors.test.ts +4 -21
  42. package/test/node/models.test.ts +499 -5
  43. package/test/node/naming.test.ts +14 -7
  44. package/test/node/resources.test.ts +1568 -9
  45. package/test/node/serializers.test.ts +241 -5
  46. package/tsconfig.json +2 -3
  47. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  48. package/dist/index.d.ts +0 -5
  49. package/dist/index.js +0 -2158
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateClient } from '../../src/node/client.js';
3
- import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
3
+ import { isServiceCoveredByExisting } from '../../src/node/utils.js';
4
+ import type { EmitterContext, ApiSpec, Service, Model, Enum } from '@workos/oagen';
5
+ import type { ApiSurface } from '@workos/oagen/compat';
4
6
 
5
7
  const service: Service = {
6
8
  name: 'Organizations',
@@ -9,7 +11,13 @@ const service: Service = {
9
11
  name: 'getOrganization',
10
12
  httpMethod: 'get',
11
13
  path: '/organizations/{id}',
12
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
14
+ pathParams: [
15
+ {
16
+ name: 'id',
17
+ type: { kind: 'primitive', type: 'string' },
18
+ required: true,
19
+ },
20
+ ],
13
21
  queryParams: [],
14
22
  headerParams: [],
15
23
  response: { kind: 'model', name: 'Organization' },
@@ -23,7 +31,11 @@ const model: Model = {
23
31
  name: 'Organization',
24
32
  fields: [
25
33
  { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
26
- { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
34
+ {
35
+ name: 'name',
36
+ type: { kind: 'primitive', type: 'string' },
37
+ required: true,
38
+ },
27
39
  ],
28
40
  };
29
41
 
@@ -40,7 +52,6 @@ const ctx: EmitterContext = {
40
52
  namespace: 'workos',
41
53
  namespacePascal: 'WorkOS',
42
54
  spec,
43
- irVersion: 6,
44
55
  };
45
56
 
46
57
  describe('generateClient', () => {
@@ -50,11 +61,16 @@ describe('generateClient', () => {
50
61
  expect(workosFile).toBeDefined();
51
62
 
52
63
  const content = workosFile!.content;
53
- expect(content).toContain('export class WorkOS {');
64
+ expect(content).toContain('export class WorkOS extends WorkOSBase {');
54
65
  expect(content).toContain('readonly organizations = new Organizations(this);');
55
- expect(content).toContain('async get<Result');
56
- expect(content).toContain('async post<Result');
57
- expect(content).toContain('async delete(');
66
+ expect(content).toContain("import { WorkOSBase } from './common/workos-base';");
67
+ });
68
+
69
+ it('allows workos.ts to participate in integration (no integrateTarget: false)', () => {
70
+ const files = generateClient(spec, ctx);
71
+ const workosFile = files.find((f) => f.path === 'src/workos.ts');
72
+ expect(workosFile).toBeDefined();
73
+ expect(workosFile!.integrateTarget).not.toBe(false);
58
74
  });
59
75
 
60
76
  it('generates barrel exports', () => {
@@ -66,10 +82,22 @@ describe('generateClient', () => {
66
82
  expect(content).toContain("export * from './common/exceptions';");
67
83
  expect(content).toContain("export { AutoPaginatable } from './common/utils/pagination';");
68
84
  expect(content).toContain("export { WorkOS } from './workos';");
69
- expect(content).toContain('export type { Organization, OrganizationResponse }');
85
+ // Service types are now re-exported via the service barrel
86
+ expect(content).toContain("export * from './organizations/interfaces';");
87
+ expect(content).not.toContain('export type { Organization, OrganizationResponse }');
70
88
  expect(content).toContain("export { Organizations } from './organizations/organizations';");
71
89
  });
72
90
 
91
+ it('generates per-service barrel files', () => {
92
+ const files = generateClient(spec, ctx);
93
+ const serviceBarrel = files.find((f) => f.path === 'src/organizations/interfaces/index.ts');
94
+ expect(serviceBarrel).toBeDefined();
95
+
96
+ const content = serviceBarrel!.content;
97
+ expect(content).toContain("export * from './organization.interface';");
98
+ expect(serviceBarrel!.skipIfExists).toBe(true);
99
+ });
100
+
73
101
  it('generates package.json and tsconfig.json', () => {
74
102
  const files = generateClient(spec, ctx);
75
103
  const pkg = files.find((f) => f.path === 'package.json');
@@ -84,7 +112,7 @@ describe('generateClient', () => {
84
112
 
85
113
  it('uses overlay-resolved names for imports and accessors', () => {
86
114
  const mfaService: Service = {
87
- name: 'MultiFactorAuth',
115
+ name: 'Billing',
88
116
  operations: [
89
117
  {
90
118
  name: 'enrollFactor',
@@ -102,7 +130,13 @@ describe('generateClient', () => {
102
130
 
103
131
  const mfaModel: Model = {
104
132
  name: 'AuthenticationFactor',
105
- fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
133
+ fields: [
134
+ {
135
+ name: 'id',
136
+ type: { kind: 'primitive', type: 'string' },
137
+ required: true,
138
+ },
139
+ ],
106
140
  };
107
141
 
108
142
  const overlaySpec: ApiSpec = {
@@ -118,12 +152,16 @@ describe('generateClient', () => {
118
152
  namespace: 'workos',
119
153
  namespacePascal: 'WorkOS',
120
154
  spec: overlaySpec,
121
- irVersion: 6,
122
155
  overlayLookup: {
123
156
  methodByOperation: new Map([
124
157
  [
125
158
  'POST /auth/factors/enroll',
126
- { className: 'Mfa', methodName: 'enrollFactor', params: [], returnType: 'void' },
159
+ {
160
+ className: 'Mfa',
161
+ methodName: 'enrollFactor',
162
+ params: [],
163
+ returnType: 'void',
164
+ },
127
165
  ],
128
166
  ]),
129
167
  httpKeyByMethod: new Map(),
@@ -147,19 +185,1044 @@ describe('generateClient', () => {
147
185
 
148
186
  const barrel = files.find((f) => f.path === 'src/index.ts');
149
187
  expect(barrel).toBeDefined();
150
- // Barrel export uses resolved name
188
+ // Barrel export uses resolved name for resource class
151
189
  expect(barrel!.content).toContain("from './mfa/mfa'");
190
+ // Service barrel uses resolved directory name
191
+ expect(barrel!.content).toContain("export * from './mfa/interfaces'");
192
+
193
+ // Per-service barrel is generated with resolved directory
194
+ const serviceBarrel = files.find((f) => f.path === 'src/mfa/interfaces/index.ts');
195
+ expect(serviceBarrel).toBeDefined();
196
+ expect(serviceBarrel!.content).toContain("export * from './authentication-factor.interface';");
197
+ });
198
+
199
+ it('does not generate error handling in WorkOS client (lives in WorkOSBase)', () => {
200
+ const files = generateClient(spec, ctx);
201
+ const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
202
+ const content = workosFile.content;
203
+
204
+ expect(content).not.toContain('handleHttpError');
205
+ expect(content).not.toContain('UnauthorizedException');
206
+ expect(content).not.toContain('NotFoundException');
207
+ });
208
+
209
+ it('skips explicit model export when name is already in apiSurface.exports', () => {
210
+ // Simulates the Event shadowing bug: the existing SDK already exports "Event"
211
+ // via a wildcard re-export (e.g., a hand-written 60+ member discriminated union).
212
+ // The barrel must not emit an explicit `export type { Event }` that would shadow it.
213
+ const eventService: Service = {
214
+ name: 'Events',
215
+ operations: [
216
+ {
217
+ name: 'listEvents',
218
+ httpMethod: 'get',
219
+ path: '/events',
220
+ pathParams: [],
221
+ queryParams: [],
222
+ headerParams: [],
223
+ response: { kind: 'model', name: 'Event' },
224
+ errors: [],
225
+ injectIdempotencyKey: false,
226
+ },
227
+ ],
228
+ };
229
+
230
+ const eventModel: Model = {
231
+ name: 'Event',
232
+ fields: [
233
+ {
234
+ name: 'id',
235
+ type: { kind: 'primitive', type: 'string' },
236
+ required: true,
237
+ },
238
+ {
239
+ name: 'event',
240
+ type: { kind: 'primitive', type: 'string' },
241
+ required: true,
242
+ },
243
+ ],
244
+ };
245
+
246
+ const otherModel: Model = {
247
+ name: 'EventCursor',
248
+ fields: [
249
+ {
250
+ name: 'cursor',
251
+ type: { kind: 'primitive', type: 'string' },
252
+ required: true,
253
+ },
254
+ ],
255
+ };
256
+
257
+ const eventSpec: ApiSpec = {
258
+ name: 'Test',
259
+ version: '1.0.0',
260
+ baseUrl: 'https://api.example.com',
261
+ services: [eventService],
262
+ models: [eventModel, otherModel],
263
+ enums: [],
264
+ };
265
+
266
+ const surface: ApiSurface = {
267
+ language: 'node',
268
+ extractedFrom: '/tmp/test-sdk',
269
+ extractedAt: '2025-01-01T00:00:00Z',
270
+ classes: {},
271
+ interfaces: {
272
+ Event: {
273
+ name: 'Event',
274
+ sourceFile: 'src/common/interfaces/event.interface.ts',
275
+ fields: {},
276
+ extends: [],
277
+ },
278
+ },
279
+ typeAliases: {},
280
+ enums: {},
281
+ // The existing SDK's barrel re-exports "Event" via a wildcard chain
282
+ exports: {
283
+ 'src/common/interfaces/event.interface.ts': ['Event'],
284
+ 'src/index.ts': ['Event', 'WorkOS', 'Events'],
285
+ },
286
+ };
287
+
288
+ const eventCtx: EmitterContext = {
289
+ namespace: 'workos',
290
+ namespacePascal: 'WorkOS',
291
+ spec: eventSpec,
292
+ apiSurface: surface,
293
+ };
294
+
295
+ const files = generateClient(eventSpec, eventCtx);
296
+ const barrel = files.find((f) => f.path === 'src/index.ts')!;
297
+ const content = barrel.content;
298
+
299
+ // Event must NOT appear as an explicit named export — it would shadow the wildcard
300
+ expect(content).not.toContain('export type { Event,');
301
+ expect(content).not.toContain('export type { Event }');
302
+
303
+ // EventCursor is NOT in apiSurface.exports, so it should still be exported
304
+ // (via common barrel wildcard since it's unassigned to any service)
305
+ expect(content).toContain("export * from './common/interfaces'");
306
+
307
+ // The resource class export should still be present
308
+ expect(content).toContain("export { Events } from './events/events'");
152
309
  });
153
310
 
154
- it('generates error handling in WorkOS client', () => {
311
+ it('skips explicit enum export when name is already in apiSurface.exports', () => {
312
+ const enumSpec: ApiSpec = {
313
+ name: 'Test',
314
+ version: '1.0.0',
315
+ baseUrl: 'https://api.example.com',
316
+ services: [service],
317
+ models: [model],
318
+ enums: [
319
+ {
320
+ name: 'EventType',
321
+ values: [
322
+ { name: 'CONNECTION_ACTIVATED', value: 'connection.activated' },
323
+ { name: 'CONNECTION_DELETED', value: 'connection.deleted' },
324
+ ],
325
+ },
326
+ ],
327
+ };
328
+
329
+ const surface: ApiSurface = {
330
+ language: 'node',
331
+ extractedFrom: '/tmp/test-sdk',
332
+ extractedAt: '2025-01-01T00:00:00Z',
333
+ classes: {},
334
+ interfaces: {},
335
+ typeAliases: {},
336
+ enums: {},
337
+ exports: {
338
+ 'src/common/interfaces/event-type.interface.ts': ['EventType'],
339
+ },
340
+ };
341
+
342
+ const enumCtx: EmitterContext = {
343
+ namespace: 'workos',
344
+ namespacePascal: 'WorkOS',
345
+ spec: enumSpec,
346
+ apiSurface: surface,
347
+ };
348
+
349
+ const files = generateClient(enumSpec, enumCtx);
350
+ const barrel = files.find((f) => f.path === 'src/index.ts')!;
351
+
352
+ // EventType should NOT appear as an explicit export — already covered by wildcard
353
+ expect(barrel.content).not.toContain('export type { EventType }');
354
+ });
355
+
356
+ it('emits model exports normally when no apiSurface is present', () => {
357
+ // Without apiSurface, all models should be exported via service barrel
155
358
  const files = generateClient(spec, ctx);
359
+ const barrel = files.find((f) => f.path === 'src/index.ts')!;
360
+ expect(barrel.content).toContain("export * from './organizations/interfaces'");
361
+ });
362
+
363
+ it('renders spec.description as JSDoc on WorkOS class', () => {
364
+ const specWithDesc: ApiSpec = {
365
+ ...spec,
366
+ description: 'The WorkOS API provides a unified interface for enterprise features.',
367
+ };
368
+
369
+ const descCtx: EmitterContext = {
370
+ namespace: 'workos',
371
+ namespacePascal: 'WorkOS',
372
+ spec: specWithDesc,
373
+ };
374
+
375
+ const files = generateClient(specWithDesc, descCtx);
156
376
  const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
157
377
  const content = workosFile.content;
158
378
 
159
- expect(content).toContain('case 401: throw new UnauthorizedException');
160
- expect(content).toContain('case 404: throw new NotFoundException');
161
- expect(content).toContain('case 422: throw new UnprocessableEntityException');
162
- expect(content).toContain('case 429:');
163
- expect(content).toContain('throw new RateLimitExceededException');
379
+ expect(content).toContain('/** The WorkOS API provides a unified interface for enterprise features. */');
380
+ expect(content).toContain('export class WorkOS extends WorkOSBase {');
381
+ });
382
+
383
+ it('uses value export for baseline TS enums and type export for type aliases', () => {
384
+ const enumDef: Enum = {
385
+ name: 'ConnectionType',
386
+ values: [
387
+ { name: 'ADFSSAML', value: 'ADFSSAML' },
388
+ { name: 'GoogleOAuth', value: 'GoogleOAuth' },
389
+ ],
390
+ };
391
+ const aliasEnumDef: Enum = {
392
+ name: 'DirectoryState',
393
+ values: [
394
+ { name: 'active', value: 'active' },
395
+ { name: 'inactive', value: 'inactive' },
396
+ ],
397
+ };
398
+ const enumService: Service = {
399
+ name: 'Payments',
400
+ operations: [
401
+ {
402
+ name: 'listPayments',
403
+ httpMethod: 'get',
404
+ path: '/payments',
405
+ pathParams: [],
406
+ queryParams: [
407
+ {
408
+ name: 'type',
409
+ type: {
410
+ kind: 'enum',
411
+ name: 'ConnectionType',
412
+ values: ['ADFSSAML', 'GoogleOAuth'],
413
+ },
414
+ required: false,
415
+ },
416
+ ],
417
+ headerParams: [],
418
+ response: { kind: 'model', name: 'Organization' },
419
+ errors: [],
420
+ injectIdempotencyKey: false,
421
+ },
422
+ ],
423
+ };
424
+ const dirService: Service = {
425
+ name: 'Invoices',
426
+ operations: [
427
+ {
428
+ name: 'listInvoices',
429
+ httpMethod: 'get',
430
+ path: '/invoices',
431
+ pathParams: [],
432
+ queryParams: [
433
+ {
434
+ name: 'state',
435
+ type: {
436
+ kind: 'enum',
437
+ name: 'DirectoryState',
438
+ values: ['active', 'inactive'],
439
+ },
440
+ required: false,
441
+ },
442
+ ],
443
+ headerParams: [],
444
+ response: { kind: 'model', name: 'Organization' },
445
+ errors: [],
446
+ injectIdempotencyKey: false,
447
+ },
448
+ ],
449
+ };
450
+ const enumSpec: ApiSpec = {
451
+ name: 'Test',
452
+ version: '1.0.0',
453
+ baseUrl: 'https://api.example.com',
454
+ services: [service, enumService, dirService],
455
+ models: [model],
456
+ enums: [enumDef, aliasEnumDef],
457
+ };
458
+ const enumCtx: EmitterContext = {
459
+ namespace: 'workos',
460
+ namespacePascal: 'WorkOS',
461
+ spec: enumSpec,
462
+ apiSurface: {
463
+ language: 'node',
464
+ extractedFrom: 'test',
465
+ extractedAt: '2024-01-01',
466
+ interfaces: {},
467
+ classes: {},
468
+ enums: {
469
+ ConnectionType: {
470
+ name: 'ConnectionType',
471
+ members: { ADFSSAML: 'ADFSSAML', GoogleOAuth: 'GoogleOAuth' },
472
+ },
473
+ },
474
+ typeAliases: {},
475
+ exports: {},
476
+ },
477
+ };
478
+
479
+ const files = generateClient(enumSpec, enumCtx);
480
+ const barrel = files.find((f) => f.path === 'src/index.ts');
481
+ expect(barrel).toBeDefined();
482
+
483
+ const content = barrel!.content;
484
+ // Both enums are now re-exported via per-service barrel wildcards
485
+ expect(content).toContain("export * from './payments/interfaces'");
486
+ expect(content).toContain("export * from './invoices/interfaces'");
487
+ // Individual enum exports should NOT appear (covered by wildcard)
488
+ expect(content).not.toContain('export { ConnectionType }');
489
+ expect(content).not.toContain('export type { InvoiceState }');
490
+ });
491
+
492
+ it('skips services whose endpoints are fully covered by existing hand-written services', () => {
493
+ const connectionsService: Service = {
494
+ name: 'Payments',
495
+ operations: [
496
+ {
497
+ name: 'listPayments',
498
+ httpMethod: 'get',
499
+ path: '/payments',
500
+ pathParams: [],
501
+ queryParams: [],
502
+ headerParams: [],
503
+ response: { kind: 'model', name: 'ConnectionList' },
504
+ errors: [],
505
+ injectIdempotencyKey: false,
506
+ },
507
+ {
508
+ name: 'getConnection',
509
+ httpMethod: 'get',
510
+ path: '/payments/{id}',
511
+ pathParams: [
512
+ {
513
+ name: 'id',
514
+ type: { kind: 'primitive', type: 'string' },
515
+ required: true,
516
+ },
517
+ ],
518
+ queryParams: [],
519
+ headerParams: [],
520
+ response: { kind: 'model', name: 'Connection' },
521
+ errors: [],
522
+ injectIdempotencyKey: false,
523
+ },
524
+ ],
525
+ };
526
+
527
+ const connectionModel: Model = {
528
+ name: 'Connection',
529
+ fields: [
530
+ {
531
+ name: 'id',
532
+ type: { kind: 'primitive', type: 'string' },
533
+ required: true,
534
+ },
535
+ {
536
+ name: 'name',
537
+ type: { kind: 'primitive', type: 'string' },
538
+ required: true,
539
+ },
540
+ ],
541
+ };
542
+
543
+ const radarService: Service = {
544
+ name: 'Radar',
545
+ operations: [
546
+ {
547
+ name: 'assess',
548
+ httpMethod: 'post',
549
+ path: '/radar/assess',
550
+ pathParams: [],
551
+ queryParams: [],
552
+ headerParams: [],
553
+ response: { kind: 'model', name: 'RadarResult' },
554
+ errors: [],
555
+ injectIdempotencyKey: false,
556
+ },
557
+ ],
558
+ };
559
+
560
+ const radarModel: Model = {
561
+ name: 'RadarResult',
562
+ fields: [
563
+ {
564
+ name: 'score',
565
+ type: { kind: 'primitive', type: 'number' },
566
+ required: true,
567
+ },
568
+ ],
569
+ };
570
+
571
+ const coveredSpec: ApiSpec = {
572
+ name: 'Test',
573
+ version: '1.0.0',
574
+ baseUrl: 'https://api.example.com',
575
+ services: [connectionsService, radarService],
576
+ models: [connectionModel, radarModel],
577
+ enums: [],
578
+ };
579
+
580
+ const coveredCtx: EmitterContext = {
581
+ namespace: 'workos',
582
+ namespacePascal: 'WorkOS',
583
+ spec: coveredSpec,
584
+ apiSurface: {
585
+ language: 'node',
586
+ extractedFrom: 'test',
587
+ extractedAt: '2024-01-01',
588
+ interfaces: {},
589
+ classes: {
590
+ Sso: {
591
+ name: 'Sso',
592
+ methods: {
593
+ listConnections: [
594
+ {
595
+ name: 'listConnections',
596
+ params: [],
597
+ returnType: 'Promise<AutoPaginatable<Connection>>',
598
+ async: true,
599
+ },
600
+ ],
601
+ getConnection: [
602
+ {
603
+ name: 'getConnection',
604
+ params: [{ name: 'id', type: 'string', optional: false }],
605
+ returnType: 'Promise<Connection>',
606
+ async: true,
607
+ },
608
+ ],
609
+ },
610
+ properties: {},
611
+ constructorParams: [],
612
+ },
613
+ },
614
+ enums: {},
615
+ typeAliases: {},
616
+ exports: {},
617
+ },
618
+ overlayLookup: {
619
+ methodByOperation: new Map([
620
+ [
621
+ 'GET /connections',
622
+ {
623
+ className: 'Sso',
624
+ methodName: 'listConnections',
625
+ params: [],
626
+ returnType: 'Promise<AutoPaginatable<Connection>>',
627
+ },
628
+ ],
629
+ [
630
+ 'GET /connections/{id}',
631
+ {
632
+ className: 'Sso',
633
+ methodName: 'getConnection',
634
+ params: [{ name: 'id', type: 'string', optional: false }],
635
+ returnType: 'Promise<Connection>',
636
+ },
637
+ ],
638
+ ]),
639
+ httpKeyByMethod: new Map(),
640
+ interfaceByName: new Map(),
641
+ typeAliasByName: new Map(),
642
+ requiredExports: new Map(),
643
+ modelNameByIR: new Map(),
644
+ fileBySymbol: new Map(),
645
+ },
646
+ };
647
+
648
+ const files = generateClient(coveredSpec, coveredCtx);
649
+ const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
650
+ const content = workosFile.content;
651
+
652
+ // Connections service should NOT appear (fully covered by Sso in baseline)
653
+ expect(content).not.toContain('Connections');
654
+ expect(content).not.toContain("from './sso/sso'");
655
+
656
+ // Radar service should still appear (not covered)
657
+ expect(content).toContain('readonly radar = new Radar(this);');
658
+ expect(content).toContain("import { Radar } from './radar/radar';");
659
+
660
+ // Barrel should also skip the Connections resource class export
661
+ const barrel = files.find((f) => f.path === 'src/index.ts')!;
662
+ const barrelContent = barrel.content;
663
+ expect(barrelContent).not.toContain('export { Sso }');
664
+ expect(barrelContent).not.toContain('export { Connections }');
665
+
666
+ // Covered services don't generate barrel exports — their types are
667
+ // already exported by the hand-written service's own barrel.
668
+ expect(barrelContent).not.toContain("export * from './sso/interfaces'");
669
+ });
670
+
671
+ it('does not skip services when only some operations are covered', () => {
672
+ const partialService: Service = {
673
+ name: 'Invoices',
674
+ operations: [
675
+ {
676
+ name: 'listInvoices',
677
+ httpMethod: 'get',
678
+ path: '/invoices',
679
+ pathParams: [],
680
+ queryParams: [],
681
+ headerParams: [],
682
+ response: { kind: 'model', name: 'DirectoryList' },
683
+ errors: [],
684
+ injectIdempotencyKey: false,
685
+ },
686
+ {
687
+ name: 'createInvoice',
688
+ httpMethod: 'post',
689
+ path: '/invoices',
690
+ pathParams: [],
691
+ queryParams: [],
692
+ headerParams: [],
693
+ response: { kind: 'model', name: 'Invoice' },
694
+ errors: [],
695
+ injectIdempotencyKey: false,
696
+ },
697
+ ],
698
+ };
699
+
700
+ const dirModel: Model = {
701
+ name: 'Invoice',
702
+ fields: [
703
+ {
704
+ name: 'id',
705
+ type: { kind: 'primitive', type: 'string' },
706
+ required: true,
707
+ },
708
+ ],
709
+ };
710
+
711
+ const partialSpec: ApiSpec = {
712
+ name: 'Test',
713
+ version: '1.0.0',
714
+ baseUrl: 'https://api.example.com',
715
+ services: [partialService],
716
+ models: [dirModel],
717
+ enums: [],
718
+ };
719
+
720
+ const partialCtx: EmitterContext = {
721
+ namespace: 'workos',
722
+ namespacePascal: 'WorkOS',
723
+ spec: partialSpec,
724
+ apiSurface: {
725
+ language: 'node',
726
+ extractedFrom: 'test',
727
+ extractedAt: '2024-01-01',
728
+ interfaces: {},
729
+ classes: {
730
+ Billing: {
731
+ name: 'Billing',
732
+ methods: {
733
+ listInvoices: [
734
+ {
735
+ name: 'listInvoices',
736
+ params: [],
737
+ returnType: 'Promise<AutoPaginatable<Invoice>>',
738
+ async: true,
739
+ },
740
+ ],
741
+ },
742
+ properties: {},
743
+ constructorParams: [],
744
+ },
745
+ },
746
+ enums: {},
747
+ typeAliases: {},
748
+ exports: {},
749
+ },
750
+ overlayLookup: {
751
+ methodByOperation: new Map([
752
+ [
753
+ 'GET /invoices',
754
+ {
755
+ className: 'Billing',
756
+ methodName: 'listInvoices',
757
+ params: [],
758
+ returnType: 'Promise<AutoPaginatable<Invoice>>',
759
+ },
760
+ ],
761
+ ]),
762
+ httpKeyByMethod: new Map(),
763
+ interfaceByName: new Map(),
764
+ typeAliasByName: new Map(),
765
+ requiredExports: new Map(),
766
+ modelNameByIR: new Map(),
767
+ fileBySymbol: new Map(),
768
+ },
769
+ };
770
+
771
+ const files = generateClient(partialSpec, partialCtx);
772
+ const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
773
+ const content = workosFile.content;
774
+
775
+ // Service should still be generated because it has an uncovered operation
776
+ expect(content).toContain('Billing');
777
+ });
778
+
779
+ it('does not skip services when no overlay is provided', () => {
780
+ const files = generateClient(spec, ctx);
781
+ const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
782
+ expect(workosFile.content).toContain('readonly organizations = new Organizations(this);');
783
+ });
784
+
785
+ it('does not skip services when overlay exists but no apiSurface baseline', () => {
786
+ const mfaService: Service = {
787
+ name: 'Analytics',
788
+ operations: [
789
+ {
790
+ name: 'enrollFactor',
791
+ httpMethod: 'post',
792
+ path: '/auth/factors/enroll',
793
+ pathParams: [],
794
+ queryParams: [],
795
+ headerParams: [],
796
+ response: { kind: 'model', name: 'AuthenticationFactor' },
797
+ errors: [],
798
+ injectIdempotencyKey: true,
799
+ },
800
+ ],
801
+ };
802
+
803
+ const mfaModel: Model = {
804
+ name: 'AuthenticationFactor',
805
+ fields: [
806
+ {
807
+ name: 'id',
808
+ type: { kind: 'primitive', type: 'string' },
809
+ required: true,
810
+ },
811
+ ],
812
+ };
813
+
814
+ const mfaSpec: ApiSpec = {
815
+ name: 'Test',
816
+ version: '1.0.0',
817
+ baseUrl: 'https://api.example.com',
818
+ services: [mfaService],
819
+ models: [mfaModel],
820
+ enums: [],
821
+ };
822
+
823
+ const namingOnlyCtx: EmitterContext = {
824
+ namespace: 'workos',
825
+ namespacePascal: 'WorkOS',
826
+ spec: mfaSpec,
827
+ overlayLookup: {
828
+ methodByOperation: new Map([
829
+ [
830
+ 'POST /auth/factors/enroll',
831
+ {
832
+ className: 'Analytics',
833
+ methodName: 'enrollFactor',
834
+ params: [],
835
+ returnType: 'void',
836
+ },
837
+ ],
838
+ ]),
839
+ httpKeyByMethod: new Map(),
840
+ interfaceByName: new Map(),
841
+ typeAliasByName: new Map(),
842
+ requiredExports: new Map(),
843
+ modelNameByIR: new Map(),
844
+ fileBySymbol: new Map(),
845
+ },
846
+ };
847
+
848
+ const files = generateClient(mfaSpec, namingOnlyCtx);
849
+ const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
850
+ expect(workosFile.content).toContain('readonly analytics = new Analytics(this);');
851
+ });
852
+ });
853
+
854
+ describe('isServiceCoveredByExisting', () => {
855
+ const emptySpec: ApiSpec = {
856
+ name: 'Test',
857
+ version: '1.0.0',
858
+ baseUrl: '',
859
+ services: [],
860
+ models: [],
861
+ enums: [],
862
+ };
863
+
864
+ it('returns false when no overlay is provided', () => {
865
+ const svc: Service = {
866
+ name: 'Payments',
867
+ operations: [
868
+ {
869
+ name: 'listPayments',
870
+ httpMethod: 'get',
871
+ path: '/payments',
872
+ pathParams: [],
873
+ queryParams: [],
874
+ headerParams: [],
875
+ response: { kind: 'model', name: 'ConnectionList' },
876
+ errors: [],
877
+ injectIdempotencyKey: false,
878
+ },
879
+ ],
880
+ };
881
+ const noOverlayCtx: EmitterContext = {
882
+ namespace: 'workos',
883
+ namespacePascal: 'WorkOS',
884
+ spec: emptySpec,
885
+ };
886
+ expect(isServiceCoveredByExisting(svc, noOverlayCtx)).toBe(false);
887
+ });
888
+
889
+ it('returns false when overlay is empty', () => {
890
+ const svc: Service = {
891
+ name: 'Payments',
892
+ operations: [
893
+ {
894
+ name: 'listPayments',
895
+ httpMethod: 'get',
896
+ path: '/payments',
897
+ pathParams: [],
898
+ queryParams: [],
899
+ headerParams: [],
900
+ response: { kind: 'model', name: 'ConnectionList' },
901
+ errors: [],
902
+ injectIdempotencyKey: false,
903
+ },
904
+ ],
905
+ };
906
+ const emptyOverlayCtx: EmitterContext = {
907
+ namespace: 'workos',
908
+ namespacePascal: 'WorkOS',
909
+ spec: emptySpec,
910
+ overlayLookup: {
911
+ methodByOperation: new Map(),
912
+ httpKeyByMethod: new Map(),
913
+ interfaceByName: new Map(),
914
+ typeAliasByName: new Map(),
915
+ requiredExports: new Map(),
916
+ modelNameByIR: new Map(),
917
+ fileBySymbol: new Map(),
918
+ },
919
+ };
920
+ expect(isServiceCoveredByExisting(svc, emptyOverlayCtx)).toBe(false);
921
+ });
922
+
923
+ it('returns true when all operations are covered by overlay and class exists in baseline', () => {
924
+ const svc: Service = {
925
+ name: 'Connections',
926
+ operations: [
927
+ {
928
+ name: 'listConnections',
929
+ httpMethod: 'get',
930
+ path: '/connections',
931
+ pathParams: [],
932
+ queryParams: [],
933
+ headerParams: [],
934
+ response: { kind: 'model', name: 'ConnectionList' },
935
+ errors: [],
936
+ injectIdempotencyKey: false,
937
+ },
938
+ {
939
+ name: 'getConnection',
940
+ httpMethod: 'get',
941
+ path: '/connections/{id}',
942
+ pathParams: [
943
+ {
944
+ name: 'id',
945
+ type: { kind: 'primitive', type: 'string' },
946
+ required: true,
947
+ },
948
+ ],
949
+ queryParams: [],
950
+ headerParams: [],
951
+ response: { kind: 'model', name: 'Connection' },
952
+ errors: [],
953
+ injectIdempotencyKey: false,
954
+ },
955
+ ],
956
+ };
957
+ const fullCoverageCtx: EmitterContext = {
958
+ namespace: 'workos',
959
+ namespacePascal: 'WorkOS',
960
+ spec: emptySpec,
961
+ apiSurface: {
962
+ language: 'node',
963
+ extractedFrom: 'test',
964
+ extractedAt: '2024-01-01',
965
+ interfaces: {},
966
+ classes: {
967
+ Sso: {
968
+ name: 'Sso',
969
+ methods: {},
970
+ properties: {},
971
+ constructorParams: [],
972
+ },
973
+ },
974
+ enums: {},
975
+ typeAliases: {},
976
+ exports: {},
977
+ },
978
+ overlayLookup: {
979
+ methodByOperation: new Map([
980
+ [
981
+ 'GET /connections',
982
+ {
983
+ className: 'Sso',
984
+ methodName: 'listConnections',
985
+ params: [],
986
+ returnType: 'Promise<AutoPaginatable<Connection>>',
987
+ },
988
+ ],
989
+ [
990
+ 'GET /connections/{id}',
991
+ {
992
+ className: 'Sso',
993
+ methodName: 'getConnection',
994
+ params: [{ name: 'id', type: 'string', optional: false }],
995
+ returnType: 'Promise<Connection>',
996
+ },
997
+ ],
998
+ ]),
999
+ httpKeyByMethod: new Map(),
1000
+ interfaceByName: new Map(),
1001
+ typeAliasByName: new Map(),
1002
+ requiredExports: new Map(),
1003
+ modelNameByIR: new Map(),
1004
+ fileBySymbol: new Map(),
1005
+ },
1006
+ };
1007
+ expect(isServiceCoveredByExisting(svc, fullCoverageCtx)).toBe(true);
1008
+ });
1009
+
1010
+ it('returns false when only some operations are covered', () => {
1011
+ const svc: Service = {
1012
+ name: 'Invoices',
1013
+ operations: [
1014
+ {
1015
+ name: 'listInvoices',
1016
+ httpMethod: 'get',
1017
+ path: '/invoices',
1018
+ pathParams: [],
1019
+ queryParams: [],
1020
+ headerParams: [],
1021
+ response: { kind: 'model', name: 'DirectoryList' },
1022
+ errors: [],
1023
+ injectIdempotencyKey: false,
1024
+ },
1025
+ {
1026
+ name: 'createInvoice',
1027
+ httpMethod: 'post',
1028
+ path: '/invoices',
1029
+ pathParams: [],
1030
+ queryParams: [],
1031
+ headerParams: [],
1032
+ response: { kind: 'model', name: 'Invoice' },
1033
+ errors: [],
1034
+ injectIdempotencyKey: false,
1035
+ },
1036
+ ],
1037
+ };
1038
+ const partialCtx: EmitterContext = {
1039
+ namespace: 'workos',
1040
+ namespacePascal: 'WorkOS',
1041
+ spec: emptySpec,
1042
+ apiSurface: {
1043
+ language: 'node',
1044
+ extractedFrom: 'test',
1045
+ extractedAt: '2024-01-01',
1046
+ interfaces: {},
1047
+ classes: {
1048
+ Billing: {
1049
+ name: 'Billing',
1050
+ methods: {},
1051
+ properties: {},
1052
+ constructorParams: [],
1053
+ },
1054
+ },
1055
+ enums: {},
1056
+ typeAliases: {},
1057
+ exports: {},
1058
+ },
1059
+ overlayLookup: {
1060
+ methodByOperation: new Map([
1061
+ [
1062
+ 'GET /invoices',
1063
+ {
1064
+ className: 'Billing',
1065
+ methodName: 'listInvoices',
1066
+ params: [],
1067
+ returnType: 'Promise<AutoPaginatable<Directory>>',
1068
+ },
1069
+ ],
1070
+ ]),
1071
+ httpKeyByMethod: new Map(),
1072
+ interfaceByName: new Map(),
1073
+ typeAliasByName: new Map(),
1074
+ requiredExports: new Map(),
1075
+ modelNameByIR: new Map(),
1076
+ fileBySymbol: new Map(),
1077
+ },
1078
+ };
1079
+ expect(isServiceCoveredByExisting(svc, partialCtx)).toBe(false);
1080
+ });
1081
+
1082
+ it('returns false for services with zero operations', () => {
1083
+ const emptySvc: Service = {
1084
+ name: 'Empty',
1085
+ operations: [],
1086
+ };
1087
+ const overlayCtx: EmitterContext = {
1088
+ namespace: 'workos',
1089
+ namespacePascal: 'WorkOS',
1090
+ spec: emptySpec,
1091
+ apiSurface: {
1092
+ language: 'node',
1093
+ extractedFrom: 'test',
1094
+ extractedAt: '2024-01-01',
1095
+ interfaces: {},
1096
+ classes: {
1097
+ Other: {
1098
+ name: 'Other',
1099
+ methods: {},
1100
+ properties: {},
1101
+ constructorParams: [],
1102
+ },
1103
+ },
1104
+ enums: {},
1105
+ typeAliases: {},
1106
+ exports: {},
1107
+ },
1108
+ overlayLookup: {
1109
+ methodByOperation: new Map([
1110
+ [
1111
+ 'GET /something',
1112
+ {
1113
+ className: 'Other',
1114
+ methodName: 'doSomething',
1115
+ params: [],
1116
+ returnType: 'void',
1117
+ },
1118
+ ],
1119
+ ]),
1120
+ httpKeyByMethod: new Map(),
1121
+ interfaceByName: new Map(),
1122
+ typeAliasByName: new Map(),
1123
+ requiredExports: new Map(),
1124
+ modelNameByIR: new Map(),
1125
+ fileBySymbol: new Map(),
1126
+ },
1127
+ };
1128
+ expect(isServiceCoveredByExisting(emptySvc, overlayCtx)).toBe(false);
1129
+ });
1130
+
1131
+ it('returns false when overlay covers operations but target class is not in baseline', () => {
1132
+ const svc: Service = {
1133
+ name: 'Payments',
1134
+ operations: [
1135
+ {
1136
+ name: 'listPayments',
1137
+ httpMethod: 'get',
1138
+ path: '/payments',
1139
+ pathParams: [],
1140
+ queryParams: [],
1141
+ headerParams: [],
1142
+ response: { kind: 'model', name: 'ConnectionList' },
1143
+ errors: [],
1144
+ injectIdempotencyKey: false,
1145
+ },
1146
+ ],
1147
+ };
1148
+ const missingClassCtx: EmitterContext = {
1149
+ namespace: 'workos',
1150
+ namespacePascal: 'WorkOS',
1151
+ spec: emptySpec,
1152
+ apiSurface: {
1153
+ language: 'node',
1154
+ extractedFrom: 'test',
1155
+ extractedAt: '2024-01-01',
1156
+ interfaces: {},
1157
+ classes: {},
1158
+ enums: {},
1159
+ typeAliases: {},
1160
+ exports: {},
1161
+ },
1162
+ overlayLookup: {
1163
+ methodByOperation: new Map([
1164
+ [
1165
+ 'GET /payments',
1166
+ {
1167
+ className: 'Sso',
1168
+ methodName: 'listPayments',
1169
+ params: [],
1170
+ returnType: 'Promise<AutoPaginatable<Connection>>',
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
+ expect(isServiceCoveredByExisting(svc, missingClassCtx)).toBe(false);
1183
+ });
1184
+
1185
+ it('returns false when no apiSurface is provided', () => {
1186
+ const svc: Service = {
1187
+ name: 'Payments',
1188
+ operations: [
1189
+ {
1190
+ name: 'listPayments',
1191
+ httpMethod: 'get',
1192
+ path: '/payments',
1193
+ pathParams: [],
1194
+ queryParams: [],
1195
+ headerParams: [],
1196
+ response: { kind: 'model', name: 'ConnectionList' },
1197
+ errors: [],
1198
+ injectIdempotencyKey: false,
1199
+ },
1200
+ ],
1201
+ };
1202
+ const noSurfaceCtx: EmitterContext = {
1203
+ namespace: 'workos',
1204
+ namespacePascal: 'WorkOS',
1205
+ spec: emptySpec,
1206
+ overlayLookup: {
1207
+ methodByOperation: new Map([
1208
+ [
1209
+ 'GET /payments',
1210
+ {
1211
+ className: 'Sso',
1212
+ methodName: 'listPayments',
1213
+ params: [],
1214
+ returnType: 'Promise<AutoPaginatable<Connection>>',
1215
+ },
1216
+ ],
1217
+ ]),
1218
+ httpKeyByMethod: new Map(),
1219
+ interfaceByName: new Map(),
1220
+ typeAliasByName: new Map(),
1221
+ requiredExports: new Map(),
1222
+ modelNameByIR: new Map(),
1223
+ fileBySymbol: new Map(),
1224
+ },
1225
+ };
1226
+ expect(isServiceCoveredByExisting(svc, noSurfaceCtx)).toBe(false);
164
1227
  });
165
1228
  });