@workos/oagen-emitters 0.4.0 → 0.6.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 (126) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-Dws9b6T7.mjs +21441 -0
  14. package/dist/plugin-Dws9b6T7.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +17 -41
  21. package/smoke/sdk-dotnet.ts +11 -5
  22. package/smoke/sdk-elixir.ts +11 -5
  23. package/smoke/sdk-go.ts +10 -4
  24. package/smoke/sdk-kotlin.ts +11 -5
  25. package/smoke/sdk-node.ts +11 -5
  26. package/smoke/sdk-php.ts +9 -4
  27. package/smoke/sdk-python.ts +10 -4
  28. package/smoke/sdk-ruby.ts +10 -4
  29. package/smoke/sdk-rust.ts +11 -5
  30. package/src/dotnet/index.ts +9 -7
  31. package/src/dotnet/manifest.ts +5 -11
  32. package/src/dotnet/models.ts +58 -82
  33. package/src/dotnet/naming.ts +44 -6
  34. package/src/dotnet/resources.ts +350 -29
  35. package/src/dotnet/tests.ts +44 -24
  36. package/src/dotnet/type-map.ts +44 -17
  37. package/src/dotnet/wrappers.ts +21 -10
  38. package/src/go/client.ts +35 -3
  39. package/src/go/enums.ts +4 -0
  40. package/src/go/index.ts +13 -8
  41. package/src/go/manifest.ts +5 -11
  42. package/src/go/models.ts +6 -1
  43. package/src/go/resources.ts +534 -73
  44. package/src/go/tests.ts +39 -3
  45. package/src/go/type-map.ts +8 -3
  46. package/src/go/wrappers.ts +79 -21
  47. package/src/index.ts +14 -0
  48. package/src/kotlin/client.ts +7 -2
  49. package/src/kotlin/enums.ts +30 -3
  50. package/src/kotlin/index.ts +3 -3
  51. package/src/kotlin/manifest.ts +9 -15
  52. package/src/kotlin/models.ts +97 -6
  53. package/src/kotlin/naming.ts +7 -1
  54. package/src/kotlin/resources.ts +370 -39
  55. package/src/kotlin/tests.ts +120 -6
  56. package/src/node/client.ts +38 -11
  57. package/src/node/field-plan.ts +12 -14
  58. package/src/node/fixtures.ts +39 -3
  59. package/src/node/index.ts +3 -3
  60. package/src/node/manifest.ts +4 -11
  61. package/src/node/models.ts +281 -37
  62. package/src/node/resources.ts +156 -52
  63. package/src/node/tests.ts +76 -27
  64. package/src/node/type-map.ts +1 -31
  65. package/src/node/utils.ts +96 -6
  66. package/src/node/wrappers.ts +31 -1
  67. package/src/php/index.ts +3 -3
  68. package/src/php/manifest.ts +5 -11
  69. package/src/php/models.ts +0 -33
  70. package/src/php/resources.ts +199 -18
  71. package/src/php/tests.ts +26 -2
  72. package/src/php/type-map.ts +16 -2
  73. package/src/php/wrappers.ts +6 -2
  74. package/src/plugin.ts +50 -0
  75. package/src/python/client.ts +13 -3
  76. package/src/python/enums.ts +28 -3
  77. package/src/python/index.ts +38 -30
  78. package/src/python/manifest.ts +5 -12
  79. package/src/python/models.ts +138 -1
  80. package/src/python/resources.ts +234 -17
  81. package/src/python/tests.ts +260 -16
  82. package/src/python/type-map.ts +16 -2
  83. package/src/ruby/client.ts +238 -0
  84. package/src/ruby/enums.ts +149 -0
  85. package/src/ruby/index.ts +93 -0
  86. package/src/ruby/manifest.ts +28 -0
  87. package/src/ruby/models.ts +360 -0
  88. package/src/ruby/naming.ts +187 -0
  89. package/src/ruby/rbi.ts +313 -0
  90. package/src/ruby/resources.ts +799 -0
  91. package/src/ruby/tests.ts +459 -0
  92. package/src/ruby/type-map.ts +97 -0
  93. package/src/ruby/wrappers.ts +161 -0
  94. package/src/shared/model-utils.ts +131 -7
  95. package/src/shared/naming-utils.ts +36 -0
  96. package/src/shared/non-spec-services.ts +13 -0
  97. package/src/shared/resolved-ops.ts +75 -1
  98. package/test/dotnet/client.test.ts +2 -2
  99. package/test/dotnet/manifest.test.ts +13 -12
  100. package/test/dotnet/models.test.ts +7 -9
  101. package/test/dotnet/resources.test.ts +135 -3
  102. package/test/dotnet/tests.test.ts +5 -5
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +1 -1
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/node/models.test.ts +134 -1
  109. package/test/node/resources.test.ts +134 -26
  110. package/test/node/utils.test.ts +140 -0
  111. package/test/php/models.test.ts +5 -4
  112. package/test/php/resources.test.ts +66 -1
  113. package/test/plugin.test.ts +50 -0
  114. package/test/python/client.test.ts +56 -0
  115. package/test/python/manifest.test.ts +7 -7
  116. package/test/python/models.test.ts +99 -0
  117. package/test/python/resources.test.ts +294 -0
  118. package/test/python/tests.test.ts +91 -0
  119. package/test/ruby/client.test.ts +81 -0
  120. package/test/ruby/resources.test.ts +386 -0
  121. package/test/shared/resolved-ops.test.ts +122 -0
  122. package/tsconfig.json +1 -0
  123. package/tsdown.config.ts +1 -1
  124. package/dist/index.mjs.map +0 -1
  125. package/scripts/generate-php.js +0 -13
  126. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { defaultSdkBehavior, type ApiSpec, type EmitterContext, type Service } from '@workos/oagen';
