@workos/oagen-emitters 0.0.1

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 (73) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.github/workflows/lint-pr-title.yml +16 -0
  3. package/.github/workflows/lint.yml +21 -0
  4. package/.github/workflows/release-please.yml +28 -0
  5. package/.github/workflows/release.yml +32 -0
  6. package/.husky/commit-msg +1 -0
  7. package/.husky/pre-commit +1 -0
  8. package/.husky/pre-push +1 -0
  9. package/.node-version +1 -0
  10. package/.oxfmtrc.json +10 -0
  11. package/.oxlintrc.json +29 -0
  12. package/.vscode/settings.json +11 -0
  13. package/LICENSE.txt +21 -0
  14. package/README.md +123 -0
  15. package/commitlint.config.ts +1 -0
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.js +2158 -0
  18. package/docs/endpoint-coverage.md +275 -0
  19. package/docs/sdk-architecture/node.md +355 -0
  20. package/oagen.config.ts +51 -0
  21. package/package.json +83 -0
  22. package/renovate.json +26 -0
  23. package/smoke/sdk-dotnet.ts +903 -0
  24. package/smoke/sdk-elixir.ts +771 -0
  25. package/smoke/sdk-go.ts +948 -0
  26. package/smoke/sdk-kotlin.ts +799 -0
  27. package/smoke/sdk-node.ts +516 -0
  28. package/smoke/sdk-php.ts +699 -0
  29. package/smoke/sdk-python.ts +738 -0
  30. package/smoke/sdk-ruby.ts +723 -0
  31. package/smoke/sdk-rust.ts +774 -0
  32. package/src/compat/extractors/dotnet.ts +8 -0
  33. package/src/compat/extractors/elixir.ts +8 -0
  34. package/src/compat/extractors/go.ts +8 -0
  35. package/src/compat/extractors/kotlin.ts +8 -0
  36. package/src/compat/extractors/node.ts +8 -0
  37. package/src/compat/extractors/php.ts +8 -0
  38. package/src/compat/extractors/python.ts +8 -0
  39. package/src/compat/extractors/ruby.ts +8 -0
  40. package/src/compat/extractors/rust.ts +8 -0
  41. package/src/index.ts +1 -0
  42. package/src/node/client.ts +356 -0
  43. package/src/node/common.ts +203 -0
  44. package/src/node/config.ts +70 -0
  45. package/src/node/enums.ts +87 -0
  46. package/src/node/errors.ts +205 -0
  47. package/src/node/fixtures.ts +139 -0
  48. package/src/node/index.ts +57 -0
  49. package/src/node/manifest.ts +23 -0
  50. package/src/node/models.ts +323 -0
  51. package/src/node/naming.ts +96 -0
  52. package/src/node/resources.ts +380 -0
  53. package/src/node/serializers.ts +286 -0
  54. package/src/node/tests.ts +336 -0
  55. package/src/node/type-map.ts +56 -0
  56. package/src/node/utils.ts +164 -0
  57. package/test/compat/extractors/node.test.ts +145 -0
  58. package/test/fixtures/sample-sdk-node/package.json +7 -0
  59. package/test/fixtures/sample-sdk-node/src/client.ts +24 -0
  60. package/test/fixtures/sample-sdk-node/src/index.ts +4 -0
  61. package/test/fixtures/sample-sdk-node/src/models.ts +28 -0
  62. package/test/fixtures/sample-sdk-node/tsconfig.json +13 -0
  63. package/test/node/client.test.ts +165 -0
  64. package/test/node/enums.test.ts +128 -0
  65. package/test/node/errors.test.ts +65 -0
  66. package/test/node/models.test.ts +301 -0
  67. package/test/node/naming.test.ts +212 -0
  68. package/test/node/resources.test.ts +260 -0
  69. package/test/node/serializers.test.ts +206 -0
  70. package/test/node/type-map.test.ts +127 -0
  71. package/tsconfig.json +20 -0
  72. package/tsup.config.ts +8 -0
  73. package/vitest.config.ts +4 -0
