@workos/oagen-emitters 0.4.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 (105) 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 +8 -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-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 +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +10 -34
  21. package/src/dotnet/index.ts +6 -4
  22. package/src/dotnet/models.ts +58 -82
  23. package/src/dotnet/naming.ts +44 -6
  24. package/src/dotnet/resources.ts +350 -29
  25. package/src/dotnet/tests.ts +44 -24
  26. package/src/dotnet/type-map.ts +44 -17
  27. package/src/dotnet/wrappers.ts +21 -10
  28. package/src/go/client.ts +35 -3
  29. package/src/go/enums.ts +4 -0
  30. package/src/go/index.ts +10 -5
  31. package/src/go/models.ts +6 -1
  32. package/src/go/resources.ts +534 -73
  33. package/src/go/tests.ts +39 -3
  34. package/src/go/type-map.ts +8 -3
  35. package/src/go/wrappers.ts +79 -21
  36. package/src/index.ts +14 -0
  37. package/src/kotlin/client.ts +7 -2
  38. package/src/kotlin/enums.ts +30 -3
  39. package/src/kotlin/models.ts +97 -6
  40. package/src/kotlin/naming.ts +7 -1
  41. package/src/kotlin/resources.ts +370 -39
  42. package/src/kotlin/tests.ts +120 -6
  43. package/src/node/client.ts +38 -11
  44. package/src/node/field-plan.ts +12 -14
  45. package/src/node/fixtures.ts +39 -3
  46. package/src/node/models.ts +281 -37
  47. package/src/node/resources.ts +156 -52
  48. package/src/node/tests.ts +76 -27
  49. package/src/node/type-map.ts +1 -31
  50. package/src/node/utils.ts +96 -6
  51. package/src/node/wrappers.ts +31 -1
  52. package/src/php/models.ts +0 -33
  53. package/src/php/resources.ts +199 -18
  54. package/src/php/tests.ts +26 -2
  55. package/src/php/type-map.ts +16 -2
  56. package/src/php/wrappers.ts +6 -2
  57. package/src/plugin.ts +50 -0
  58. package/src/python/client.ts +13 -3
  59. package/src/python/enums.ts +28 -3
  60. package/src/python/index.ts +35 -27
  61. package/src/python/models.ts +138 -1
  62. package/src/python/resources.ts +234 -17
  63. package/src/python/tests.ts +260 -16
  64. package/src/python/type-map.ts +16 -2
  65. package/src/ruby/client.ts +238 -0
  66. package/src/ruby/enums.ts +149 -0
  67. package/src/ruby/index.ts +93 -0
  68. package/src/ruby/manifest.ts +35 -0
  69. package/src/ruby/models.ts +360 -0
  70. package/src/ruby/naming.ts +187 -0
  71. package/src/ruby/rbi.ts +313 -0
  72. package/src/ruby/resources.ts +799 -0
  73. package/src/ruby/tests.ts +459 -0
  74. package/src/ruby/type-map.ts +97 -0
  75. package/src/ruby/wrappers.ts +161 -0
  76. package/src/shared/model-utils.ts +131 -7
  77. package/src/shared/naming-utils.ts +36 -0
  78. package/src/shared/non-spec-services.ts +13 -0
  79. package/src/shared/resolved-ops.ts +75 -1
  80. package/test/dotnet/client.test.ts +2 -2
  81. package/test/dotnet/models.test.ts +7 -9
  82. package/test/dotnet/resources.test.ts +135 -3
  83. package/test/dotnet/tests.test.ts +5 -5
  84. package/test/entrypoint.test.ts +89 -0
  85. package/test/go/client.test.ts +6 -6
  86. package/test/go/resources.test.ts +156 -7
  87. package/test/kotlin/models.test.ts +1 -1
  88. package/test/kotlin/resources.test.ts +210 -0
  89. package/test/node/models.test.ts +134 -1
  90. package/test/node/resources.test.ts +134 -26
  91. package/test/node/utils.test.ts +140 -0
  92. package/test/php/models.test.ts +5 -4
  93. package/test/php/resources.test.ts +66 -1
  94. package/test/plugin.test.ts +50 -0
  95. package/test/python/client.test.ts +56 -0
  96. package/test/python/models.test.ts +99 -0
  97. package/test/python/resources.test.ts +294 -0
  98. package/test/python/tests.test.ts +91 -0
  99. package/test/ruby/client.test.ts +81 -0
  100. package/test/ruby/resources.test.ts +386 -0
  101. package/test/shared/resolved-ops.test.ts +122 -0
  102. package/tsdown.config.ts +1 -1
  103. package/dist/index.mjs.map +0 -1
  104. package/scripts/generate-php.js +0 -13
  105. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -139,8 +139,9 @@ describe('dotnet/resources', () => {
139
139
 
140
140
  const content = optionsFile.content;
141
141
  expect(content).toContain('Options');
142
- expect(content).toContain('[JsonProperty("name")]');
143
142
  expect(content).toContain('public string Name');
143
+ // Convention-based naming — no per-property JSON attributes
144
+ expect(content).not.toContain('[JsonProperty("name")]');
144
145
  });
