@workos/oagen-emitters 0.3.0 → 0.4.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 (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateEnums } from '../../src/dotnet/enums.js';
3
+ import type { EmitterContext, ApiSpec, Enum, Service } from '@workos/oagen';
4
+ import { defaultSdkBehavior } from '@workos/oagen';
5
+
6
+ const emptySpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [],
11
+ models: [],
12
+ enums: [],
13
+ sdk: defaultSdkBehavior(),
14
+ };
15
+
16
+ const ctx: EmitterContext = {
17
+ namespace: 'workos',
18
+ namespacePascal: 'WorkOS',
19
+ spec: emptySpec,
20
+ };
21
+
22
+ describe('dotnet/enums', () => {
23
+ it('returns empty for no enums', () => {
24
+ expect(generateEnums([], ctx)).toEqual([]);
25
+ });
26
+
27
+ it('generates C# enum with EnumMember attributes', () => {
28
+ const enums: Enum[] = [
29
+ {
30
+ name: 'Status',
31
+ values: [
32
+ { name: 'ACTIVE', value: 'active' },
33
+ { name: 'INACTIVE', value: 'inactive' },
34
+ { name: 'PENDING', value: 'pending' },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ const service: Service = {
40
+ name: 'Organizations',
41
+ operations: [
42
+ {
43
+ name: 'getOrganization',
44
+ httpMethod: 'get',
45
+ path: '/organizations/{id}',
46
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
47
+ queryParams: [{ name: 'status', type: { kind: 'enum', name: 'Status' }, required: false }],
48
+ headerParams: [],
49
+ response: { kind: 'model', name: 'Organization' },
50
+ errors: [],
51
+ injectIdempotencyKey: false,
52
+ },
53
+ ],
54
+ };
55
+
56
+ const files = generateEnums(enums, {
57
+ ...ctx,
58
+ spec: { ...emptySpec, services: [service], enums },
59
+ });
60
+ expect(files.length).toBe(1);
61
+
62
+ const content = files[0].content;
63
+ expect(content).toContain('namespace WorkOS');
64
+ expect(content).toContain('public enum Status');
65
+ expect(content).toContain('[EnumMember(Value = "active")]');
66
+ expect(content).toContain('Active');
67
+ expect(content).toContain('[EnumMember(Value = "inactive")]');
68
+ expect(content).toContain('Inactive');
69
+ expect(content).toContain('[EnumMember(Value = "pending")]');
70
+ expect(content).toContain('Pending');
71
+ // Unknown sentinel for forward compatibility
72
+ expect(content).toContain('Unknown');
73
+ });
74
+
75
+ it('skips single-value enums (discriminator consts)', () => {
76
+ const enums: Enum[] = [
77
+ {
78
+ name: 'DiscriminatorType',
79
+ values: [{ name: 'ONLY_VALUE', value: 'only_value' }],
80
+ },
81
+ ];
82
+
83
+ const files = generateEnums(enums, {
84
+ ...ctx,
85
+ spec: { ...emptySpec, enums },
86
+ });
87
+ expect(files).toHaveLength(0);
88
+ });
89
+
90
+ it('deduplicates structurally identical enums', () => {
91
+ const enums: Enum[] = [
92
+ {
93
+ name: 'ConnectionType',
94
+ values: [
95
+ { name: 'SAML', value: 'saml' },
96
+ { name: 'OIDC', value: 'oidc' },
97
+ ],
98
+ },
99
+ {
100
+ name: 'ProfileConnectionType',
101
+ values: [
102
+ { name: 'SAML', value: 'saml' },
103
+ { name: 'OIDC', value: 'oidc' },
104
+ ],
105
+ },
106
+ ];
107
+
108
+ const service: Service = {
109
+ name: 'Test',
110
+ operations: [
111
+ {
112
+ name: 'test',
113
+ httpMethod: 'get',
114
+ path: '/test',
115
+ pathParams: [],
116
+ queryParams: [
117
+ { name: 'type', type: { kind: 'enum', name: 'ConnectionType' }, required: false },
118
+ { name: 'profile_type', type: { kind: 'enum', name: 'ProfileConnectionType' }, required: false },
119
+ ],
120
+ headerParams: [],
121
+ response: { kind: 'primitive', type: 'unknown' },
122
+ errors: [],
123
+ injectIdempotencyKey: false,
124
+ },
125
+ ],
126
+ };
127
+
128
+ const files = generateEnums(enums, {
129
+ ...ctx,
130
+ spec: { ...emptySpec, services: [service], enums },
131
+ });
132
+ // Only one enum file should be generated (the canonical)
133
+ expect(files).toHaveLength(1);
134
+ expect(files[0].content).toContain('ConnectionType');
135
+ });
136
+
137
+ it('skips orphan enums not referenced by models or operations', () => {
138
+ const enums: Enum[] = [
139
+ {
140
+ name: 'OrphanEnum',
141
+ values: [
142
+ { name: 'A', value: 'a' },
143
+ { name: 'B', value: 'b' },
144
+ ],
145
+ },
146
+ ];
147
+
148
+ const files = generateEnums(enums, {
149
+ ...ctx,
150
+ spec: { ...emptySpec, enums },
151
+ });
152
+ expect(files).toHaveLength(0);
153
+ });
154
+
155
+ it('generates deprecated enum values with Obsolete attribute', () => {
156
+ const enums: Enum[] = [
157
+ {
158
+ name: 'Status',
159
+ values: [
160
+ { name: 'ACTIVE', value: 'active' },
161
+ { name: 'OLD_STATUS', value: 'old_status', deprecated: true, description: 'Use ACTIVE instead' },
162
+ ],
163
+ },
164
+ ];
165
+
166
+ const service: Service = {
167
+ name: 'Test',
168
+ operations: [
169
+ {
170
+ name: 'test',
171
+ httpMethod: 'get',
172
+ path: '/test',
173
+ pathParams: [],
174
+ queryParams: [{ name: 'status', type: { kind: 'enum', name: 'Status' }, required: false }],
175
+ headerParams: [],
176
+ response: { kind: 'primitive', type: 'unknown' },
177
+ errors: [],
178
+ injectIdempotencyKey: false,
179
+ },
180
+ ],
181
+ };
182
+
183
+ const files = generateEnums(enums, {
184
+ ...ctx,
185
+ spec: { ...emptySpec, services: [service], enums },
186
+ });
187
+ expect(files).toHaveLength(1);
188
+ const content = files[0].content;
189
+
190
+ expect(content).toContain('[System.Obsolete');
191
+ expect(content).toContain('OldStatus');
192
+ });
193
+ });
@@ -0,0 +1,9 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { dotnetEmitter } from '../../src/dotnet/index.js';
3
+
4
+ describe('dotnet/errors', () => {
5
+ it('returns empty array (errors are hand-maintained in the target SDK)', () => {
6
+ const files = dotnetEmitter.generateErrors!({} as any);
7
+ expect(files).toHaveLength(0);
8
+ });
9
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateManifest } from '../../src/dotnet/manifest.js';
3
+ import type { ApiSpec, EmitterContext, Service, Model } from '@workos/oagen';
4
+ import { defaultSdkBehavior } from '@workos/oagen';
5
+
6
+ const models: Model[] = [
7
+ {
8
+ name: 'Organization',
9
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
10
+ },
11
+ ];
12
+
13
+ const services: Service[] = [
14
+ {
15
+ name: 'Organizations',
16
+ operations: [
17
+ {
18
+ name: 'listOrganizations',
19
+ httpMethod: 'get',
20
+ path: '/organizations',
21
+ pathParams: [],
22
+ queryParams: [],
23
+ headerParams: [],
24
+ response: { kind: 'model', name: 'Organization' },
25
+ errors: [],
26
+ injectIdempotencyKey: false,
27
+ },
28
+ ],
29
+ },
30
+ {
31
+ name: 'OrganizationsApiKeys',
32
+ operations: [
33
+ {
34
+ name: 'listOrganizationApiKeys',
35
+ httpMethod: 'get',
36
+ path: '/organizations/api_keys',
37
+ pathParams: [],
38
+ queryParams: [],
39
+ headerParams: [],
40
+ response: { kind: 'model', name: 'Organization' },
41
+ errors: [],
42
+ injectIdempotencyKey: false,
43
+ },
44
+ ],
45
+ },
46
+ ];
47
+
48
+ const spec: ApiSpec = {
49
+ name: 'TestAPI',
50
+ version: '1.0.0',
51
+ baseUrl: 'https://api.workos.com',
52
+ services,
53
+ models,
54
+ enums: [],
55
+ sdk: defaultSdkBehavior(),
56
+ };
57
+
58
+ const ctx: EmitterContext = {
59
+ namespace: 'workos',
60
+ namespacePascal: 'WorkOS',
61
+ spec,
62
+ };
63
+
64
+ describe('dotnet/manifest', () => {
65
+ it('generates smoke-manifest.json', () => {
66
+ const files = generateManifest(spec, ctx);
67
+ expect(files).toHaveLength(1);
68
+ expect(files[0].path).toBe('smoke-manifest.json');
69
+ });
70
+
71
+ it('maps HTTP operations to SDK method names and services', () => {
72
+ const files = generateManifest(spec, ctx);
73
+ const manifest = JSON.parse(files[0].content) as Record<string, { sdkMethod: string; service: string }>;
74
+
75
+ expect(manifest['GET /organizations']).toBeDefined();
76
+ expect(manifest['GET /organizations'].sdkMethod).toBeDefined();
77
+ expect(manifest['GET /organizations'].service).toBeDefined();
78
+
79
+ expect(manifest['GET /organizations/api_keys']).toBeDefined();
80
+ expect(manifest['GET /organizations/api_keys'].sdkMethod).toBeDefined();
81
+ });
82
+ });
@@ -0,0 +1,260 @@
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 with JSON attributes
84
+ expect(content).toContain('[JsonProperty("id")]');
85
+ expect(content).toContain('public string Id');
86
+ expect(content).toContain('[JsonProperty("name")]');
87
+ expect(content).toContain('public string Name');
88
+
89
+ // DateTime field
90
+ expect(content).toContain('DateTimeOffset');
91
+ expect(content).toContain('[JsonProperty("created_at")]');
92
+
93
+ // Optional/nullable field
94
+ expect(content).toContain('[JsonProperty("external_id")]');
95
+ });
96
+
97
+ it('skips list wrapper and list metadata models', () => {
98
+ const models: Model[] = [
99
+ {
100
+ name: 'Organization',
101
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
102
+ },
103
+ {
104
+ name: 'OrganizationList',
105
+ fields: [
106
+ {
107
+ name: 'data',
108
+ type: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
109
+ required: true,
110
+ },
111
+ {
112
+ name: 'list_metadata',
113
+ type: { kind: 'model', name: 'ListMetadata' },
114
+ required: true,
115
+ },
116
+ ],
117
+ },
118
+ {
119
+ name: 'ListMetadata',
120
+ fields: [
121
+ { name: 'before', type: { kind: 'primitive', type: 'string' }, required: false },
122
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
123
+ ],
124
+ },
125
+ ];
126
+
127
+ primeEnumAliases([]);
128
+ const files = generateModels(models, {
129
+ ...ctx,
130
+ spec: { ...emptySpec, models },
131
+ });
132
+ const filePaths = files.map((f) => f.path);
133
+
134
+ // Should generate Organization but NOT OrganizationList or ListMetadata
135
+ expect(filePaths.some((p) => p.includes('Organization.cs') && !p.includes('List'))).toBe(true);
136
+ expect(filePaths.some((p) => p.includes('OrganizationList.cs'))).toBe(false);
137
+ expect(filePaths.some((p) => p.includes('ListMetadata.cs'))).toBe(false);
138
+ });
139
+
140
+ it('deduplicates structurally identical models', () => {
141
+ const models: Model[] = [
142
+ {
143
+ name: 'OrganizationDomain',
144
+ fields: [
145
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
146
+ { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true },
147
+ ],
148
+ },
149
+ {
150
+ name: 'OrganizationDomainStandAlone',
151
+ fields: [
152
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
153
+ { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true },
154
+ ],
155
+ },
156
+ ];
157
+
158
+ primeEnumAliases([]);
159
+ const files = generateModels(models, {
160
+ ...ctx,
161
+ spec: { ...emptySpec, models },
162
+ });
163
+
164
+ // Canonical model should have a full class
165
+ const canonicalFile = files.find(
166
+ (f) => f.path.includes('OrganizationDomain.cs') && !f.path.includes('StandAlone'),
167
+ )!;
168
+ expect(canonicalFile).toBeDefined();
169
+ expect(canonicalFile.content).toContain('public class OrganizationDomain');
170
+
171
+ // Alias model should be a subclass of canonical
172
+ const aliasFile = files.find((f) => f.path.includes('OrganizationDomainStandAlone.cs'))!;
173
+ expect(aliasFile).toBeDefined();
174
+ expect(aliasFile.content).toContain('OrganizationDomain');
175
+ });
176
+
177
+ it('emits [System.Obsolete] for deprecated fields', () => {
178
+ const models: Model[] = [
179
+ {
180
+ name: 'Organization',
181
+ fields: [
182
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
183
+ {
184
+ name: 'old_field',
185
+ type: { kind: 'primitive', type: 'string' },
186
+ required: true,
187
+ deprecated: true,
188
+ description: 'Legacy field',
189
+ },
190
+ ],
191
+ },
192
+ ];
193
+
194
+ primeEnumAliases([]);
195
+ const files = generateModels(models, {
196
+ ...ctx,
197
+ spec: { ...emptySpec, models },
198
+ });
199
+ const modelFile = files.find((f) => f.path.includes('Organization.cs'))!;
200
+
201
+ expect(modelFile.content).toContain('[System.Obsolete');
202
+ });
203
+
204
+ it('handles map fields', () => {
205
+ const models: Model[] = [
206
+ {
207
+ name: 'Organization',
208
+ fields: [
209
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
210
+ {
211
+ name: 'metadata',
212
+ type: { kind: 'map', valueType: { kind: 'primitive', type: 'string' } },
213
+ required: false,
214
+ },
215
+ ],
216
+ },
217
+ ];
218
+
219
+ primeEnumAliases([]);
220
+ const files = generateModels(models, {
221
+ ...ctx,
222
+ spec: { ...emptySpec, models },
223
+ });
224
+ const modelFile = files.find((f) => f.path.includes('Organization.cs'))!;
225
+
226
+ expect(modelFile.content).toContain('Dictionary<string,');
227
+ });
228
+
229
+ it('handles array fields with model refs', () => {
230
+ const models: Model[] = [
231
+ {
232
+ name: 'Organization',
233
+ fields: [
234
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
235
+ {
236
+ name: 'domains',
237
+ type: { kind: 'array', items: { kind: 'model', name: 'OrganizationDomain' } },
238
+ required: true,
239
+ },
240
+ ],
241
+ },
242
+ {
243
+ name: 'OrganizationDomain',
244
+ fields: [
245
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
246
+ { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true },
247
+ ],
248
+ },
249
+ ];
250
+
251
+ primeEnumAliases([]);
252
+ const files = generateModels(models, {
253
+ ...ctx,
254
+ spec: { ...emptySpec, models },
255
+ });
256
+ const orgFile = files.find((f) => f.path.includes('Organization.cs') && !f.path.includes('Domain'))!;
257
+ expect(orgFile).toBeDefined();
258
+ expect(orgFile.content).toContain('List<OrganizationDomain>');
259
+ });
260
+ });