@workos/oagen-emitters 0.2.0 → 0.3.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 (110) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.oxfmtrc.json +8 -1
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +129 -0
  6. package/dist/index.d.mts +10 -1
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +11943 -2728
  9. package/dist/index.mjs.map +1 -1
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +298 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +137 -46
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-php.ts +28 -26
  23. package/smoke/sdk-python.ts +5 -2
  24. package/smoke/sdk-ruby.ts +17 -3
  25. package/smoke/sdk-rust.ts +16 -3
  26. package/src/go/client.ts +141 -0
  27. package/src/go/enums.ts +196 -0
  28. package/src/go/fixtures.ts +212 -0
  29. package/src/go/index.ts +81 -0
  30. package/src/go/manifest.ts +36 -0
  31. package/src/go/models.ts +254 -0
  32. package/src/go/naming.ts +191 -0
  33. package/src/go/resources.ts +827 -0
  34. package/src/go/tests.ts +751 -0
  35. package/src/go/type-map.ts +82 -0
  36. package/src/go/wrappers.ts +261 -0
  37. package/src/index.ts +3 -0
  38. package/src/node/client.ts +167 -122
  39. package/src/node/enums.ts +13 -4
  40. package/src/node/errors.ts +42 -233
  41. package/src/node/field-plan.ts +726 -0
  42. package/src/node/fixtures.ts +15 -5
  43. package/src/node/index.ts +65 -16
  44. package/src/node/models.ts +264 -96
  45. package/src/node/naming.ts +52 -25
  46. package/src/node/resources.ts +621 -172
  47. package/src/node/sdk-errors.ts +41 -0
  48. package/src/node/tests.ts +71 -27
  49. package/src/node/type-map.ts +4 -2
  50. package/src/node/utils.ts +56 -64
  51. package/src/node/wrappers.ts +151 -0
  52. package/src/php/client.ts +171 -0
  53. package/src/php/enums.ts +67 -0
  54. package/src/php/errors.ts +9 -0
  55. package/src/php/fixtures.ts +181 -0
  56. package/src/php/index.ts +96 -0
  57. package/src/php/manifest.ts +36 -0
  58. package/src/php/models.ts +310 -0
  59. package/src/php/naming.ts +298 -0
  60. package/src/php/resources.ts +561 -0
  61. package/src/php/tests.ts +533 -0
  62. package/src/php/type-map.ts +90 -0
  63. package/src/php/utils.ts +18 -0
  64. package/src/php/wrappers.ts +151 -0
  65. package/src/python/client.ts +337 -0
  66. package/src/python/enums.ts +313 -0
  67. package/src/python/fixtures.ts +196 -0
  68. package/src/python/index.ts +95 -0
  69. package/src/python/manifest.ts +38 -0
  70. package/src/python/models.ts +688 -0
  71. package/src/python/naming.ts +209 -0
  72. package/src/python/resources.ts +1322 -0
  73. package/src/python/tests.ts +1335 -0
  74. package/src/python/type-map.ts +93 -0
  75. package/src/python/wrappers.ts +191 -0
  76. package/src/shared/model-utils.ts +255 -0
  77. package/src/shared/naming-utils.ts +107 -0
  78. package/src/shared/non-spec-services.ts +54 -0
  79. package/src/shared/resolved-ops.ts +109 -0
  80. package/src/shared/wrapper-utils.ts +59 -0
  81. package/test/go/client.test.ts +92 -0
  82. package/test/go/enums.test.ts +132 -0
  83. package/test/go/errors.test.ts +9 -0
  84. package/test/go/models.test.ts +265 -0
  85. package/test/go/resources.test.ts +408 -0
  86. package/test/go/tests.test.ts +143 -0
  87. package/test/node/client.test.ts +199 -94
  88. package/test/node/enums.test.ts +75 -3
  89. package/test/node/errors.test.ts +2 -41
  90. package/test/node/models.test.ts +109 -20
  91. package/test/node/naming.test.ts +37 -4
  92. package/test/node/resources.test.ts +662 -30
  93. package/test/node/serializers.test.ts +36 -7
  94. package/test/node/type-map.test.ts +11 -0
  95. package/test/php/client.test.ts +94 -0
  96. package/test/php/enums.test.ts +173 -0
  97. package/test/php/errors.test.ts +9 -0
  98. package/test/php/models.test.ts +497 -0
  99. package/test/php/resources.test.ts +644 -0
  100. package/test/php/tests.test.ts +118 -0
  101. package/test/python/client.test.ts +200 -0
  102. package/test/python/enums.test.ts +228 -0
  103. package/test/python/errors.test.ts +16 -0
  104. package/test/python/manifest.test.ts +74 -0
  105. package/test/python/models.test.ts +716 -0
  106. package/test/python/resources.test.ts +617 -0
  107. package/test/python/tests.test.ts +202 -0
  108. package/src/node/common.ts +0 -273
  109. package/src/node/config.ts +0 -71
  110. package/src/node/serializers.ts +0 -744
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateResources, resolveResourceClassName, hasCompatibleConstructor } from '../../src/node/resources.js';
3
3
  import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