145
146
 
146
147
  it('generates paginated list method with auto-pagination', () => {
@@ -204,9 +205,9 @@ describe('dotnet/resources', () => {
204
205
  const serviceFile = files.find((f) => f.path.includes('OrganizationsService.cs'))!;
205
206
  const content = serviceFile.content;
206
207
 
207
- // List method (public method name omits Async suffix; return type is async Task)
208
+ // List method (return type is async Task)
208
209
  expect(content).toContain('async Task<WorkOSList<Organization>>');
209
- expect(content).toContain('List(');
210
+ expect(content).toContain('ListAsync(');
210
211
 
211
212
  // Auto-pagination method
212
213
  expect(content).toContain('ListAutoPagingAsync');
@@ -252,4 +253,135 @@ describe('dotnet/resources', () => {
252
253
 
253
254
  expect(serviceFile.content).toContain('[System.Obsolete');
254
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
+ });
255
387
  });
@@ -105,15 +105,15 @@ describe('dotnet/tests', () => {
105
105
  const content = testFile.content;
106
106
 
107
107
  expect(content).toContain('TestError401');
108
- expect(content).toContain('AuthenticationError');
108
+ expect(content).toContain('AuthenticationException');
109
109
  expect(content).toContain('TestError404');
110
- expect(content).toContain('NotFoundError');
110
+ expect(content).toContain('NotFoundException');
111
111
  expect(content).toContain('TestError422');
112
- expect(content).toContain('UnprocessableEntityError');
112
+ expect(content).toContain('UnprocessableEntityException');
113
113
  expect(content).toContain('TestError429');
114
- expect(content).toContain('RateLimitExceededError');
114
+ expect(content).toContain('RateLimitExceededException');
115
115
  expect(content).toContain('TestError500');
116
- expect(content).toContain('ServerError');
116
+ expect(content).toContain('ServerException');
117
117
  });
118
118
 
119
119
  it('generates fixture JSON files', () => {
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ /**
4
+ * Verify the public entrypoint exports the plugin bundle
5
+ * and all intended direct symbols (emitters + extractors).
6
+ */
7
+ describe('public entrypoint (@workos/oagen-emitters)', () => {
8
+ it('exports workosEmittersPlugin', async () => {
9
+ const mod = await import('../src/index.js');
10
+ expect(mod.workosEmittersPlugin).toBeDefined();
11
+ expect(mod.workosEmittersPlugin.emitters).toBeDefined();
12
+ expect(mod.workosEmittersPlugin.extractors).toBeDefined();
13
+ expect(mod.workosEmittersPlugin.smokeRunners).toBeDefined();
14
+ });
15
+
16
+ it('exports all individual emitters', async () => {
17
+ const mod = await import('../src/index.js');
18
+ const expectedEmitters = [
19
+ 'nodeEmitter',
20
+ 'pythonEmitter',
21
+ 'phpEmitter',
22
+ 'goEmitter',
23
+ 'dotnetEmitter',
24
+ 'kotlinEmitter',
25
+ 'rubyEmitter',
26
+ ];
27
+ for (const name of expectedEmitters) {
28
+ expect(mod).toHaveProperty(name);
29
+ expect((mod as Record<string, unknown>)[name]).toHaveProperty('language');
30
+ }
31
+ });
32
+
33
+ it('exports all individual extractors', async () => {
34
+ const mod = await import('../src/index.js');
35
+ const expectedExtractors = [
36
+ 'nodeExtractor',
37
+ 'rubyExtractor',
38
+ 'pythonExtractor',
39
+ 'phpExtractor',
40
+ 'goExtractor',
41
+ 'rustExtractor',
42
+ 'kotlinExtractor',
43
+ 'dotnetExtractor',
44
+ 'elixirExtractor',
45
+ ];
46
+ for (const name of expectedExtractors) {
47
+ expect(mod).toHaveProperty(name);
48
+ expect((mod as Record<string, unknown>)[name]).toHaveProperty('language');
49
+ }
50
+ });
51
+
52
+ it('plugin bundle emitters match individual emitter exports', async () => {
53
+ const mod = await import('../src/index.js');
54
+ const pluginLanguages = mod.workosEmittersPlugin.emitters!.map((e) => e.language);
55
+ const directEmitters = [
56
+ mod.nodeEmitter,
57
+ mod.pythonEmitter,
58
+ mod.phpEmitter,
59
+ mod.goEmitter,
60
+ mod.dotnetEmitter,
61
+ mod.kotlinEmitter,
62
+ mod.rubyEmitter,
63
+ ];
64
+ for (const emitter of directEmitters) {
65
+ expect(pluginLanguages).toContain(emitter.language);
66
+ }
67
+ expect(pluginLanguages).toHaveLength(directEmitters.length);
68
+ });
69
+
70
+ it('plugin bundle extractors match individual extractor exports', async () => {
71
+ const mod = await import('../src/index.js');
72
+ const pluginLanguages = mod.workosEmittersPlugin.extractors!.map((e) => e.language);
73
+ const directExtractors = [
74
+ mod.nodeExtractor,
75
+ mod.rubyExtractor,
76
+ mod.pythonExtractor,
77
+ mod.phpExtractor,
78
+ mod.goExtractor,
79
+ mod.rustExtractor,
80
+ mod.kotlinExtractor,
81
+ mod.dotnetExtractor,
82
+ mod.elixirExtractor,
83
+ ];
84
+ for (const extractor of directExtractors) {
85
+ expect(pluginLanguages).toContain(extractor.language);
86
+ }
87
+ expect(pluginLanguages).toHaveLength(directExtractors.length);
88
+ });
89
+ });
@@ -55,9 +55,9 @@ describe('go/client', () => {
55
55
  const content = workosFile.content;
56
56
 
57
57
  expect(content).toContain('package workos');
58
- expect(content).toContain('organizations *organizationService');
58
+ expect(content).toContain('organizations *OrganizationService');
59
59
  expect(content).toContain('func NewClient(apiKey string, opts ...ClientOption) *Client {');
60
- expect(content).toContain('func (c *Client) Organizations() *organizationService {');
60
+ expect(content).toContain('func (c *Client) Organizations() *OrganizationService {');
61
61
  });
62
62
 
63
63
  it('does not emit static options or HTTP infrastructure', () => {
@@ -84,9 +84,9 @@ describe('go/client', () => {
84
84
  const workosFile = files.find((f) => f.path === 'workos.go')!;
85
85
  const content = workosFile.content;
86
86
 
87
- expect(content).toContain('apiKeys *apiKeyService');
88
- expect(content).toContain('sso *ssoService');
89
- expect(content).toContain('func (c *Client) APIKeys() *apiKeyService {');
90
- expect(content).toContain('func (c *Client) SSO() *ssoService {');
87
+ expect(content).toContain('apiKeys *APIKeyService');
88
+ expect(content).toContain('sso *SSOService');
89
+ expect(content).toContain('func (c *Client) APIKeys() *APIKeyService {');
90
+ expect(content).toContain('func (c *Client) SSO() *SSOService {');
91
91
  });
92
92
  });
@@ -114,12 +114,12 @@ describe('go/resources', () => {
114
114
  expect(files.length).toBeGreaterThanOrEqual(1);
115
115
  const content = files[0].content;
116
116
  expect(content).toContain('package workos');
117
- expect(content).toContain('type organizationService struct {');
117
+ expect(content).toContain('type OrganizationService struct {');
118
118
  expect(content).toContain('Limit *int `url:"limit,omitempty" json:"-"`');
119
- expect(content).toContain('func (s *organizationService) List(');
120
- expect(content).toContain('func (s *organizationService) Get(');
121
- expect(content).toContain('func (s *organizationService) Create(');
122
- expect(content).toContain('func (s *organizationService) Delete(');
119
+ expect(content).toContain('func (s *OrganizationService) List(');
120
+ expect(content).toContain('func (s *OrganizationService) Get(');
121
+ expect(content).toContain('func (s *OrganizationService) Create(');
122
+ expect(content).toContain('func (s *OrganizationService) Delete(');
123
123
  });
124
124
 
125
125
  it('generates path interpolation with fmt.Sprintf', () => {
@@ -139,7 +139,7 @@ describe('go/resources', () => {
139
139
  const spec = makeSpec(services);
140
140
  const files = generateResources(services, makeCtx(spec));
141
141
  const content = files[0].content;
142
- expect(content).toContain('fmt.Sprintf("/users/%s", id)');
142
+ expect(content).toContain('fmt.Sprintf("/users/%s", url.PathEscape(id))');
143
143
  });
144
144
 
145
145
  it('generates paginated methods returning Iterator', () => {
@@ -165,7 +165,7 @@ describe('go/resources', () => {
165
165
  const files = generateResources(services, makeCtx(spec));
166
166
  const content = files[0].content;
167
167
  expect(content).toContain('*Iterator[User]');
168
- expect(content).toContain('newIterator[User](ctx, s.client, "GET", "/users", nil, "after", "data", opts)');
168
+ expect(content).toContain('newIterator[User](ctx, s.client, "GET", "/users", nil, "after", "data", opts,');
169
169
  });
170
170
 
171
171
  it('generates delete methods returning error', () => {
@@ -405,4 +405,153 @@ describe('go/resources', () => {
405
405
  expect(content).toContain('Body interface{} `json:"-"`');
406
406
  expect(content).toContain('request(ctx, "POST", "/connect/applications", nil, params.Body, &result, opts)');
407
407
  });
408
+
409
+ describe('mutually-exclusive parameter groups', () => {
410
+ const groupedOp = makeOp({
411
+ name: 'listResources',
412
+ httpMethod: 'get',
413
+ path: '/authorization/organization_memberships/{organization_membership_id}/resources',
414
+ pathParams: [{ name: 'organization_membership_id', type: { kind: 'primitive', type: 'string' }, required: true }],
415
+ queryParams: [
416
+ { name: 'before', type: { kind: 'primitive', type: 'string' }, required: false },
417
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
418
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
419
+ { name: 'order', type: { kind: 'primitive', type: 'string' }, required: false },
420
+ { name: 'permission_slug', type: { kind: 'primitive', type: 'string' }, required: true },
421
+ { name: 'parent_resource_id', type: { kind: 'primitive', type: 'string' }, required: false },
422
+ { name: 'parent_resource_type_slug', type: { kind: 'primitive', type: 'string' }, required: false },
423
+ { name: 'parent_resource_external_id', type: { kind: 'primitive', type: 'string' }, required: false },
424
+ ],
425
+ parameterGroups: [
426
+ {
427
+ name: 'parent_resource',
428
+ optional: false,
429
+ variants: [
430
+ {
431
+ name: 'by_id',
432
+ parameters: [
433
+ { name: 'parent_resource_id', type: { kind: 'primitive', type: 'string' }, required: false },
434
+ ],
435
+ },
436
+ {
437
+ name: 'by_external_id',
438
+ parameters: [
439
+ { name: 'parent_resource_type_slug', type: { kind: 'primitive', type: 'string' }, required: false },
440
+ { name: 'parent_resource_external_id', type: { kind: 'primitive', type: 'string' }, required: false },
441
+ ],
442
+ },
443
+ ],
444
+ },
445
+ ],
446
+ pagination: {
447
+ strategy: 'cursor' as const,
448
+ param: 'after',
449
+ dataPath: 'data',
450
+ itemType: { kind: 'model' as const, name: 'AuthorizationResource' },
451
+ },
452
+ });
453
+
454
+ function makeGroupedServices(): Service[] {
455
+ return [{ name: 'Authorization', operations: [groupedOp] }];
456
+ }
457
+
458
+ it('generates a sealed interface for the parameter group', () => {
459
+ const services = makeGroupedServices();
460
+ const spec = makeSpec(services);
461
+ const files = generateResources(services, makeCtx(spec));
462
+ const content = files[0].content;
463
+
464
+ // Interface declaration with unexported marker + applyToQuery
465
+ expect(content).toContain('type AuthorizationParentResource interface {');
466
+ expect(content).toContain('isAuthorizationParentResource()');
467
+ expect(content).toContain('applyToQuery(url.Values)');
468
+ });
469
+
470
+ it('generates variant structs with shortened field names', () => {
471
+ const services = makeGroupedServices();
472
+ const spec = makeSpec(services);
473
+ const files = generateResources(services, makeCtx(spec));
474
+ const content = files[0].content;
475
+
476
+ // ByID variant
477
+ expect(content).toContain('type AuthorizationParentResourceByID struct {');
478
+ expect(content).toContain('\tID string');
479
+
480
+ // ByExternalID variant
481
+ expect(content).toContain('type AuthorizationParentResourceByExternalID struct {');
482
+ expect(content).toContain('\tTypeSlug string');
483
+ expect(content).toContain('\tExternalID string');
484
+ });
485
+
486
+ it('generates marker methods on each variant', () => {
487
+ const services = makeGroupedServices();
488
+ const spec = makeSpec(services);
489
+ const files = generateResources(services, makeCtx(spec));
490
+ const content = files[0].content;
491
+
492
+ expect(content).toContain('func (p AuthorizationParentResourceByID) isAuthorizationParentResource()');
493
+ expect(content).toContain('func (p AuthorizationParentResourceByExternalID) isAuthorizationParentResource()');
494
+ });
495
+
496
+ it('generates applyToQuery methods using original wire names', () => {
497
+ const services = makeGroupedServices();
498
+ const spec = makeSpec(services);
499
+ const files = generateResources(services, makeCtx(spec));
500
+ const content = files[0].content;
501
+
502
+ // ByID variant sets parent_resource_id
503
+ expect(content).toContain('func (p AuthorizationParentResourceByID) applyToQuery(v url.Values)');
504
+ expect(content).toContain('v.Set("parent_resource_id", p.ID)');
505
+
506
+ // ByExternalID variant sets both wire-name params
507
+ expect(content).toContain('func (p AuthorizationParentResourceByExternalID) applyToQuery(v url.Values)');
508
+ expect(content).toContain('v.Set("parent_resource_type_slug", p.TypeSlug)');
509
+ expect(content).toContain('v.Set("parent_resource_external_id", p.ExternalID)');
510
+ });
511
+
512
+ it('params struct uses group interface instead of flat pointers', () => {
513
+ const services = makeGroupedServices();
514
+ const spec = makeSpec(services);
515
+ const files = generateResources(services, makeCtx(spec));
516
+ const content = files[0].content;
517
+
518
+ // Should have the group field
519
+ expect(content).toContain('ParentResource AuthorizationParentResource `url:"-" json:"-"`');
520
+
521
+ // Should NOT have the flat pointer fields
522
+ expect(content).not.toMatch(/ParentResourceID\s+\*string/);
523
+ expect(content).not.toMatch(/ParentResourceTypeSlug\s+\*string/);
524
+ expect(content).not.toMatch(/ParentResourceExternalID\s+\*string/);
525
+
526
+ // Should still have non-grouped params
527
+ expect(content).toContain('PermissionSlug string');
528
+ expect(content).toContain('PaginationParams');
529
+ });
530
+
531
+ it('method body builds url.Values and calls applyToQuery', () => {
532
+ const services = makeGroupedServices();
533
+ const spec = makeSpec(services);
534
+ const files = generateResources(services, makeCtx(spec));
535
+ const content = files[0].content;
536
+
537
+ // Should build url.Values manually
538
+ expect(content).toContain('query := url.Values{}');
539
+ // Should encode the non-grouped required param
540
+ expect(content).toContain('query.Set("permission_slug", params.PermissionSlug)');
541
+ // Should call applyToQuery on the group
542
+ expect(content).toContain('params.ParentResource.applyToQuery(query)');
543
+ // Should pass query to the iterator (not params)
544
+ expect(content).toContain('newIterator[AuthorizationResource](ctx, s.client, "GET"');
545
+ expect(content).toContain(', query, "after", "data", opts, nil)');
546
+ });
547
+
548
+ it('imports net/url when parameter groups are present', () => {
549
+ const services = makeGroupedServices();
550
+ const spec = makeSpec(services);
551
+ const files = generateResources(services, makeCtx(spec));
552
+ const content = files[0].content;
553
+
554
+ expect(content).toContain('"net/url"');
555
+ });
556
+ });
408
557
  });
@@ -57,7 +57,7 @@ describe('kotlin/models', () => {
57
57
  const content = modelFile.content;
58
58
  expect(content).toContain('data class Organization');
59
59
  expect(content).toContain('@JsonProperty("id")');
60
- expect(content).toContain('@JvmField');
60
+ expect(content).not.toContain('@JvmField');
61
61
  expect(content).toContain('OffsetDateTime');
62
62
  expect(content).toContain('externalId: String?');
63
63
  });