@workos/oagen-emitters 0.0.1 → 0.2.0

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