@@ -0,0 +1,301 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateModels } from '../../src/node/models.js';
3
+ import type { EmitterContext, ApiSpec, Model, Service } from '@workos/oagen';
4
+
5
+ const emptySpec: ApiSpec = {
6
+ name: 'Test',
7
+ version: '1.0.0',
8
+ baseUrl: '',
9
+ services: [],
10
+ models: [],
11
+ enums: [],
12
+ };
13
+
14
+ const ctx: EmitterContext = {
15
+ namespace: 'workos',
16
+ namespacePascal: 'WorkOS',
17
+ spec: emptySpec,
18
+ irVersion: 6,
19
+ };
20
+
21
+ describe('generateModels', () => {
22
+ it('returns empty for no models', () => {
23
+ expect(generateModels([], ctx)).toEqual([]);
24
+ });
25
+
26
+ it('generates domain and response interfaces for a model', () => {
27
+ const service: Service = {
28
+ name: 'Organizations',
29
+ operations: [
30
+ {
31
+ name: 'getOrganization',
32
+ httpMethod: 'get',
33
+ path: '/organizations/{id}',
34
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
35
+ queryParams: [],
36
+ headerParams: [],
37
+ response: { kind: 'model', name: 'Organization' },
38
+ errors: [],
39
+ injectIdempotencyKey: false,
40
+ },
41
+ ],
42
+ };
43
+
44
+ const models: Model[] = [
45
+ {
46
+ name: 'Organization',
47
+ fields: [
48
+ {
49
+ name: 'id',
50
+ type: { kind: 'primitive', type: 'string' },
51
+ required: true,
52
+ },
53
+ {
54
+ name: 'name',
55
+ type: { kind: 'primitive', type: 'string' },
56
+ required: true,
57
+ },
58
+ {
59
+ name: 'created_at',
60
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
61
+ required: true,
62
+ },
63
+ {
64
+ name: 'external_id',
65
+ type: {
66
+ kind: 'nullable',
67
+ inner: { kind: 'primitive', type: 'string' },
68
+ },
69
+ required: false,
70
+ },
71
+ ],
72
+ },
73
+ ];
74
+
75
+ const ctxWithServices: EmitterContext = {
76
+ ...ctx,
77
+ spec: { ...emptySpec, services: [service], models },
78
+ };
79
+
80
+ const files = generateModels(models, ctxWithServices);
81
+ expect(files.length).toBe(1);
82
+ expect(files[0].path).toBe('src/organizations/interfaces/organization.interface.ts');
83
+
84
+ // Domain interface has camelCase fields
85
+ expect(files[0].content).toContain('export interface Organization {');
86
+ expect(files[0].content).toContain(' id: string;');
87
+ expect(files[0].content).toContain(' name: string;');
88
+ expect(files[0].content).toContain(' createdAt: string;');
89
+ expect(files[0].content).toContain(' externalId?: string | null;');
90
+
91
+ // Response interface has snake_case fields
92
+ expect(files[0].content).toContain('export interface OrganizationResponse {');
93
+ expect(files[0].content).toContain(' created_at: string;');
94
+ expect(files[0].content).toContain(' external_id?: string | null;');
95
+ });
96
+
97
+ it('generates imports for referenced models', () => {
98
+ const service: Service = {
99
+ name: 'Organizations',
100
+ operations: [
101
+ {
102
+ name: 'getOrganization',
103
+ httpMethod: 'get',
104
+ path: '/organizations/{id}',
105
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
106
+ queryParams: [],
107
+ headerParams: [],
108
+ response: { kind: 'model', name: 'Organization' },
109
+ errors: [],
110
+ injectIdempotencyKey: false,
111
+ },
112
+ ],
113
+ };
114
+
115
+ const models: Model[] = [
116
+ {
117
+ name: 'Organization',
118
+ fields: [
119
+ {
120
+ name: 'id',
121
+ type: { kind: 'primitive', type: 'string' },
122
+ required: true,
123
+ },
124
+ {
125
+ name: 'domains',
126
+ type: {
127
+ kind: 'array',
128
+ items: { kind: 'model', name: 'OrganizationDomain' },
129
+ },
130
+ required: true,
131
+ },
132
+ ],
133
+ },
134
+ {
135
+ name: 'OrganizationDomain',
136
+ fields: [
137
+ {
138
+ name: 'id',
139
+ type: { kind: 'primitive', type: 'string' },
140
+ required: true,
141
+ },
142
+ {
143
+ name: 'domain',
144
+ type: { kind: 'primitive', type: 'string' },
145
+ required: true,
146
+ },
147
+ ],
148
+ },
149
+ ];
150
+
151
+ const ctxWithServices: EmitterContext = {
152
+ ...ctx,
153
+ spec: { ...emptySpec, services: [service], models },
154
+ };
155
+
156
+ const files = generateModels(models, ctxWithServices);
157
+
158
+ // Organization file should import OrganizationDomain
159
+ const orgFile = files.find((f) => f.path.includes('organization.interface.ts'))!;
160
+ expect(orgFile.content).toContain(
161
+ "import type { OrganizationDomain, OrganizationDomainResponse } from './organization-domain.interface';",
162
+ );
163
+
164
+ // Domain interface uses OrganizationDomain[]
165
+ expect(orgFile.content).toContain(' domains: OrganizationDomain[];');
166
+
167
+ // Response interface uses OrganizationDomainResponse[]
168
+ expect(orgFile.content).toContain(' domains: OrganizationDomainResponse[];');
169
+ });
170
+
171
+ it('handles generic type params', () => {
172
+ const models: Model[] = [
173
+ {
174
+ name: 'DirectoryUser',
175
+ typeParams: [
176
+ {
177
+ name: 'TCustom',
178
+ default: {
179
+ kind: 'map',
180
+ valueType: { kind: 'primitive', type: 'unknown' },
181
+ },
182
+ },
183
+ ],
184
+ fields: [
185
+ {
186
+ name: 'id',
187
+ type: { kind: 'primitive', type: 'string' },
188
+ required: true,
189
+ },
190
+ ],
191
+ },
192
+ ];
193
+
194
+ const files = generateModels(models, ctx);
195
+ expect(files[0].content).toContain('export interface DirectoryUser<TCustom = Record<string, any>> {');
196
+ expect(files[0].content).toContain('export interface DirectoryUserResponse<TCustom = Record<string, any>> {');
197
+ });
198
+
199
+ it('uses Wire suffix for models already ending in Response', () => {
200
+ const service: Service = {
201
+ name: 'PortalSessions',
202
+ operations: [
203
+ {
204
+ name: 'createPortalSession',
205
+ httpMethod: 'post',
206
+ path: '/portal/sessions',
207
+ pathParams: [],
208
+ queryParams: [],
209
+ headerParams: [],
210
+ response: { kind: 'model', name: 'PortalSessionsCreateResponse' },
211
+ errors: [],
212
+ injectIdempotencyKey: false,
213
+ },
214
+ ],
215
+ };
216
+
217
+ const models: Model[] = [
218
+ {
219
+ name: 'PortalSessionsCreateResponse',
220
+ fields: [
221
+ {
222
+ name: 'link',
223
+ type: { kind: 'primitive', type: 'string' },
224
+ required: true,
225
+ },
226
+ ],
227
+ },
228
+ ];
229
+
230
+ const ctxWithServices: EmitterContext = {
231
+ ...ctx,
232
+ spec: { ...emptySpec, services: [service], models },
233
+ };
234
+
235
+ const files = generateModels(models, ctxWithServices);
236
+ const content = files[0].content;
237
+
238
+ // Should use Wire suffix, not ResponseResponse
239
+ expect(content).toContain('export interface PortalSessionsCreateResponseWire {');
240
+ expect(content).not.toContain('PortalSessionsCreateResponseResponse');
241
+ });
242
+
243
+ it('renders @deprecated on fields', () => {
244
+ const service: Service = {
245
+ name: 'Organizations',
246
+ operations: [
247
+ {
248
+ name: 'getOrganization',
249
+ httpMethod: 'get',
250
+ path: '/organizations/{id}',
251
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
252
+ queryParams: [],
253
+ headerParams: [],
254
+ response: { kind: 'model', name: 'Organization' },
255
+ errors: [],
256
+ injectIdempotencyKey: false,
257
+ },
258
+ ],
259
+ };
260
+
261
+ const models: Model[] = [
262
+ {
263
+ name: 'Organization',
264
+ fields: [
265
+ {
266
+ name: 'id',
267
+ type: { kind: 'primitive', type: 'string' },
268
+ required: true,
269
+ },
270
+ {
271
+ name: 'legacy_slug',
272
+ type: { kind: 'primitive', type: 'string' },
273
+ required: false,
274
+ description: 'Use external_id instead.',
275
+ deprecated: true,
276
+ },
277
+ {
278
+ name: 'old_field',
279
+ type: { kind: 'primitive', type: 'string' },
280
+ required: false,
281
+ deprecated: true,
282
+ },
283
+ ],
284
+ },
285
+ ];
286
+
287
+ const ctxWithServices: EmitterContext = {
288
+ ...ctx,
289
+ spec: { ...emptySpec, services: [service], models },
290
+ };
291
+
292
+ const files = generateModels(models, ctxWithServices);
293
+ const content = files[0].content;
294
+
295
+ // Field with description + deprecated gets multiline JSDoc
296
+ expect(content).toContain(' /**\n * Use external_id instead.\n * @deprecated\n */');
297
+
298
+ // Field with only deprecated gets single-line JSDoc
299
+ expect(content).toContain(' /** @deprecated */');
300
+ });
301
+ });
@@ -0,0 +1,212 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ className,
4
+ fileName,
5
+ methodName,
6
+ fieldName,
7
+ wireFieldName,
8
+ wireInterfaceName,
9
+ serviceDirName,
10
+ servicePropertyName,
11
+ resolveServiceName,
12
+ buildServiceNameMap,
13
+ } from '../../src/node/naming.js';
14
+ import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
15
+
16
+ describe('naming', () => {
17
+ describe('className', () => {
18
+ it('converts to PascalCase', () => {
19
+ expect(className('organizations')).toBe('Organizations');
20
+ expect(className('user_management')).toBe('UserManagement');
21
+ expect(className('api_keys')).toBe('ApiKeys');
22
+ });
23
+ });
24
+
25
+ describe('fileName', () => {
26
+ it('converts to kebab-case', () => {
27
+ expect(fileName('Organization')).toBe('organization');
28
+ expect(fileName('OrganizationDomain')).toBe('organization-domain');
29
+ expect(fileName('UserManagement')).toBe('user-management');
30
+ });
31
+ });
32
+
33
+ describe('methodName', () => {
34
+ it('converts to camelCase', () => {
35
+ expect(methodName('list_organizations')).toBe('listOrganizations');
36
+ expect(methodName('create_organization')).toBe('createOrganization');
37
+ expect(methodName('get_organization')).toBe('getOrganization');
38
+ });
39
+ });
40
+
41
+ describe('fieldName', () => {
42
+ it('converts to camelCase', () => {
43
+ expect(fieldName('allow_profiles_outside_organization')).toBe('allowProfilesOutsideOrganization');
44
+ expect(fieldName('stripe_customer_id')).toBe('stripeCustomerId');
45
+ expect(fieldName('id')).toBe('id');
46
+ });
47
+ });
48
+
49
+ describe('wireFieldName', () => {
50
+ it('converts to snake_case', () => {
51
+ expect(wireFieldName('allowProfilesOutsideOrganization')).toBe('allow_profiles_outside_organization');
52
+ expect(wireFieldName('id')).toBe('id');
53
+ expect(wireFieldName('created_at')).toBe('created_at');
54
+ });
55
+ });
56
+
57
+ describe('wireInterfaceName', () => {
58
+ it('appends Response for normal names', () => {
59
+ expect(wireInterfaceName('Organization')).toBe('OrganizationResponse');
60
+ });
61
+
62
+ it('appends Wire when name already ends in Response', () => {
63
+ expect(wireInterfaceName('PortalSessionsCreateResponse')).toBe('PortalSessionsCreateResponseWire');
64
+ });
65
+ });
66
+
67
+ describe('serviceDirName', () => {
68
+ it('converts to kebab-case', () => {
69
+ expect(serviceDirName('Organizations')).toBe('organizations');
70
+ expect(serviceDirName('UserManagement')).toBe('user-management');
71
+ expect(serviceDirName('ApiKeys')).toBe('api-keys');
72
+ });
73
+ });
74
+
75
+ describe('servicePropertyName', () => {
76
+ it('converts to camelCase', () => {
77
+ expect(servicePropertyName('Organizations')).toBe('organizations');
78
+ expect(servicePropertyName('UserManagement')).toBe('userManagement');
79
+ expect(servicePropertyName('ApiKeys')).toBe('apiKeys');
80
+ });
81
+ });
82
+
83
+ describe('resolveServiceName', () => {
84
+ const emptySpec: ApiSpec = {
85
+ name: 'Test',
86
+ version: '1.0.0',
87
+ baseUrl: '',
88
+ services: [],
89
+ models: [],
90
+ enums: [],
91
+ };
92
+
93
+ it('returns overlay class name when available', () => {
94
+ const service: Service = {
95
+ name: 'MultiFactorAuth',
96
+ operations: [
97
+ {
98
+ name: 'enrollFactor',
99
+ httpMethod: 'post',
100
+ path: '/auth/factors/enroll',
101
+ pathParams: [],
102
+ queryParams: [],
103
+ headerParams: [],
104
+ response: { kind: 'primitive', type: 'string' },
105
+ errors: [],
106
+ injectIdempotencyKey: true,
107
+ },
108
+ ],
109
+ };
110
+
111
+ const ctx: EmitterContext = {
112
+ namespace: 'workos',
113
+ namespacePascal: 'WorkOS',
114
+ spec: emptySpec,
115
+ irVersion: 6,
116
+ overlayLookup: {
117
+ methodByOperation: new Map([
118
+ [
119
+ 'POST /auth/factors/enroll',
120
+ { className: 'Mfa', methodName: 'enrollFactor', params: [], returnType: 'void' },
121
+ ],
122
+ ]),
123
+ httpKeyByMethod: new Map(),
124
+ interfaceByName: new Map(),
125
+ typeAliasByName: new Map(),
126
+ requiredExports: new Map(),
127
+ modelNameByIR: new Map(),
128
+ fileBySymbol: new Map(),
129
+ },
130
+ };
131
+
132
+ expect(resolveServiceName(service, ctx)).toBe('Mfa');
133
+ });
134
+
135
+ it('falls back to PascalCase of service.name', () => {
136
+ const service: Service = {
137
+ name: 'MultiFactorAuth',
138
+ operations: [],
139
+ };
140
+
141
+ const ctx: EmitterContext = {
142
+ namespace: 'workos',
143
+ namespacePascal: 'WorkOS',
144
+ spec: emptySpec,
145
+ irVersion: 6,
146
+ };
147
+
148
+ expect(resolveServiceName(service, ctx)).toBe('MultiFactorAuth');
149
+ });
150
+ });
151
+
152
+ describe('buildServiceNameMap', () => {
153
+ const emptySpec: ApiSpec = {
154
+ name: 'Test',
155
+ version: '1.0.0',
156
+ baseUrl: '',
157
+ services: [],
158
+ models: [],
159
+ enums: [],
160
+ };
161
+
162
+ it('maps IR names to resolved names', () => {
163
+ const services: Service[] = [
164
+ {
165
+ name: 'MultiFactorAuth',
166
+ operations: [
167
+ {
168
+ name: 'enrollFactor',
169
+ httpMethod: 'post',
170
+ path: '/auth/factors/enroll',
171
+ pathParams: [],
172
+ queryParams: [],
173
+ headerParams: [],
174
+ response: { kind: 'primitive', type: 'string' },
175
+ errors: [],
176
+ injectIdempotencyKey: true,
177
+ },
178
+ ],
179
+ },
180
+ {
181
+ name: 'Organizations',
182
+ operations: [],
183
+ },
184
+ ];
185
+
186
+ const ctx: EmitterContext = {
187
+ namespace: 'workos',
188
+ namespacePascal: 'WorkOS',
189
+ spec: emptySpec,
190
+ irVersion: 6,
191
+ overlayLookup: {
192
+ methodByOperation: new Map([
193
+ [
194
+ 'POST /auth/factors/enroll',
195
+ { className: 'Mfa', methodName: 'enrollFactor', params: [], returnType: 'void' },
196
+ ],
197
+ ]),
198
+ httpKeyByMethod: new Map(),
199
+ interfaceByName: new Map(),
200
+ typeAliasByName: new Map(),
201
+ requiredExports: new Map(),
202
+ modelNameByIR: new Map(),
203
+ fileBySymbol: new Map(),
204
+ },
205
+ };
206
+
207
+ const map = buildServiceNameMap(services, ctx);
208
+ expect(map.get('MultiFactorAuth')).toBe('Mfa');
209
+ expect(map.get('Organizations')).toBe('Organizations');
210
+ });
211
+ });
212
+ });