3
+ import { generateResources } from '../../src/kotlin/resources.js';
4
+ import { generateEnums } from '../../src/kotlin/enums.js';
5
+
6
+ const baseSpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: 'https://api.workos.com',
10
+ services: [],
11
+ models: [
12
+ {
13
+ name: 'AuthenticateResponse',
14
+ fields: [{ name: 'access_token', type: { kind: 'primitive', type: 'string' }, required: true }],
15
+ },
16
+ {
17
+ name: 'SSOTokenResponse',
18
+ fields: [{ name: 'access_token', type: { kind: 'primitive', type: 'string' }, required: true }],
19
+ },
20
+ ],
21
+ enums: [],
22
+ sdk: defaultSdkBehavior(),
23
+ };
24
+
25
+ function ctxFor(services: Service[], enums = baseSpec.enums): EmitterContext {
26
+ return {
27
+ namespace: 'workos',
28
+ namespacePascal: 'WorkOS',
29
+ spec: { ...baseSpec, services, enums },
30
+ resolvedOperations: services.flatMap((service) =>
31
+ service.operations.map((operation) => ({
32
+ service,
33
+ operation,
34
+ methodName: operation.name,
35
+ mountOn: service.name,
36
+ defaults: {},
37
+ inferFromClient: [],
38
+ urlBuilder: false,
39
+ })),
40
+ ),
41
+ };
42
+ }
43
+
44
+ describe('kotlin/resources', () => {
45
+ it('collapses duplicated query/body params into a single Kotlin parameter', () => {
46
+ const services: Service[] = [
47
+ {
48
+ name: 'SSO',
49
+ operations: [
50
+ {
51
+ name: 'getProfileAndToken',
52
+ httpMethod: 'post',
53
+ path: '/sso/token',
54
+ pathParams: [],
55
+ queryParams: [{ name: 'code', type: { kind: 'primitive', type: 'string' }, required: true }],
56
+ headerParams: [],
57
+ requestBody: { kind: 'model', name: 'GetProfileAndTokenRequest' },
58
+ response: { kind: 'model', name: 'SSOTokenResponse' },
59
+ errors: [],
60
+ injectIdempotencyKey: false,
61
+ },
62
+ ],
63
+ },
64
+ ];
65
+ const spec = {
66
+ ...baseSpec,
67
+ services,
68
+ models: [
69
+ ...baseSpec.models,
70
+ {
71
+ name: 'GetProfileAndTokenRequest',
72
+ fields: [{ name: 'code', type: { kind: 'primitive', type: 'string' }, required: true }],
73
+ },
74
+ ],
75
+ };
76
+ const files = generateResources(services, { ...ctxFor(services), spec: spec as ApiSpec });
77
+ const ssoFile = files.find((file) => file.path.endsWith('/Sso.kt'));
78
+ expect(ssoFile).toBeDefined();
79
+ expect(ssoFile!.content).toContain('fun getProfileAndToken(');
80
+ expect(ssoFile!.content).toContain('code: String');
81
+ expect(ssoFile!.content).not.toContain('bodyCode');
82
+ expect(ssoFile!.content).toContain('params += "code" to code');
83
+ expect(ssoFile!.content).toContain('"code" to code');
84
+ });
85
+
86
+ it('emits a shared authenticate helper for user management authenticate variants', () => {
87
+ const services: Service[] = [
88
+ {
89
+ name: 'UserManagement',
90
+ operations: [
91
+ {
92
+ name: 'authenticateWithPassword',
93
+ httpMethod: 'post',
94
+ path: '/user_management/authenticate',
95
+ pathParams: [],
96
+ queryParams: [],
97
+ headerParams: [],
98
+ requestBody: { kind: 'model', name: 'AuthenticatePasswordRequest' },
99
+ response: { kind: 'model', name: 'AuthenticateResponse' },
100
+ errors: [],
101
+ injectIdempotencyKey: false,
102
+ },
103
+ ],
104
+ },
105
+ ];
106
+ const spec = {
107
+ ...baseSpec,
108
+ services,
109
+ models: [
110
+ ...baseSpec.models,
111
+ {
112
+ name: 'AuthenticatePasswordRequest',
113
+ fields: [
114
+ { name: 'email', type: { kind: 'primitive', type: 'string' }, required: true },
115
+ { name: 'password', type: { kind: 'primitive', type: 'string' }, required: true },
116
+ ],
117
+ },
118
+ ],
119
+ };
120
+ const files = generateResources(services, { ...ctxFor(services), spec: spec as ApiSpec });
121
+ const userManagementFile = files.find((file) => file.path.endsWith('/UserManagement.kt'));
122
+ expect(userManagementFile).toBeDefined();
123
+ expect(userManagementFile!.content).toContain('private fun authenticate(');
124
+ expect(userManagementFile!.content).toContain('grantType = "authorization_code"');
125
+ });
126
+
127
+ it('renames package-level parameter group helpers to avoid Role/Password collisions', () => {
128
+ const services: Service[] = [
129
+ {
130
+ name: 'UserManagement',
131
+ operations: [
132
+ {
133
+ name: 'createUser',
134
+ httpMethod: 'post',
135
+ path: '/users',
136
+ pathParams: [],
137
+ queryParams: [],
138
+ headerParams: [],
139
+ requestBody: { kind: 'model', name: 'CreateUserRequest' },
140
+ response: { kind: 'model', name: 'AuthenticateResponse' },
141
+ errors: [],
142
+ injectIdempotencyKey: false,
143
+ parameterGroups: [
144
+ {
145
+ name: 'Password',
146
+ optional: true,
147
+ variants: [
148
+ {
149
+ name: 'Plaintext',
150
+ parameters: [{ name: 'password', type: { kind: 'primitive', type: 'string' }, required: true }],
151
+ },
152
+ ],
153
+ },
154
+ {
155
+ name: 'Role',
156
+ optional: true,
157
+ variants: [
158
+ {
159
+ name: 'Single',
160
+ parameters: [{ name: 'role_slug', type: { kind: 'primitive', type: 'string' }, required: true }],
161
+ },
162
+ ],
163
+ },
164
+ ],
165
+ },
166
+ ],
167
+ },
168
+ ];
169
+ const spec = {
170
+ ...baseSpec,
171
+ services,
172
+ models: [
173
+ ...baseSpec.models,
174
+ {
175
+ name: 'CreateUserRequest',
176
+ fields: [
177
+ { name: 'password', type: { kind: 'primitive', type: 'string' }, required: false },
178
+ { name: 'role_slug', type: { kind: 'primitive', type: 'string' }, required: false },
179
+ ],
180
+ },
181
+ ],
182
+ };
183
+ const files = generateResources(services, { ...ctxFor(services), spec: spec as ApiSpec });
184
+ const userManagementFile = files.find((file) => file.path.endsWith('/UserManagement.kt'));
185
+ expect(userManagementFile).toBeDefined();
186
+ expect(userManagementFile!.content).toContain('sealed class CreateUserPassword');
187
+ expect(userManagementFile!.content).toContain('sealed class CreateUserRole');
188
+ });
189
+
190
+ it('collapses asc/desc order enums into SortOrder', () => {
191
+ const enums = [
192
+ {
193
+ name: 'EventsOrder',
194
+ values: [{ value: 'asc' }, { value: 'desc' }],
195
+ },
196
+ {
197
+ name: 'OrganizationsOrder',
198
+ values: [{ value: 'asc' }, { value: 'desc' }],
199
+ },
200
+ ];
201
+
202
+ const files = generateEnums(enums as never, ctxFor([], enums as never));
203
+ const sortOrder = files.find((file) => file.path.endsWith('/SortOrder.kt'));
204
+ const aliases = files.filter((file) => file.path.endsWith('Order.kt') && !file.path.endsWith('/SortOrder.kt'));
205
+
206
+ expect(sortOrder).toBeDefined();
207
+ expect(sortOrder!.content).toContain('enum class SortOrder');
208
+ expect(aliases.length).toBeLessThanOrEqual(1);
209
+ });
210
+ });
@@ -182,6 +182,23 @@ describe('generateModels', () => {
182
182
  });
