@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,202 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateTests } from '../../src/dotnet/tests.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 models: Model[] = [
8
+ {
9
+ name: 'Organization',
10
+ fields: [
11
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
12
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
13
+ ],
14
+ },
15
+ ];
16
+
17
+ const services: Service[] = [
18
+ {
19
+ name: 'Organizations',
20
+ operations: [
21
+ {
22
+ name: 'getOrganization',
23
+ httpMethod: 'get',
24
+ path: '/organizations/{id}',
25
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
26
+ queryParams: [],
27
+ headerParams: [],
28
+ response: { kind: 'model', name: 'Organization' },
29
+ errors: [],
30
+ injectIdempotencyKey: false,
31
+ },
32
+ {
33
+ name: 'deleteOrganization',
34
+ httpMethod: 'delete',
35
+ path: '/organizations/{id}',
36
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
37
+ queryParams: [],
38
+ headerParams: [],
39
+ response: { kind: 'primitive', type: 'unknown' },
40
+ errors: [],
41
+ injectIdempotencyKey: false,
42
+ },
43
+ ],
44
+ },
45
+ ];
46
+
47
+ const spec: ApiSpec = {
48
+ name: 'TestAPI',
49
+ version: '1.0.0',
50
+ baseUrl: 'https://api.workos.com',
51
+ services,
52
+ models,
53
+ enums: [],
54
+ sdk: defaultSdkBehavior(),
55
+ };
56
+
57
+ const ctx: EmitterContext = {
58
+ namespace: 'workos',
59
+ namespacePascal: 'WorkOS',
60
+ spec,
61
+ };
62
+
63
+ describe('dotnet/tests', () => {
64
+ it('generates per-service test files', () => {
65
+ primeEnumAliases([]);
66
+ const files = generateTests(spec, ctx);
67
+ const testFile = files.find((f) => f.path === 'Tests/OrganizationsServiceTest.cs');
68
+ expect(testFile).toBeDefined();
69
+
70
+ const content = testFile!.content;
71
+ expect(content).toContain('namespace WorkOSTests');
72
+ expect(content).toContain('public class OrganizationsServiceTest');
73
+ expect(content).toContain('HttpMock');
74
+ expect(content).toContain('[Fact]');
75
+ });
76
+
77
+ it('generates GET operation test with fixture', () => {
78
+ primeEnumAliases([]);
79
+ const files = generateTests(spec, ctx);
80
+ const testFile = files.find((f) => f.path === 'Tests/OrganizationsServiceTest.cs')!;
81
+ const content = testFile.content;
82
+
83
+ expect(content).toContain('TestGet');
84
+ expect(content).toContain('ReadAllText');
85
+ expect(content).toContain('MockResponse');
86
+ expect(content).toContain('Assert.NotNull(result)');
87
+ expect(content).toContain('AssertRequestWasMade');
88
+ });
89
+
90
+ it('generates DELETE operation test', () => {
91
+ primeEnumAliases([]);
92
+ const files = generateTests(spec, ctx);
93
+ const testFile = files.find((f) => f.path === 'Tests/OrganizationsServiceTest.cs')!;
94
+ const content = testFile.content;
95
+
96
+ expect(content).toContain('TestDelete');
97
+ expect(content).toContain('HttpMethod.Delete');
98
+ expect(content).toContain('NoContent');
99
+ });
100
+
101
+ it('generates error tests (401, 404, 422, 429, 500)', () => {
102
+ primeEnumAliases([]);
103
+ const files = generateTests(spec, ctx);
104
+ const testFile = files.find((f) => f.path === 'Tests/OrganizationsServiceTest.cs')!;
105
+ const content = testFile.content;
106
+
107
+ expect(content).toContain('TestError401');
108
+ expect(content).toContain('AuthenticationException');
109
+ expect(content).toContain('TestError404');
110
+ expect(content).toContain('NotFoundException');
111
+ expect(content).toContain('TestError422');
112
+ expect(content).toContain('UnprocessableEntityException');
113
+ expect(content).toContain('TestError429');
114
+ expect(content).toContain('RateLimitExceededException');
115
+ expect(content).toContain('TestError500');
116
+ expect(content).toContain('ServerException');
117
+ });
118
+
119
+ it('generates fixture JSON files', () => {
120
+ primeEnumAliases([]);
121
+ const files = generateTests(spec, ctx);
122
+ const fixture = files.find((f) => f.path === 'testdata/organization.json');
123
+ expect(fixture).toBeDefined();
124
+ expect(fixture!.headerPlacement).toBe('skip');
125
+
126
+ const data = JSON.parse(fixture!.content);
127
+ expect(data).toHaveProperty('id');
128
+ expect(data).toHaveProperty('name');
129
+ });
130
+
131
+ it('does not generate static test infrastructure', () => {
132
+ primeEnumAliases([]);
133
+ const files = generateTests(spec, ctx);
134
+ const paths = files.map((f) => f.path);
135
+
136
+ // HttpMock and other static helpers are @oagen-ignore-file in target SDK
137
+ expect(paths.find((p) => p.includes('HttpMock'))).toBeUndefined();
138
+ expect(paths.find((p) => p.includes('WorkOSClientTest'))).toBeUndefined();
139
+ });
140
+
141
+ it('generates auto-pagination tests for paginated operations', () => {
142
+ const paginatedModels: Model[] = [
143
+ ...models,
144
+ {
145
+ name: 'OrganizationList',
146
+ fields: [
147
+ {
148
+ name: 'data',
149
+ type: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
150
+ required: true,
151
+ },
152
+ {
153
+ name: 'list_metadata',
154
+ type: { kind: 'model', name: 'ListMetadata' },
155
+ required: true,
156
+ },
157
+ ],
158
+ },
159
+ ];
160
+
161
+ const paginatedServices: Service[] = [
162
+ {
163
+ name: 'Organizations',
164
+ operations: [
165
+ {
166
+ name: 'listOrganizations',
167
+ httpMethod: 'get',
168
+ path: '/organizations',
169
+ pathParams: [],
170
+ queryParams: [],
171
+ headerParams: [],
172
+ response: { kind: 'model', name: 'OrganizationList' },
173
+ errors: [],
174
+ injectIdempotencyKey: false,
175
+ pagination: {
176
+ strategy: 'cursor',
177
+ param: 'after',
178
+ dataPath: 'data',
179
+ itemType: { kind: 'model', name: 'Organization' },
180
+ },
181
+ },
182
+ ],
183
+ },
184
+ ];
185
+
186
+ const paginatedSpec: ApiSpec = {
187
+ ...spec,
188
+ services: paginatedServices,
189
+ models: paginatedModels,
190
+ };
191
+
192
+ primeEnumAliases([]);
193
+ const files = generateTests(paginatedSpec, { ...ctx, spec: paginatedSpec });
194
+ const testFile = files.find((f) => f.path === 'Tests/OrganizationsServiceTest.cs')!;
195
+ const content = testFile.content;
196
+
197
+ // Auto-paging test
198
+ expect(content).toContain('AutoPagingAsync');
199
+ expect(content).toContain('MockSequentialResponses');
200
+ expect(content).toContain('await foreach');
201
+ });
202
+ });
@@ -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
  });
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateModels } from '../../src/kotlin/models.js';
3
+ import { generateEnums } from '../../src/kotlin/enums.js';
4
+ import type { EmitterContext, ApiSpec, 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('kotlin/models', () => {
24
+ it('returns empty for no models', () => {
25
+ generateEnums([], ctx);
26
+ expect(generateModels([], ctx)).toEqual([]);
27
+ });
28
+
29
+ it('generates a Kotlin data class with Jackson annotations', () => {
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
+ name: 'created_at',
38
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
39
+ required: true,
40
+ },
41
+ {
42
+ name: 'external_id',
43
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
44
+ required: false,
45
+ },
46
+ ],
47
+ },
48
+ ];
49
+
50
+ generateEnums([], ctx);
51
+ const files = generateModels(models, { ...ctx, spec: { ...emptySpec, models } });
52
+
53
+ expect(files.length).toBeGreaterThanOrEqual(1);
54
+ const modelFile = files.find((f) => f.path.includes('Organization.kt'))!;
55
+ expect(modelFile).toBeDefined();
56
+
57
+ const content = modelFile.content;
58
+ expect(content).toContain('data class Organization');
59
+ expect(content).toContain('@JsonProperty("id")');
60
+ expect(content).not.toContain('@JvmField');
61
+ expect(content).toContain('OffsetDateTime');
62
+ expect(content).toContain('externalId: String?');
63
+ });
64
+
65
+ it('skips list wrapper and list metadata models', () => {
66
+ const models: Model[] = [
67
+ {
68
+ name: 'Organization',
69
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
70
+ },
71
+ {
72
+ name: 'OrganizationList',
73
+ fields: [
74
+ {
75
+ name: 'data',
76
+ type: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
77
+ required: true,
78
+ },
79
+ {
80
+ name: 'list_metadata',
81
+ type: { kind: 'model', name: 'ListMetadata' },
82
+ required: true,
83
+ },
84
+ ],
85
+ },
86
+ {
87
+ name: 'ListMetadata',
88
+ fields: [
89
+ { name: 'before', type: { kind: 'primitive', type: 'string' }, required: false },
90
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
91
+ ],
92
+ },
93
+ ];
94
+
95
+ generateEnums([], ctx);
96
+ const files = generateModels(models, { ...ctx, spec: { ...emptySpec, models } });
97
+ const filePaths = files.map((f) => f.path);
98
+
99
+ expect(filePaths.some((p) => p.includes('Organization.kt') && !p.includes('List'))).toBe(true);
100
+ expect(filePaths.some((p) => p.includes('OrganizationList.kt'))).toBe(false);
101
+ expect(filePaths.some((p) => p.includes('ListMetadata.kt'))).toBe(false);
102
+ });
103
+
104
+ it('deduplicates structurally identical models preferring shorter names', () => {
105
+ const models: Model[] = [
106
+ {
107
+ name: 'EmailChangeConfirmationUser',
108
+ fields: [
109
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
110
+ { name: 'email', type: { kind: 'primitive', type: 'string' }, required: true },
111
+ ],
112
+ },
113
+ {
114
+ name: 'User',
115
+ fields: [
116
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
117
+ { name: 'email', type: { kind: 'primitive', type: 'string' }, required: true },
118
+ ],
119
+ },
120
+ ];
121
+
122
+ generateEnums([], ctx);
123
+ const files = generateModels(models, { ...ctx, spec: { ...emptySpec, models } });
124
+
125
+ // User should be the canonical (shorter name) — a data class
126
+ const userFile = files.find((f) => f.path.includes('/User.kt'))!;
127
+ expect(userFile).toBeDefined();
128
+ expect(userFile.content).toContain('data class User');
129
+
130
+ // EmailChangeConfirmationUser should be the typealias
131
+ const aliasFile = files.find((f) => f.path.includes('/EmailChangeConfirmationUser.kt'))!;
132
+ expect(aliasFile).toBeDefined();
133
+ expect(aliasFile.content).toContain('typealias EmailChangeConfirmationUser = User');
134
+ });
135
+ });