@workos/oagen-emitters 0.3.0 → 0.5.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 (128) 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 +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.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 +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  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 +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateModels } from '../../src/dotnet/models.js';
3
+ import { primeEnumAliases } from '../../src/dotnet/enums.js';
4
+ import type { EmitterContext, ApiSpec, Model, Service } from '@workos/oagen';
5
+ import { defaultSdkBehavior } from '@workos/oagen';
6
+
7
+ const emptySpec: ApiSpec = {
8
+ name: 'Test',
9
+ version: '1.0.0',
10
+ baseUrl: '',
11
+ services: [],
12
+ models: [],
13
+ enums: [],
14
+ sdk: defaultSdkBehavior(),
15
+ };
16
+
17
+ const ctx: EmitterContext = {
18
+ namespace: 'workos',
19
+ namespacePascal: 'WorkOS',
20
+ spec: emptySpec,
21
+ };
22
+
23
+ describe('dotnet/models', () => {
24
+ it('returns empty for no models', () => {
25
+ expect(generateModels([], ctx)).toEqual([]);
26
+ });
27
+
28
+ it('generates a C# class with JSON attributes', () => {
29
+ const service: Service = {
30
+ name: 'Organizations',
31
+ operations: [
32
+ {
33
+ name: 'getOrganization',
34
+ httpMethod: 'get',
35
+ path: '/organizations/{id}',
36
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
37
+ queryParams: [],
38
+ headerParams: [],
39
+ response: { kind: 'model', name: 'Organization' },
40
+ errors: [],
41
+ injectIdempotencyKey: false,
42
+ },
43
+ ],
44
+ };
45
+
46
+ const models: Model[] = [
47
+ {
48
+ name: 'Organization',
49
+ fields: [
50
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
51
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
52
+ {
53
+ name: 'created_at',
54
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
55
+ required: true,
56
+ },
57
+ {
58
+ name: 'external_id',
59
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
60
+ required: false,
61
+ },
62
+ ],
63
+ },
64
+ ];
65
+
66
+ primeEnumAliases([]);
67
+ const files = generateModels(models, {
68
+ ...ctx,
69
+ spec: { ...emptySpec, services: [service], models },
70
+ });
71
+
72
+ expect(files.length).toBeGreaterThanOrEqual(1);
73
+
74
+ const modelFile = files.find((f) => f.path === 'Entities/Organization.cs')!;
75
+ expect(modelFile).toBeDefined();
76
+
77
+ const content = modelFile.content;
78
+ // Namespace
79
+ expect(content).toContain('namespace WorkOS');
80
+ // Class definition
81
+ expect(content).toContain('public class Organization');
82
+
83
+ // Required fields — convention-based naming (no per-property JSON attributes)
84
+ expect(content).toContain('public string Id');
85
+ expect(content).toContain('public string Name');
86
+ expect(content).not.toContain('[JsonProperty("id")]');
87
+ expect(content).not.toContain('[STJS.JsonPropertyName(');
88
+
89
+ // DateTime field
90
+ expect(content).toContain('DateTimeOffset');
91
+
92
+ // Optional/nullable field
93
+ expect(content).toContain('public string? ExternalId');
94
+ });
95
+
96
+ it('skips list wrapper and list metadata models', () => {
97
+ const models: Model[] = [
98
+ {
99
+ name: 'Organization',
100
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
101
+ },
102
+ {
103
+ name: 'OrganizationList',
104
+ fields: [
105
+ {
106
+ name: 'data',
107
+ type: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
108
+ required: true,
109
+ },
110
+ {
111
+ name: 'list_metadata',
112
+ type: { kind: 'model', name: 'ListMetadata' },
113
+ required: true,
114
+ },
115
+ ],
116
+ },
117
+ {
118
+ name: 'ListMetadata',
119
+ fields: [
120
+ { name: 'before', type: { kind: 'primitive', type: 'string' }, required: false },
121
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
122
+ ],
123
+ },
124
+ ];
125
+
126
+ primeEnumAliases([]);
127
+ const files = generateModels(models, {
128
+ ...ctx,
129
+ spec: { ...emptySpec, models },
130
+ });
131
+ const filePaths = files.map((f) => f.path);
132
+
133
+ // Should generate Organization but NOT OrganizationList or ListMetadata
134
+ expect(filePaths.some((p) => p.includes('Organization.cs') && !p.includes('List'))).toBe(true);
135
+ expect(filePaths.some((p) => p.includes('OrganizationList.cs'))).toBe(false);
136
+ expect(filePaths.some((p) => p.includes('ListMetadata.cs'))).toBe(false);
137
+ });
138
+
139
+ it('deduplicates structurally identical models', () => {
140
+ const models: Model[] = [
141
+ {
142
+ name: 'OrganizationDomain',
143
+ fields: [
144
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
145
+ { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true },
146
+ ],
147
+ },
148
+ {
149
+ name: 'OrganizationDomainStandAlone',
150
+ fields: [
151
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
152
+ { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true },
153
+ ],
154
+ },
155
+ ];
156
+
157
+ primeEnumAliases([]);
158
+ const files = generateModels(models, {
159
+ ...ctx,
160
+ spec: { ...emptySpec, models },
161
+ });
162
+
163
+ // Canonical model should have a full class
164
+ const canonicalFile = files.find(
165
+ (f) => f.path.includes('OrganizationDomain.cs') && !f.path.includes('StandAlone'),
166
+ )!;
167
+ expect(canonicalFile).toBeDefined();
168
+ expect(canonicalFile.content).toContain('public class OrganizationDomain');
169
+
170
+ // Alias model should NOT be emitted — references are rewritten to the canonical
171
+ const aliasFile = files.find((f) => f.path.includes('OrganizationDomainStandAlone.cs'));
172
+ expect(aliasFile).toBeUndefined();
173
+ });
174
+
175
+ it('emits [System.Obsolete] for deprecated fields', () => {
176
+ const models: Model[] = [
177
+ {
178
+ name: 'Organization',
179
+ fields: [
180
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
181
+ {
182
+ name: 'old_field',
183
+ type: { kind: 'primitive', type: 'string' },
184
+ required: true,
185
+ deprecated: true,
186
+ description: 'Legacy field',
187
+ },
188
+ ],
189
+ },
190
+ ];
191
+
192
+ primeEnumAliases([]);
193
+ const files = generateModels(models, {
194
+ ...ctx,
195
+ spec: { ...emptySpec, models },
196
+ });
197
+ const modelFile = files.find((f) => f.path.includes('Organization.cs'))!;
198
+
199
+ expect(modelFile.content).toContain('[System.Obsolete');
200
+ });
201
+
202
+ it('handles map fields', () => {
203
+ const models: Model[] = [
204
+ {
205
+ name: 'Organization',
206
+ fields: [
207
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
208
+ {
209
+ name: 'metadata',
210
+ type: { kind: 'map', valueType: { kind: 'primitive', type: 'string' } },
211
+ required: false,
212
+ },
213
+ ],
214
+ },
215
+ ];
216
+
217
+ primeEnumAliases([]);
218
+ const files = generateModels(models, {
219
+ ...ctx,
220
+ spec: { ...emptySpec, models },
221
+ });
222
+ const modelFile = files.find((f) => f.path.includes('Organization.cs'))!;
223
+
224
+ expect(modelFile.content).toContain('Dictionary<string,');
225
+ });
226
+
227
+ it('handles array fields with model refs', () => {
228
+ const models: Model[] = [
229
+ {
230
+ name: 'Organization',
231
+ fields: [
232
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
233
+ {
234
+ name: 'domains',
235
+ type: { kind: 'array', items: { kind: 'model', name: 'OrganizationDomain' } },
236
+ required: true,
237
+ },
238
+ ],
239
+ },
240
+ {
241
+ name: 'OrganizationDomain',
242
+ fields: [
243
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
244
+ { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true },
245
+ ],
246
+ },
247
+ ];
248
+
249
+ primeEnumAliases([]);
250
+ const files = generateModels(models, {
251
+ ...ctx,
252
+ spec: { ...emptySpec, models },
253
+ });
254
+ const orgFile = files.find((f) => f.path.includes('Organization.cs') && !f.path.includes('Domain'))!;
255
+ expect(orgFile).toBeDefined();
256
+ expect(orgFile.content).toContain('List<OrganizationDomain>');
257
+ });
258
+ });
@@ -0,0 +1,387 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateResources } from '../../src/dotnet/resources.js';
3
+ import { primeEnumAliases } from '../../src/dotnet/enums.js';
4
+ import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
5
+ import { defaultSdkBehavior } from '@workos/oagen';
6
+
7
+ const emptySpec: ApiSpec = {
8
+ name: 'Test',
9
+ version: '1.0.0',
10
+ baseUrl: '',
11
+ services: [],
12
+ models: [],
13
+ enums: [],
14
+ sdk: defaultSdkBehavior(),
15
+ };
16
+
17
+ const ctx: EmitterContext = {
18
+ namespace: 'workos',
19
+ namespacePascal: 'WorkOS',
20
+ spec: emptySpec,
21
+ };
22
+
23
+ describe('dotnet/resources', () => {
24
+ it('returns empty for no services', () => {
25
+ primeEnumAliases([]);
26
+ expect(generateResources([], ctx)).toEqual([]);
27
+ });
28
+
29
+ it('generates a service class with methods', () => {
30
+ const models: Model[] = [
31
+ {
32
+ name: 'Organization',
33
+ fields: [
34
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
35
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
36
+ ],
37
+ },
38
+ ];
39
+
40
+ const services: Service[] = [
41
+ {
42
+ name: 'Organizations',
43
+ operations: [
44
+ {
45
+ name: 'getOrganization',
46
+ httpMethod: 'get',
47
+ path: '/organizations/{id}',
48
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
49
+ queryParams: [],
50
+ headerParams: [],
51
+ response: { kind: 'model', name: 'Organization' },
52
+ errors: [],
53
+ injectIdempotencyKey: false,
54
+ },
55
+ {
56
+ name: 'deleteOrganization',
57
+ httpMethod: 'delete',
58
+ path: '/organizations/{id}',
59
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
60
+ queryParams: [],
61
+ headerParams: [],
62
+ response: { kind: 'primitive', type: 'unknown' },
63
+ errors: [],
64
+ injectIdempotencyKey: false,
65
+ },
66
+ ],
67
+ },
68
+ ];
69
+
70
+ primeEnumAliases([]);
71
+ const ctxWithServices: EmitterContext = {
72
+ ...ctx,
73
+ spec: { ...emptySpec, services, models },
74
+ };
75
+
76
+ const files = generateResources(services, ctxWithServices);
77
+ expect(files.length).toBeGreaterThanOrEqual(1);
78
+
79
+ const serviceFile = files.find((f) => f.path.includes('OrganizationsService.cs'))!;
80
+ expect(serviceFile).toBeDefined();
81
+
82
+ const content = serviceFile.content;
83
+ // Namespace and class
84
+ expect(content).toContain('namespace WorkOS');
85
+ expect(content).toContain('public class OrganizationsService : Service');
86
+
87
+ // GET method
88
+ expect(content).toContain('GetAsync');
89
+ expect(content).toContain('async Task');
90
+
91
+ // DELETE method
92
+ expect(content).toContain('DeleteAsync');
93
+ });
94
+
95
+ it('generates options classes for operations with params', () => {
96
+ const models: Model[] = [
97
+ {
98
+ name: 'Organization',
99
+ fields: [
100
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
101
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
102
+ ],
103
+ },
104
+ {
105
+ name: 'CreateOrganizationRequest',
106
+ fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
107
+ },
108
+ ];
109
+
110
+ const services: Service[] = [
111
+ {
112
+ name: 'Organizations',
113
+ operations: [
114
+ {
115
+ name: 'createOrganization',
116
+ httpMethod: 'post',
117
+ path: '/organizations',
118
+ pathParams: [],
119
+ queryParams: [],
120
+ headerParams: [],
121
+ requestBody: { kind: 'model', name: 'CreateOrganizationRequest' },
122
+ response: { kind: 'model', name: 'Organization' },
123
+ errors: [],
124
+ injectIdempotencyKey: false,
125
+ },
126
+ ],
127
+ },
128
+ ];
129
+
130
+ primeEnumAliases([]);
131
+ const ctxWithServices: EmitterContext = {
132
+ ...ctx,
133
+ spec: { ...emptySpec, services, models },
134
+ };
135
+
136
+ const files = generateResources(services, ctxWithServices);
137
+ const optionsFile = files.find((f) => f.path.includes('Options.cs'))!;
138
+ expect(optionsFile).toBeDefined();
139
+
140
+ const content = optionsFile.content;
141
+ expect(content).toContain('Options');
142
+ expect(content).toContain('public string Name');
143
+ // Convention-based naming — no per-property JSON attributes
144
+ expect(content).not.toContain('[JsonProperty("name")]');
145
+ });
146
+
147
+ it('generates paginated list method with auto-pagination', () => {
148
+ const models: Model[] = [
149
+ {
150
+ name: 'Organization',
151
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
152
+ },
153
+ {
154
+ name: 'OrganizationList',
155
+ fields: [
156
+ {
157
+ name: 'data',
158
+ type: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
159
+ required: true,
160
+ },
161
+ {
162
+ name: 'list_metadata',
163
+ type: { kind: 'model', name: 'ListMetadata' },
164
+ required: true,
165
+ },
166
+ ],
167
+ },
168
+ ];
169
+
170
+ const services: Service[] = [
171
+ {
172
+ name: 'Organizations',
173
+ operations: [
174
+ {
175
+ name: 'listOrganizations',
176
+ httpMethod: 'get',
177
+ path: '/organizations',
178
+ pathParams: [],
179
+ queryParams: [
180
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
181
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
182
+ ],
183
+ headerParams: [],
184
+ response: { kind: 'model', name: 'OrganizationList' },
185
+ errors: [],
186
+ injectIdempotencyKey: false,
187
+ pagination: {
188
+ strategy: 'cursor',
189
+ param: 'after',
190
+ dataPath: 'data',
191
+ itemType: { kind: 'model', name: 'Organization' },
192
+ },
193
+ },
194
+ ],
195
+ },
196
+ ];
197
+
198
+ primeEnumAliases([]);
199
+ const ctxWithServices: EmitterContext = {
200
+ ...ctx,
201
+ spec: { ...emptySpec, services, models },
202
+ };
203
+
204
+ const files = generateResources(services, ctxWithServices);
205
+ const serviceFile = files.find((f) => f.path.includes('OrganizationsService.cs'))!;
206
+ const content = serviceFile.content;
207
+
208
+ // List method (return type is async Task)
209
+ expect(content).toContain('async Task<WorkOSList<Organization>>');
210
+ expect(content).toContain('ListAsync(');
211
+
212
+ // Auto-pagination method
213
+ expect(content).toContain('ListAutoPagingAsync');
214
+ expect(content).toContain('IAsyncEnumerable<Organization>');
215
+ });
216
+
217
+ it('generates deprecated operations with Obsolete attribute', () => {
218
+ const models: Model[] = [
219
+ {
220
+ name: 'Organization',
221
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
222
+ },
223
+ ];
224
+
225
+ const services: Service[] = [
226
+ {
227
+ name: 'Organizations',
228
+ operations: [
229
+ {
230
+ name: 'getOrganization',
231
+ httpMethod: 'get',
232
+ path: '/organizations/{id}',
233
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
234
+ queryParams: [],
235
+ headerParams: [],
236
+ response: { kind: 'model', name: 'Organization' },
237
+ errors: [],
238
+ injectIdempotencyKey: false,
239
+ deprecated: true,
240
+ },
241
+ ],
242
+ },
243
+ ];
244
+
245
+ primeEnumAliases([]);
246
+ const ctxWithServices: EmitterContext = {
247
+ ...ctx,
248
+ spec: { ...emptySpec, services, models },
249
+ };
250
+
251
+ const files = generateResources(services, ctxWithServices);
252
+ const serviceFile = files.find((f) => f.path.includes('OrganizationsService.cs'))!;
253
+
254
+ expect(serviceFile.content).toContain('[System.Obsolete');
255
+ });
256
+
257
+ it('generates parameter group abstract base + variant classes and query serialization', () => {
258
+ const models: Model[] = [
259
+ {
260
+ name: 'Authorization',
261
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
262
+ },
263
+ {
264
+ name: 'AuthorizationList',
265
+ fields: [
266
+ {
267
+ name: 'data',
268
+ type: { kind: 'array', items: { kind: 'model', name: 'Authorization' } },
269
+ required: true,
270
+ },
271
+ {
272
+ name: 'list_metadata',
273
+ type: { kind: 'model', name: 'ListMetadata' },
274
+ required: true,
275
+ },
276
+ ],
277
+ },
278
+ ];
279
+
280
+ const services: Service[] = [
281
+ {
282
+ name: 'Fga',
283
+ operations: [
284
+ {
285
+ name: 'listAuthorizations',
286
+ httpMethod: 'get',
287
+ path: '/fga/authorizations',
288
+ pathParams: [],
289
+ queryParams: [
290
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
291
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
292
+ { name: 'parent_resource_id', type: { kind: 'primitive', type: 'string' }, required: false },
293
+ { name: 'parent_resource_type_slug', type: { kind: 'primitive', type: 'string' }, required: false },
294
+ { name: 'parent_resource_external_id', type: { kind: 'primitive', type: 'string' }, required: false },
295
+ ],
296
+ headerParams: [],
297
+ response: { kind: 'model', name: 'AuthorizationList' },
298
+ errors: [],
299
+ injectIdempotencyKey: false,
300
+ pagination: {
301
+ strategy: 'cursor',
302
+ param: 'after',
303
+ dataPath: 'data',
304
+ itemType: { kind: 'model', name: 'Authorization' },
305
+ },
306
+ parameterGroups: [
307
+ {
308
+ name: 'parent_resource',
309
+ optional: false,
310
+ variants: [
311
+ {
312
+ name: 'by_id',
313
+ parameters: [
314
+ { name: 'parent_resource_id', type: { kind: 'primitive', type: 'string' }, required: true },
315
+ ],
316
+ },
317
+ {
318
+ name: 'by_external_id',
319
+ parameters: [
320
+ {
321
+ name: 'parent_resource_type_slug',
322
+ type: { kind: 'primitive', type: 'string' },
323
+ required: true,
324
+ },
325
+ {
326
+ name: 'parent_resource_external_id',
327
+ type: { kind: 'primitive', type: 'string' },
328
+ required: true,
329
+ },
330
+ ],
331
+ },
332
+ ],
333
+ },
334
+ ],
335
+ },
336
+ ],
337
+ },
338
+ ];
339
+
340
+ primeEnumAliases([]);
341
+ const ctxWithServices: EmitterContext = {
342
+ ...ctx,
343
+ spec: { ...emptySpec, services, models },
344
+ };
345
+
346
+ const files = generateResources(services, ctxWithServices);
347
+
348
+ // Options file should exist and contain group types
349
+ const optionsFile = files.find((f) => f.path.includes('Options.cs'))!;
350
+ expect(optionsFile).toBeDefined();
351
+ const optContent = optionsFile.content;
352
+
353
+ // Abstract base class (prefixed with service name)
354
+ expect(optContent).toContain('public abstract class FGAParentResource { }');
355
+
356
+ // Concrete variant: ById
357
+ expect(optContent).toContain('public class FGAParentResourceById : FGAParentResource');
358
+ expect(optContent).toContain('public string ParentResourceId { get; set; } = default!;');
359
+
360
+ // Concrete variant: ByExternalId
361
+ expect(optContent).toContain('public class FGAParentResourceByExternalId : FGAParentResource');
362
+ expect(optContent).toContain('public string ParentResourceTypeSlug { get; set; } = default!;');
363
+ expect(optContent).toContain('public string ParentResourceExternalId { get; set; } = default!;');
364
+
365
+ // Group property on options class with JsonIgnore
366
+ expect(optContent).toContain('[JsonIgnore]');
367
+ expect(optContent).toContain('[STJS.JsonIgnore]');
368
+ expect(optContent).toContain('public FGAParentResource ParentResource { get; set; } = default!;');
369
+
370
+ // Grouped params should NOT appear as individual properties
371
+ expect(optContent).not.toMatch(/\[JsonProperty\("parent_resource_id"\)\]/);
372
+ expect(optContent).not.toMatch(/\[JsonProperty\("parent_resource_type_slug"\)\]/);
373
+ expect(optContent).not.toMatch(/\[JsonProperty\("parent_resource_external_id"\)\]/);
374
+
375
+ // Service file should contain group query serialization
376
+ const serviceFile = files.find((f) => f.path.endsWith('Service.cs'))!;
377
+ expect(serviceFile).toBeDefined();
378
+ const svcContent = serviceFile.content;
379
+
380
+ // Pattern matching for group variants
381
+ expect(svcContent).toContain('ParentResourceById');
382
+ expect(svcContent).toContain('ParentResourceByExternalId');
383
+ expect(svcContent).toContain('AddQueryParam("parent_resource_id"');
384
+ expect(svcContent).toContain('AddQueryParam("parent_resource_type_slug"');
385
+ expect(svcContent).toContain('AddQueryParam("parent_resource_external_id"');
386
+ });
387
+ });