183
183
 
184
184
  it('handles generic type params', () => {
185
+ const service: Service = {
186
+ name: 'DirectorySync',
187
+ operations: [
188
+ {
189
+ name: 'getDirectoryUser',
190
+ httpMethod: 'get',
191
+ path: '/directory_users/{id}',
192
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
193
+ queryParams: [],
194
+ headerParams: [],
195
+ response: { kind: 'model', name: 'DirectoryUser' },
196
+ errors: [],
197
+ injectIdempotencyKey: false,
198
+ },
199
+ ],
200
+ };
201
+
185
202
  const models: Model[] = [
186
203
  {
187
204
  name: 'DirectoryUser',
@@ -204,7 +221,12 @@ describe('generateModels', () => {
204
221
  },
205
222
  ];
206
223
 
207
- const files = generateModels(models, ctx);
224
+ const ctxWithServices: EmitterContext = {
225
+ ...ctx,
226
+ spec: { ...emptySpec, services: [service], models },
227
+ };
228
+
229
+ const files = generateModels(models, ctxWithServices);
208
230
  expect(files[0].content).toContain('export interface DirectoryUser<TCustom = Record<string, any>> {');
209
231
  expect(files[0].content).toContain('export interface DirectoryUserResponse<TCustom = Record<string, any>> {');
210
232
  });
@@ -733,6 +755,23 @@ describe('model deduplication', () => {
733
755
  errors: [],
734
756
  injectIdempotencyKey: false,
735
757
  },
758
+ {
759
+ name: 'getOrganizationRole',
760
+ httpMethod: 'get',
761
+ path: '/organization_roles/{id}',
762
+ pathParams: [
763
+ {
764
+ name: 'id',
765
+ type: { kind: 'primitive', type: 'string' },
766
+ required: true,
767
+ },
768
+ ],
769
+ queryParams: [],
770
+ headerParams: [],
771
+ response: { kind: 'model', name: 'OrganizationRole' },
772
+ errors: [],
773
+ injectIdempotencyKey: false,
774
+ },
736
775
  ],
737
776
  };
738
777
 
@@ -794,4 +833,98 @@ describe('model deduplication', () => {
794
833
  expect(files[1].content).toContain('export type OrganizationRole = EnvironmentRole');
795
834
  expect(files[1].content).toContain('export type OrganizationRoleResponse = EnvironmentRoleResponse');
796
835
  });
836
+
837
+ it('generates Date type for date-time fields even when baseline says string', () => {
838
+ const service: Service = {
839
+ name: 'Authorization',
840
+ operations: [
841
+ {
842
+ name: 'getRoleAssignment',
843
+ httpMethod: 'get',
844
+ path: '/role_assignments/{id}',
845
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
846
+ queryParams: [],
847
+ headerParams: [],
848
+ response: { kind: 'model', name: 'RoleAssignment' },
849
+ errors: [],
850
+ injectIdempotencyKey: false,
851
+ },
852
+ ],
853
+ };
854
+
855
+ const models: Model[] = [
856
+ {
857
+ name: 'RoleAssignment',
858
+ fields: [
859
+ {
860
+ name: 'id',
861
+ type: { kind: 'primitive', type: 'string' },
862
+ required: true,
863
+ },
864
+ {
865
+ name: 'created_at',
866
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
867
+ required: true,
868
+ },
869
+ {
870
+ name: 'updated_at',
871
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
872
+ required: true,
873
+ },
874
+ // Extra field not in baseline so modelHasNewFields returns true
875
+ // (allowing the dedup test to proceed with generation)
876
+ {
877
+ name: 'role_name',
878
+ type: { kind: 'primitive', type: 'string' },
879
+ required: true,
880
+ },
881
+ ],
882
+ },
883
+ ];
884
+
885
+ const ctxWithBaseline: EmitterContext = {
886
+ ...ctx,
887
+ spec: { ...emptySpec, services: [service], models },
888
+ apiSurface: {
889
+ language: 'node',
890
+ extractedFrom: 'test',
891
+ extractedAt: '2024-01-01',
892
+ classes: {},
893
+ typeAliases: {},
894
+ enums: {},
895
+ exports: {},
896
+ interfaces: {
897
+ RoleAssignment: {
898
+ name: 'RoleAssignment',
899
+ fields: {
900
+ id: { name: 'id', type: 'string', optional: false },
901
+ createdAt: { name: 'createdAt', type: 'string', optional: false },
902
+ updatedAt: { name: 'updatedAt', type: 'string', optional: false },
903
+ },
904
+ extends: [],
905
+ },
906
+ RoleAssignmentResponse: {
907
+ name: 'RoleAssignmentResponse',
908
+ fields: {
909
+ id: { name: 'id', type: 'string', optional: false },
910
+ created_at: { name: 'created_at', type: 'string', optional: false },
911
+ updated_at: { name: 'updated_at', type: 'string', optional: false },
912
+ },
913
+ extends: [],
914
+ },
915
+ },
916
+ },
917
+ };
918
+
919
+ const files = generateModels(models, ctxWithBaseline);
920
+ const content = files[0].content;
921
+
922
+ // Domain interface should use Date, not string from baseline
923
+ expect(content).toContain(' createdAt: Date;');
924
+ expect(content).toContain(' updatedAt: Date;');
925
+
926
+ // Wire interface should stay as string (JSON native)
927
+ expect(content).toContain(' created_at: string;');
928
+ expect(content).toContain(' updated_at: string;');
929
+ });
797
930
  });