4
+ import { defaultSdkBehavior } from '@workos/oagen';
4
5
 
5
6
  const emptySpec: ApiSpec = {
6
7
  name: 'Test',
@@ -9,6 +10,7 @@ const emptySpec: ApiSpec = {
9
10
  services: [],
10
11
  models: [],
11
12
  enums: [],
13
+ sdk: defaultSdkBehavior(),
12
14
  };
13
15
 
14
16
  const ctx: EmitterContext = {
@@ -72,7 +74,10 @@ describe('generateResources', () => {
72
74
  queryParams: [
73
75
  {
74
76
  name: 'domains',
75
- type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
77
+ type: {
78
+ kind: 'array',
79
+ items: { kind: 'primitive', type: 'string' },
80
+ },
76
81
  required: false,
77
82
  },
78
83
  ],
@@ -243,7 +248,12 @@ describe('generateResources', () => {
243
248
  methodByOperation: new Map([
244
249
  [
245
250
  'POST /auth/factors/enroll',
246
- { className: 'Mfa', methodName: 'enrollFactor', params: [], returnType: 'void' },
251
+ {
252
+ className: 'Mfa',
253
+ methodName: 'enrollFactor',
254
+ params: [],
255
+ returnType: 'void',
256
+ },
247
257
  ],
248
258
  ]),
249
259
  httpKeyByMethod: new Map(),
@@ -301,7 +311,7 @@ describe('generateResources', () => {
301
311
  expect(content).toContain(' *');
302
312
  expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
303
313
  expect(content).toContain(' * @param id - The unique identifier of the attempt.');
304
- expect(content).toContain(' * @returns {RadarAttempt}');
314
+ expect(content).toContain(' * @returns {Promise<RadarAttempt>}');
305
315
  expect(content).toContain(' * @deprecated');
306
316
  expect(content).toContain(' */');
307
317
  });
@@ -315,7 +325,13 @@ describe('generateResources', () => {
315
325
  name: 'getOrganization',
316
326
  httpMethod: 'get',
317
327
  path: '/organizations/{id}',
318
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
328
+ pathParams: [
329
+ {
330
+ name: 'id',
331
+ type: { kind: 'primitive', type: 'string' },
332
+ required: true,
333
+ },
334
+ ],
319
335
  queryParams: [],
320
336
  headerParams: [],
321
337
  response: { kind: 'model', name: 'Organization' },
@@ -328,7 +344,61 @@ describe('generateResources', () => {
328
344
 
329
345
  const files = generateResources(services, ctx);
330
346
  const content = files[0].content;
331
- expect(content).toContain('@returns {Organization}');
347
+ expect(content).toContain('@returns {Promise<Organization>}');
348
+ });
349
+
350
+ it('renders @returns from overlay return type when available', () => {
351
+ const services: Service[] = [
352
+ {
353
+ name: 'Authorization',
354
+ operations: [
355
+ {
356
+ name: 'createEnvironmentRole',
357
+ httpMethod: 'post',
358
+ path: '/authorization/roles',
359
+ pathParams: [],
360
+ queryParams: [],
361
+ headerParams: [],
362
+ requestBody: { kind: 'model', name: 'CreateRoleInput' },
363
+ response: { kind: 'model', name: 'Role' },
364
+ errors: [],
365
+ injectIdempotencyKey: false,
366
+ },
367
+ ],
368
+ },
369
+ ];
370
+
371
+ const overlayCtx: EmitterContext = {
372
+ namespace: 'workos',
373
+ namespacePascal: 'WorkOS',
374
+ spec: { ...emptySpec, services, models: [] },
375
+ overlayLookup: {
376
+ methodByOperation: new Map([
377
+ [
378
+ 'POST /authorization/roles',
379
+ {
380
+ className: 'Authorization',
381
+ methodName: 'createEnvironmentRole',
382
+ params: [{ name: 'payload', type: 'CreateRoleInput', optional: false }],
383
+ returnType: 'Promise<EnvironmentRole>',
384
+ },
385
+ ],
386
+ ]),
387
+ httpKeyByMethod: new Map(),
388
+ interfaceByName: new Map(),
389
+ typeAliasByName: new Map(),
390
+ requiredExports: new Map(),
391
+ modelNameByIR: new Map(),
392
+ fileBySymbol: new Map(),
393
+ },
394
+ };
395
+
396
+ const files = generateResources(services, overlayCtx);
397
+ const content = files[0].content;
398
+ // JSDoc should use the overlay return type, not the spec schema name
399
+ expect(content).toContain('@returns {Promise<EnvironmentRole>}');
400
+ expect(content).not.toContain('@returns {Role}');
401
+ expect(content).not.toContain('@returns {Promise<Role>}');
332
402
  });
333
403
 
334
404
  it('renders query param docs for non-paginated operations', () => {
@@ -340,7 +410,13 @@ describe('generateResources', () => {
340
410
  name: 'getOrganization',
341
411
  httpMethod: 'get',
342
412
  path: '/organizations/{id}',
343
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
413
+ pathParams: [
414
+ {
415
+ name: 'id',
416
+ type: { kind: 'primitive', type: 'string' },
417
+ required: true,
418
+ },
419
+ ],
344
420
  queryParams: [
345
421
  {
346
422
  name: 'include_fields',
@@ -372,7 +448,13 @@ describe('generateResources', () => {
372
448
  name: 'getSession',
373
449
  httpMethod: 'get',
374
450
  path: '/sessions/{id}',
375
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
451
+ pathParams: [
452
+ {
453
+ name: 'id',
454
+ type: { kind: 'primitive', type: 'string' },
455
+ required: true,
456
+ },
457
+ ],
376
458
  queryParams: [],
377
459
  headerParams: [
378
460
  {
@@ -421,8 +503,14 @@ describe('generateResources', () => {
421
503
  requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
422
504
  response: { kind: 'model', name: 'Organization' },
423
505
  successResponses: [
424
- { statusCode: 200, type: { kind: 'model', name: 'Organization' } },
425
- { statusCode: 201, type: { kind: 'model', name: 'Organization' } },
506
+ {
507
+ statusCode: 200,
508
+ type: { kind: 'model', name: 'Organization' },
509
+ },
510
+ {
511
+ statusCode: 201,
512
+ type: { kind: 'model', name: 'Organization' },
513
+ },
426
514
  ],
427
515
  errors: [],
428
516
  injectIdempotencyKey: false,
@@ -434,9 +522,9 @@ describe('generateResources', () => {
434
522
  const files = generateResources(services, ctx);
435
523
  const content = files[0].content;
436
524
  // Only emit a single @returns for the primary response model (no status-code variants)
437
- expect(content).toContain('@returns {Organization}');
438
- expect(content).not.toContain('@returns {Organization} 200');
439
- expect(content).not.toContain('@returns {Organization} 201');
525
+ expect(content).toContain('@returns {Promise<Organization>}');
526
+ expect(content).not.toContain('@returns {Promise<Organization>} 200');
527
+ expect(content).not.toContain('@returns {Promise<Organization>} 201');
440
528
  });
441
529
 
442
530
  it('generates DELETE-with-body method using deleteWithBody', () => {
@@ -517,7 +605,13 @@ describe('generateResources', () => {
517
605
  name: 'getOrganization',
518
606
  httpMethod: 'get',
519
607
  path: '/organizations/{id}',
520
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
608
+ pathParams: [
609
+ {
610
+ name: 'id',
611
+ type: { kind: 'primitive', type: 'string' },
612
+ required: true,
613
+ },
614
+ ],
521
615
  queryParams: [
522
616
  {
523
617
  name: 'include_fields',
@@ -603,7 +697,7 @@ describe('generateResources', () => {
603
697
  expect(content).toContain('query: options');
604
698
  });
605
699
 
606
- it('generates union type for non-discriminated request body (pass-through)', () => {
700
+ it('falls back to pass-through for non-discriminated union when models not in spec', () => {
607
701
  const services: Service[] = [
608
702
  {
609
703
  name: 'Auth',
@@ -640,7 +734,7 @@ describe('generateResources', () => {
640
734
  // Should NOT use Record<string, unknown>
641
735
  expect(content).not.toContain('Record<string, unknown>');
642
736
 
643
- // Should pass payload directly (no serializer for unions)
737
+ // Models not in spec falls back to pass-through
644
738
  expect(content).toContain("'/user_management/authenticate',");
645
739
  expect(content).toContain('payload,');
646
740
 
@@ -650,6 +744,111 @@ describe('generateResources', () => {
650
744
  expect(content).toContain('AuthByMagicAuth');
651
745
  });
652
746
 
747
+ it('generates field-guard serializer dispatch for non-discriminated union with models', () => {
748
+ const services: Service[] = [
749
+ {
750
+ name: 'Applications',
751
+ operations: [
752
+ {
753
+ name: 'createApplication',
754
+ httpMethod: 'post',
755
+ path: '/connect/applications',
756
+ pathParams: [],
757
+ queryParams: [],
758
+ headerParams: [],
759
+ requestBody: {
760
+ kind: 'union',
761
+ variants: [
762
+ { kind: 'model', name: 'CreateOAuthApplication' },
763
+ { kind: 'model', name: 'CreateM2MApplication' },
764
+ ],
765
+ },
766
+ response: { kind: 'model', name: 'ConnectApplication' },
767
+ errors: [],
768
+ injectIdempotencyKey: false,
769
+ },
770
+ ],
771
+ },
772
+ ];
773
+
774
+ const testCtx: EmitterContext = {
775
+ namespace: 'workos',
776
+ namespacePascal: 'WorkOS',
777
+ spec: {
778
+ ...emptySpec,
779
+ services,
780
+ models: [
781
+ {
782
+ name: 'CreateOAuthApplication',
783
+ fields: [
784
+ {
785
+ name: 'name',
786
+ type: { kind: 'primitive', type: 'string' },
787
+ required: true,
788
+ },
789
+ {
790
+ name: 'redirect_uris',
791
+ type: {
792
+ kind: 'array',
793
+ items: { kind: 'primitive', type: 'string' },
794
+ },
795
+ required: true,
796
+ },
797
+ {
798
+ name: 'uses_pkce',
799
+ type: { kind: 'primitive', type: 'boolean' },
800
+ required: false,
801
+ },
802
+ ],
803
+ },
804
+ {
805
+ name: 'CreateM2MApplication',
806
+ fields: [
807
+ {
808
+ name: 'name',
809
+ type: { kind: 'primitive', type: 'string' },
810
+ required: true,
811
+ },
812
+ {
813
+ name: 'scopes',
814
+ type: {
815
+ kind: 'array',
816
+ items: { kind: 'primitive', type: 'string' },
817
+ },
818
+ required: true,
819
+ },
820
+ ],
821
+ },
822
+ {
823
+ name: 'ConnectApplication',
824
+ fields: [
825
+ {
826
+ name: 'id',
827
+ type: { kind: 'primitive', type: 'string' },
828
+ required: true,
829
+ },
830
+ ],
831
+ },
832
+ ],
833
+ },
834
+ };
835
+
836
+ const files = generateResources(services, testCtx);
837
+ const content = files[0].content;
838
+
839
+ // Should use the union type for the payload parameter
840
+ expect(content).toContain('payload: CreateOAuthApplication | CreateM2MApplication');
841
+
842
+ // Should dispatch via unique required field guards
843
+ expect(content).toContain("'redirectUris' in payload");
844
+ expect(content).toContain('serializeCreateOAuthApplication(payload as any)');
845
+ expect(content).toContain('serializeCreateM2MApplication(payload as any)');
846
+
847
+ // Should import serializers for all union variants
848
+ expect(content).toContain('serializeCreateOAuthApplication');
849
+ expect(content).toContain('serializeCreateM2MApplication');
850
+ });
851
+
653
852
  it('generates discriminated union serializer dispatch for request body', () => {
654
853
  const services: Service[] = [
655
854
  {
@@ -790,12 +989,12 @@ describe('generateResources', () => {
790
989
  it('prefixes ListOptions with service name when method is "list"', () => {
791
990
  const services: Service[] = [
792
991
  {
793
- name: 'Connections',
992
+ name: 'Payments',
794
993
  operations: [
795
994
  {
796
995
  name: 'list',
797
996
  httpMethod: 'get',
798
- path: '/connections',
997
+ path: '/payments',
799
998
  pathParams: [],
800
999
  queryParams: [
801
1000
  {
@@ -826,7 +1025,15 @@ describe('generateResources', () => {
826
1025
  spec: { ...emptySpec, services, models: [] },
827
1026
  overlayLookup: {
828
1027
  methodByOperation: new Map([
829
- ['GET /connections', { className: 'Connections', methodName: 'list', params: [], returnType: 'void' }],
1028
+ [
1029
+ 'GET /payments',
1030
+ {
1031
+ className: 'Payments',
1032
+ methodName: 'list',
1033
+ params: [],
1034
+ returnType: 'void',
1035
+ },
1036
+ ],
830
1037
  ]),
831
1038
  httpKeyByMethod: new Map(),
832
1039
  interfaceByName: new Map(),
@@ -841,8 +1048,8 @@ describe('generateResources', () => {
841
1048
  const content = files[0].content;
842
1049
 
843
1050
  // Should use service-prefixed options name instead of generic "ListOptions"
844
- expect(content).toContain('export interface ConnectionsListOptions extends PaginationOptions {');
845
- expect(content).toContain('Promise<AutoPaginatable<Connection, ConnectionsListOptions>>');
1051
+ expect(content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
1052
+ expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
846
1053
  // Should NOT use the generic "ListOptions"
847
1054
  expect(content).not.toContain('export interface ListOptions ');
848
1055
  });
@@ -860,7 +1067,10 @@ describe('generateResources', () => {
860
1067
  queryParams: [
861
1068
  {
862
1069
  name: 'domains',
863
- type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
1070
+ type: {
1071
+ kind: 'array',
1072
+ items: { kind: 'primitive', type: 'string' },
1073
+ },
864
1074
  required: false,
865
1075
  },
866
1076
  ],
@@ -885,6 +1095,185 @@ describe('generateResources', () => {
885
1095
  // Method is "listOrganizations", not "list", so options name should be normal
886
1096
  expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
887
1097
  });
1098
+
1099
+ it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
1100
+ const services: Service[] = [
1101
+ {
1102
+ name: 'SSOService',
1103
+ operations: [
1104
+ {
1105
+ name: 'getAuthorizationUrl',
1106
+ httpMethod: 'get',
1107
+ path: '/sso/authorize',
1108
+ pathParams: [],
1109
+ queryParams: [],
1110
+ headerParams: [],
1111
+ response: { kind: 'model', name: 'AuthorizationUrl' },
1112
+ errors: [],
1113
+ injectIdempotencyKey: false,
1114
+ },
1115
+ {
1116
+ name: 'logout',
1117
+ httpMethod: 'get',
1118
+ path: '/sso/logout',
1119
+ pathParams: [],
1120
+ queryParams: [],
1121
+ headerParams: [],
1122
+ response: { kind: 'model', name: 'LogoutResult' },
1123
+ errors: [],
1124
+ injectIdempotencyKey: false,
1125
+ },
1126
+ ],
1127
+ },
1128
+ ];
1129
+
1130
+ // Overlay maps both operations to SSO class
1131
+ // Baseline SSO class exists but only has getAuthorizationUrl (logout is missing)
1132
+ const overlayCtx: EmitterContext = {
1133
+ namespace: 'workos',
1134
+ namespacePascal: 'WorkOS',
1135
+ spec: { ...emptySpec, services, models: [] },
1136
+ overlayLookup: {
1137
+ methodByOperation: new Map([
1138
+ [
1139
+ 'GET /sso/authorize',
1140
+ {
1141
+ className: 'SSO',
1142
+ methodName: 'getAuthorizationUrl',
1143
+ params: [],
1144
+ returnType: 'void',
1145
+ },
1146
+ ],
1147
+ [
1148
+ 'GET /sso/logout',
1149
+ {
1150
+ className: 'SSO',
1151
+ methodName: 'logout',
1152
+ params: [],
1153
+ returnType: 'void',
1154
+ },
1155
+ ],
1156
+ ]),
1157
+ httpKeyByMethod: new Map(),
1158
+ interfaceByName: new Map(),
1159
+ typeAliasByName: new Map(),
1160
+ requiredExports: new Map(),
1161
+ modelNameByIR: new Map(),
1162
+ fileBySymbol: new Map(),
1163
+ },
1164
+ apiSurface: {
1165
+ language: 'node',
1166
+ extractedFrom: 'test',
1167
+ extractedAt: '2024-01-01',
1168
+ classes: {
1169
+ SSO: {
1170
+ name: 'SSO',
1171
+ methods: {
1172
+ getAuthorizationUrl: [
1173
+ {
1174
+ name: 'getAuthorizationUrl',
1175
+ params: [],
1176
+ returnType: 'void',
1177
+ async: true,
1178
+ },
1179
+ ],
1180
+ // logout method is intentionally ABSENT
1181
+ },
1182
+ properties: {},
1183
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1184
+ },
1185
+ },
1186
+ interfaces: {},
1187
+ typeAliases: {},
1188
+ enums: {},
1189
+ exports: {},
1190
+ },
1191
+ };
1192
+
1193
+ const files = generateResources(services, overlayCtx);
1194
+ expect(files.length).toBe(1);
1195
+
1196
+ // skipIfExists should be removed because 'logout' is absent from baseline
1197
+ expect(files[0].skipIfExists).toBeUndefined();
1198
+ });
1199
+
1200
+ it('keeps skipIfExists when fully-covered service has all methods in baseline', () => {
1201
+ const services: Service[] = [
1202
+ {
1203
+ name: 'SSOService',
1204
+ operations: [
1205
+ {
1206
+ name: 'getAuthorizationUrl',
1207
+ httpMethod: 'get',
1208
+ path: '/sso/authorize',
1209
+ pathParams: [],
1210
+ queryParams: [],
1211
+ headerParams: [],
1212
+ response: { kind: 'model', name: 'AuthorizationUrl' },
1213
+ errors: [],
1214
+ injectIdempotencyKey: false,
1215
+ },
1216
+ ],
1217
+ },
1218
+ ];
1219
+
1220
+ const overlayCtx: EmitterContext = {
1221
+ namespace: 'workos',
1222
+ namespacePascal: 'WorkOS',
1223
+ spec: { ...emptySpec, services, models: [] },
1224
+ overlayLookup: {
1225
+ methodByOperation: new Map([
1226
+ [
1227
+ 'GET /sso/authorize',
1228
+ {
1229
+ className: 'SSO',
1230
+ methodName: 'getAuthorizationUrl',
1231
+ params: [],
1232
+ returnType: 'void',
1233
+ },
1234
+ ],
1235
+ ]),
1236
+ httpKeyByMethod: new Map(),
1237
+ interfaceByName: new Map(),
1238
+ typeAliasByName: new Map(),
1239
+ requiredExports: new Map(),
1240
+ modelNameByIR: new Map(),
1241
+ fileBySymbol: new Map(),
1242
+ },
1243
+ apiSurface: {
1244
+ language: 'node',
1245
+ extractedFrom: 'test',
1246
+ extractedAt: '2024-01-01',
1247
+ classes: {
1248
+ SSO: {
1249
+ name: 'SSO',
1250
+ methods: {
1251
+ getAuthorizationUrl: [
1252
+ {
1253
+ name: 'getAuthorizationUrl',
1254
+ params: [],
1255
+ returnType: 'void',
1256
+ async: true,
1257
+ },
1258
+ ],
1259
+ },
1260
+ properties: {},
1261
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1262
+ },
1263
+ },
1264
+ interfaces: {},
1265
+ typeAliases: {},
1266
+ enums: {},
1267
+ exports: {},
1268
+ },
1269
+ };
1270
+
1271
+ const files = generateResources(services, overlayCtx);
1272
+ expect(files.length).toBe(1);
1273
+
1274
+ // skipIfExists should stay true because all methods exist in baseline
1275
+ expect(files[0].skipIfExists).toBe(true);
1276
+ });
888
1277
  });
889
1278
 
890
1279
  describe('resolveResourceClassName', () => {
@@ -914,7 +1303,12 @@ describe('resolveResourceClassName', () => {
914
1303
  methodByOperation: new Map([
915
1304
  [
916
1305
  'GET /webhook_events',
917
- { className: 'Webhooks', methodName: 'listWebhookEvents', params: [], returnType: 'void' },
1306
+ {
1307
+ className: 'Webhooks',
1308
+ methodName: 'listWebhookEvents',
1309
+ params: [],
1310
+ returnType: 'void',
1311
+ },
918
1312
  ],
919
1313
  ]),
920
1314
  httpKeyByMethod: new Map(),
@@ -933,7 +1327,13 @@ describe('resolveResourceClassName', () => {
933
1327
  name: 'Webhooks',
934
1328
  methods: {},
935
1329
  properties: {},
936
- constructorParams: [{ name: 'cryptoProvider', type: 'CryptoProvider', optional: false }],
1330
+ constructorParams: [
1331
+ {
1332
+ name: 'cryptoProvider',
1333
+ type: 'CryptoProvider',
1334
+ optional: false,
1335
+ },
1336
+ ],
937
1337
  },
938
1338
  },
939
1339
  interfaces: {},
@@ -957,7 +1357,12 @@ describe('resolveResourceClassName', () => {
957
1357
  methodByOperation: new Map([
958
1358
  [
959
1359
  'GET /webhook_events',
960
- { className: 'Webhooks', methodName: 'listWebhookEvents', params: [], returnType: 'void' },
1360
+ {
1361
+ className: 'Webhooks',
1362
+ methodName: 'listWebhookEvents',
1363
+ params: [],
1364
+ returnType: 'void',
1365
+ },
961
1366
  ],
962
1367
  ]),
963
1368
  httpKeyByMethod: new Map(),
@@ -1014,7 +1419,15 @@ describe('resolveResourceClassName', () => {
1014
1419
  spec: { ...emptySpec, services: [collisionService] },
1015
1420
  overlayLookup: {
1016
1421
  methodByOperation: new Map([
1017
- ['GET /webhooks', { className: 'Webhooks', methodName: 'listWebhooks', params: [], returnType: 'void' }],
1422
+ [
1423
+ 'GET /webhooks',
1424
+ {
1425
+ className: 'Webhooks',
1426
+ methodName: 'listWebhooks',
1427
+ params: [],
1428
+ returnType: 'void',
1429
+ },
1430
+ ],
1018
1431
  ]),
1019
1432
  httpKeyByMethod: new Map(),
1020
1433
  interfaceByName: new Map(),
@@ -1032,7 +1445,13 @@ describe('resolveResourceClassName', () => {
1032
1445
  name: 'Webhooks',
1033
1446
  methods: {},
1034
1447
  properties: {},
1035
- constructorParams: [{ name: 'cryptoProvider', type: 'CryptoProvider', optional: false }],
1448
+ constructorParams: [
1449
+ {
1450
+ name: 'cryptoProvider',
1451
+ type: 'CryptoProvider',
1452
+ optional: false,
1453
+ },
1454
+ ],
1036
1455
  },
1037
1456
  },
1038
1457
  interfaces: {},
@@ -1090,7 +1509,13 @@ describe('hasCompatibleConstructor', () => {
1090
1509
  name: 'Webhooks',
1091
1510
  methods: {},
1092
1511
  properties: {},
1093
- constructorParams: [{ name: 'cryptoProvider', type: 'CryptoProvider', optional: false }],
1512
+ constructorParams: [
1513
+ {
1514
+ name: 'cryptoProvider',
1515
+ type: 'CryptoProvider',
1516
+ optional: false,
1517
+ },
1518
+ ],
1094
1519
  },
1095
1520
  },
1096
1521
  interfaces: {},
@@ -1192,7 +1617,14 @@ describe('partial service coverage', () => {
1192
1617
  AuditLogs: {
1193
1618
  name: 'AuditLogs',
1194
1619
  methods: {
1195
- createEvent: [{ name: 'createEvent', params: [], returnType: 'AuditLogEvent', async: true }],
1620
+ createEvent: [
1621
+ {
1622
+ name: 'createEvent',
1623
+ params: [],
1624
+ returnType: 'AuditLogEvent',
1625
+ async: true,
1626
+ },
1627
+ ],
1196
1628
  },
1197
1629
  properties: {},
1198
1630
  constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
@@ -1211,7 +1643,207 @@ describe('partial service coverage', () => {
1211
1643
 
1212
1644
  // Should generate method for uncovered operation
1213
1645
  expect(content).toContain('async getRetention');
1214
- // Should NOT generate method for covered operation
1215
- expect(content).not.toContain('async createEvent');
1646
+ // Should also generate covered operation so the merger can apply JSDoc
1647
+ expect(content).toContain('async createEvent');
1648
+ });
1649
+
1650
+ it('generates resource class for fully covered services to provide JSDoc', () => {
1651
+ const services: Service[] = [
1652
+ {
1653
+ name: 'Permissions',
1654
+ operations: [
1655
+ {
1656
+ name: 'listPermissions',
1657
+ description: 'List all permissions.',
1658
+ httpMethod: 'get',
1659
+ path: '/authorization/permissions',
1660
+ pathParams: [],
1661
+ queryParams: [],
1662
+ headerParams: [],
1663
+ response: { kind: 'model', name: 'PermissionList' },
1664
+ errors: [{ statusCode: 404 }],
1665
+ injectIdempotencyKey: false,
1666
+ },
1667
+ ],
1668
+ },
1669
+ ];
1670
+
1671
+ const ctxCovered: EmitterContext = {
1672
+ ...ctx,
1673
+ spec: { ...emptySpec, services, models: [] },
1674
+ overlayLookup: {
1675
+ methodByOperation: new Map([
1676
+ [
1677
+ 'GET /authorization/permissions',
1678
+ {
1679
+ className: 'Permissions',
1680
+ methodName: 'listPermissions',
1681
+ params: [],
1682
+ returnType: 'void',
1683
+ },
1684
+ ],
1685
+ ]),
1686
+ httpKeyByMethod: new Map(),
1687
+ interfaceByName: new Map(),
1688
+ typeAliasByName: new Map(),
1689
+ requiredExports: new Map(),
1690
+ modelNameByIR: new Map(),
1691
+ fileBySymbol: new Map(),
1692
+ },
1693
+ apiSurface: {
1694
+ language: 'node',
1695
+ extractedFrom: 'test',
1696
+ extractedAt: '2024-01-01',
1697
+ classes: {
1698
+ Permissions: {
1699
+ name: 'Permissions',
1700
+ methods: {
1701
+ listPermissions: [
1702
+ {
1703
+ name: 'listPermissions',
1704
+ params: [],
1705
+ returnType: 'void',
1706
+ async: true,
1707
+ },
1708
+ ],
1709
+ },
1710
+ properties: {},
1711
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1712
+ },
1713
+ },
1714
+ interfaces: {},
1715
+ typeAliases: {},
1716
+ enums: {},
1717
+ exports: {},
1718
+ },
1719
+ };
1720
+
1721
+ const files = generateResources(services, ctxCovered);
1722
+ expect(files.length).toBe(1);
1723
+ const content = files[0].content;
1724
+ // All methods should have generated JSDoc — the merger matches by name
1725
+ // and handles @deprecated preservation, so the emitter always provides
1726
+ // docstrings for the merger to work with.
1727
+ expect(content).toContain('List all permissions.');
1728
+ // skipIfExists should remain true for covered services
1729
+ expect(files[0].skipIfExists).toBe(true);
1730
+ });
1731
+
1732
+ it('uses resolved operation method names when provided', () => {
1733
+ const op = {
1734
+ name: 'listRolesOrganizations',
1735
+ httpMethod: 'get' as const,
1736
+ path: '/authorization/organizations/{organizationId}/roles',
1737
+ pathParams: [
1738
+ {
1739
+ name: 'organizationId',
1740
+ type: { kind: 'primitive' as const, type: 'string' as const },
1741
+ required: true,
1742
+ },
1743
+ ],
1744
+ queryParams: [],
1745
+ headerParams: [],
1746
+ response: { kind: 'model' as const, name: 'RoleList' },
1747
+ errors: [],
1748
+ pagination: {
1749
+ strategy: 'cursor' as const,
1750
+ param: 'after',
1751
+ itemType: { kind: 'model' as const, name: 'RoleList' },
1752
+ },
1753
+ injectIdempotencyKey: false,
1754
+ };
1755
+ const services: Service[] = [
1756
+ {
1757
+ name: 'Authorization',
1758
+ operations: [op],
1759
+ },
1760
+ ];
1761
+
1762
+ const ctxResolved: EmitterContext = {
1763
+ ...ctx,
1764
+ spec: {
1765
+ ...emptySpec,
1766
+ services,
1767
+ models: [{ name: 'RoleList', fields: [] }],
1768
+ },
1769
+ resolvedOperations: [
1770
+ {
1771
+ operation: op,
1772
+ service: services[0],
1773
+ methodName: 'list_organization_roles',
1774
+ mountOn: 'Authorization',
1775
+ } as any,
1776
+ ],
1777
+ };
1778
+
1779
+ const files = generateResources(services, ctxResolved);
1780
+ expect(files.length).toBe(1);
1781
+ const content = files[0].content;
1782
+ // Should use the resolved operation name (converted to camelCase)
1783
+ expect(content).toContain('async listOrganizationRoles');
1784
+ expect(content).not.toContain('async listRolesOrganizations');
1785
+ });
1786
+
1787
+ it('deduplicates method names for operations on different paths', () => {
1788
+ const services: Service[] = [
1789
+ {
1790
+ name: 'Organizations',
1791
+ operations: [
1792
+ {
1793
+ name: 'create',
1794
+ httpMethod: 'post',
1795
+ path: '/organization_domains',
1796
+ pathParams: [],
1797
+ queryParams: [],
1798
+ headerParams: [],
1799
+ requestBody: { kind: 'model', name: 'CreateOrgDomain' },
1800
+ response: { kind: 'model', name: 'OrgDomain' },
1801
+ errors: [],
1802
+ injectIdempotencyKey: false,
1803
+ },
1804
+ {
1805
+ name: 'create',
1806
+ httpMethod: 'post',
1807
+ path: '/organizations',
1808
+ pathParams: [],
1809
+ queryParams: [],
1810
+ headerParams: [],
1811
+ requestBody: { kind: 'model', name: 'CreateOrg' },
1812
+ response: { kind: 'model', name: 'Organization' },
1813
+ errors: [],
1814
+ injectIdempotencyKey: false,
1815
+ },
1816
+ ],
1817
+ },
1818
+ ];
1819
+
1820
+ const ctxDedup: EmitterContext = {
1821
+ ...ctx,
1822
+ spec: {
1823
+ ...emptySpec,
1824
+ services,
1825
+ models: [
1826
+ { name: 'CreateOrgDomain', fields: [] },
1827
+ { name: 'OrgDomain', fields: [] },
1828
+ { name: 'CreateOrg', fields: [] },
1829
+ { name: 'Organization', fields: [] },
1830
+ ],
1831
+ },
1832
+ };
1833
+
1834
+ const files = generateResources(services, ctxDedup);
1835
+ expect(files.length).toBe(1);
1836
+ const content = files[0].content;
1837
+ // The best-scoring plan keeps the name; the other gets disambiguated.
1838
+ // "create" matches "organizations" path better (the word "create" doesn't
1839
+ // appear in either path, but scoring is equal — first wins).
1840
+ // The other gets a path suffix.
1841
+ const createMatches = content.match(/async create\b/g);
1842
+ // At most one un-suffixed "create"
1843
+ expect(createMatches?.length ?? 0).toBeLessThanOrEqual(1);
1844
+ // The two methods should have different names
1845
+ const methodNames = [...content.matchAll(/async (\w+)\(/g)].map((m) => m[1]);
1846
+ const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
1847
+ expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
1216
1848
  });
1217
1849
  });