@@ -99,9 +99,9 @@ describe('generateResources', () => {
99
99
  const files = generateResources(services, ctx);
100
100
  const content = files[0].content;
101
101
 
102
- // Should have AutoPaginatable type import and createPaginatedList import
103
- expect(content).toContain("import type { AutoPaginatable } from '../common/utils/pagination'");
104
- expect(content).toContain("import { createPaginatedList } from '../common/utils/fetch-and-deserialize'");
102
+ // Should have AutoPaginatable value import and fetchAndDeserialize import
103
+ expect(content).toContain("import { AutoPaginatable } from '../common/utils/pagination'");
104
+ expect(content).toContain("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'");
105
105
  // Options interface lives in its own file under interfaces/ so the
106
106
  // per-service barrel picks it up.
107
107
  expect(content).toContain(
@@ -120,10 +120,11 @@ describe('generateResources', () => {
120
120
  expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
121
121
 
122
122
  // `domains` has the same camelCase and snake_case spelling, so no wire
123
- // serializer should be emitted and createPaginatedList should be called
124
- // with just options (no 5th arg).
123
+ // serializer should be emitted; options are passed directly.
125
124
  expect(content).not.toContain('serializeListOrganizationsOptions');
126
- expect(content).toMatch(/createPaginatedList<[^>]+>\([^)]+options\);/);
125
+ expect(content).toContain('new AutoPaginatable(');
126
+ expect(content).toContain('fetchAndDeserialize<OrganizationResponse, Organization>');
127
+ expect(content).toContain('options,');
127
128
  });
128
129
 
129
130
  it('emits wire-options serializer for paginated list with camelCase filter fields', () => {
@@ -187,8 +188,9 @@ describe('generateResources', () => {
187
188
  expect(content).toContain('wire.organization_id = options.organizationId');
188
189
  expect(content).not.toContain('wire.organizationId');
189
190
 
190
- // createPaginatedList is invoked with the serializer as the 5th arg.
191
- expect(content).toMatch(/createPaginatedList<[^>]+>\([^)]+options,\s*serializeListApplicationsOptions\);/);
191
+ // fetchAndDeserialize is invoked with the serialized options.
192
+ expect(content).toContain('serializeListApplicationsOptions(options)');
193
+ expect(content).toContain('new AutoPaginatable(');
192
194
  });
193
195
 
194
196
  it('uses item type not list wrapper type for paginated methods', () => {
@@ -230,7 +232,7 @@ describe('generateResources', () => {
230
232
  const content = files[0].content;
231
233
 
232
234
  // Should use item type (Connection) not list wrapper (ConnectionList)
233
- expect(content).toContain('createPaginatedList<ConnectionResponse, Connection,');
235
+ expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
234
236
  expect(content).toContain('deserializeConnection');
235
237
  expect(content).toContain('Promise<AutoPaginatable<Connection,');
236
238
 
@@ -1085,7 +1087,7 @@ describe('generateResources', () => {
1085
1087
  expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload)");
1086
1088
  });
1087
1089
 
1088
- it('uses createPaginatedList helper in paginated methods', () => {
1090
+ it('uses AutoPaginatable pattern in paginated methods', () => {
1089
1091
  const services: Service[] = [
1090
1092
  {
1091
1093
  name: 'Connections',
@@ -1114,9 +1116,9 @@ describe('generateResources', () => {
1114
1116
  const files = generateResources(services, ctx);
1115
1117
  const content = files[0].content;
1116
1118
 
1117
- // Should use createPaginatedList helper for concise paginated methods
1118
- expect(content).toContain('createPaginatedList<ConnectionResponse, Connection,');
1119
- expect(content).toContain('this.workos,');
1119
+ // Should use AutoPaginatable + fetchAndDeserialize pattern for paginated methods
1120
+ expect(content).toContain('new AutoPaginatable(');
1121
+ expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
1120
1122
  expect(content).toContain('deserializeConnection');
1121
1123
  });
1122
1124
 
@@ -1407,10 +1409,8 @@ describe('generateResources', () => {
1407
1409
  };
1408
1410
 
1409
1411
  const files = generateResources(services, overlayCtx);
1410
- expect(files.length).toBe(1);
1411
-
1412
- // skipIfExists should stay true because all methods exist in baseline
1413
- expect(files[0].skipIfExists).toBe(true);
1412
+ // Fully covered services with no new methods are skipped entirely
1413
+ expect(files.length).toBe(0);
1414
1414
  });
1415
1415
 
1416
1416
  it('removes skipIfExists for purely oagen-managed services (no baseline)', () => {
@@ -1848,7 +1848,7 @@ describe('partial service coverage', () => {
1848
1848
  expect(content).toContain('async createEvent');
1849
1849
  });
1850
1850
 
1851
- it('generates resource class for fully covered services to provide JSDoc', () => {
1851
+ it('skips fully covered services with no new methods', () => {
1852
1852
  const services: Service[] = [
1853
1853
  {
1854
1854
  name: 'Permissions',
@@ -1920,14 +1920,9 @@ describe('partial service coverage', () => {
1920
1920
  };
1921
1921
 
1922
1922
  const files = generateResources(services, ctxCovered);
1923
- expect(files.length).toBe(1);
1924
- const content = files[0].content;
1925
- // All methods should have generated JSDoc — the merger matches by name
1926
- // and handles @deprecated preservation, so the emitter always provides
1927
- // docstrings for the merger to work with.
1928
- expect(content).toContain('List all permissions.');
1929
- // skipIfExists should remain true for covered services
1930
- expect(files[0].skipIfExists).toBe(true);
1923
+ // Fully covered services with no new methods are skipped entirely
1924
+ // (JSDoc-only updates are deferred until a structural change touches the file)
1925
+ expect(files.length).toBe(0);
1931
1926
  });
1932
1927
 
1933
1928
  it('uses resolved operation method names when provided', () => {
@@ -2047,4 +2042,117 @@ describe('partial service coverage', () => {
2047
2042
  const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
2048
2043
  expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
2049
2044
  });
2045
+
2046
+ it('omits @param payload when overlay method has no payload param', () => {
2047
+ const services: Service[] = [
2048
+ {
2049
+ name: 'AuditLogs',
2050
+ operations: [
2051
+ {
2052
+ name: 'createEvent',
2053
+ httpMethod: 'post',
2054
+ path: '/audit_logs/events',
2055
+ pathParams: [],
2056
+ queryParams: [],
2057
+ headerParams: [],
2058
+ requestBody: { kind: 'model', name: 'CreateAuditLogEvent' },
2059
+ response: { kind: 'primitive', type: 'unknown' },
2060
+ errors: [],
2061
+ injectIdempotencyKey: false,
2062
+ },
2063
+ ],
2064
+ },
2065
+ ];
2066
+
2067
+ const overlayCtx: EmitterContext = {
2068
+ namespace: 'workos',
2069
+ namespacePascal: 'WorkOS',
2070
+ spec: { ...emptySpec, services, models: [] },
2071
+ overlayLookup: {
2072
+ methodByOperation: new Map([
2073
+ [
2074
+ 'POST /audit_logs/events',
2075
+ {
2076
+ className: 'AuditLogs',
2077
+ methodName: 'createEvent',
2078
+ params: [
2079
+ { name: 'organization', type: 'string', optional: false },
2080
+ { name: 'event', type: 'CreateAuditLogEventOptions', optional: false },
2081
+ { name: 'options', type: 'CreateAuditLogEventRequestOptions', optional: true },
2082
+ ],
2083
+ returnType: 'Promise<void>',
2084
+ },
2085
+ ],
2086
+ ]),
2087
+ httpKeyByMethod: new Map(),
2088
+ interfaceByName: new Map(),
2089
+ typeAliasByName: new Map(),
2090
+ requiredExports: new Map(),
2091
+ modelNameByIR: new Map(),
2092
+ fileBySymbol: new Map(),
2093
+ },
2094
+ };
2095
+
2096
+ const files = generateResources(services, overlayCtx);
2097
+ const content = files[0].content;
2098
+ // Overlay has (organization, event, options) — no payload param
2099
+ expect(content).not.toContain('@param payload');
2100
+ });
2101
+
2102
+ it('documents @param options when overlay folds path params into options', () => {
2103
+ const services: Service[] = [
2104
+ {
2105
+ name: 'FeatureFlags',
2106
+ operations: [
2107
+ {
2108
+ name: 'addFeatureFlagTarget',
2109
+ httpMethod: 'post',
2110
+ path: '/feature-flags/{slug}/targets/{target_id}',
2111
+ pathParams: [
2112
+ { name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true },
2113
+ { name: 'target_id', type: { kind: 'primitive', type: 'string' }, required: true },
2114
+ ],
2115
+ queryParams: [],
2116
+ headerParams: [],
2117
+ response: { kind: 'primitive', type: 'unknown' },
2118
+ errors: [],
2119
+ injectIdempotencyKey: false,
2120
+ },
2121
+ ],
2122
+ },
2123
+ ];
2124
+
2125
+ const overlayCtx: EmitterContext = {
2126
+ namespace: 'workos',
2127
+ namespacePascal: 'WorkOS',
2128
+ spec: { ...emptySpec, services, models: [] },
2129
+ overlayLookup: {
2130
+ methodByOperation: new Map([
2131
+ [
2132
+ 'POST /feature-flags/{slug}/targets/{target_id}',
2133
+ {
2134
+ className: 'FeatureFlags',
2135
+ methodName: 'addFlagTarget',
2136
+ params: [{ name: 'options', type: 'AddFlagTargetOptions', optional: false }],
2137
+ returnType: 'Promise<void>',
2138
+ },
2139
+ ],
2140
+ ]),
2141
+ httpKeyByMethod: new Map(),
2142
+ interfaceByName: new Map(),
2143
+ typeAliasByName: new Map(),
2144
+ requiredExports: new Map(),
2145
+ modelNameByIR: new Map(),
2146
+ fileBySymbol: new Map(),
2147
+ },
2148
+ };
2149
+
2150
+ const files = generateResources(services, overlayCtx);
2151
+ const content = files[0].content;
2152
+ // Path params (slug, targetId) are folded into options — should not appear as top-level @param
2153
+ expect(content).not.toContain('@param slug');
2154
+ expect(content).not.toContain('@param targetId');
2155
+ // Should have @param options since it's in the overlay signature
2156
+ expect(content).toContain('@param options');
2157
+ });
2050
2158
  });