@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.
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/lint-pr-title.yml +16 -0
- package/.github/workflows/lint.yml +21 -0
- package/.github/workflows/release-please.yml +28 -0
- package/.github/workflows/release.yml +32 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.node-version +1 -0
- package/.oxfmtrc.json +10 -0
- package/.oxlintrc.json +29 -0
- package/.vscode/settings.json +11 -0
- package/LICENSE.txt +21 -0
- package/README.md +123 -0
- package/commitlint.config.ts +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2158 -0
- package/docs/endpoint-coverage.md +275 -0
- package/docs/sdk-architecture/node.md +355 -0
- package/oagen.config.ts +51 -0
- package/package.json +83 -0
- package/renovate.json +26 -0
- package/smoke/sdk-dotnet.ts +903 -0
- package/smoke/sdk-elixir.ts +771 -0
- package/smoke/sdk-go.ts +948 -0
- package/smoke/sdk-kotlin.ts +799 -0
- package/smoke/sdk-node.ts +516 -0
- package/smoke/sdk-php.ts +699 -0
- package/smoke/sdk-python.ts +738 -0
- package/smoke/sdk-ruby.ts +723 -0
- package/smoke/sdk-rust.ts +774 -0
- package/src/compat/extractors/dotnet.ts +8 -0
- package/src/compat/extractors/elixir.ts +8 -0
- package/src/compat/extractors/go.ts +8 -0
- package/src/compat/extractors/kotlin.ts +8 -0
- package/src/compat/extractors/node.ts +8 -0
- package/src/compat/extractors/php.ts +8 -0
- package/src/compat/extractors/python.ts +8 -0
- package/src/compat/extractors/ruby.ts +8 -0
- package/src/compat/extractors/rust.ts +8 -0
- package/src/index.ts +1 -0
- package/src/node/client.ts +356 -0
- package/src/node/common.ts +203 -0
- package/src/node/config.ts +70 -0
- package/src/node/enums.ts +87 -0
- package/src/node/errors.ts +205 -0
- package/src/node/fixtures.ts +139 -0
- package/src/node/index.ts +57 -0
- package/src/node/manifest.ts +23 -0
- package/src/node/models.ts +323 -0
- package/src/node/naming.ts +96 -0
- package/src/node/resources.ts +380 -0
- package/src/node/serializers.ts +286 -0
- package/src/node/tests.ts +336 -0
- package/src/node/type-map.ts +56 -0
- package/src/node/utils.ts +164 -0
- package/test/compat/extractors/node.test.ts +145 -0
- package/test/fixtures/sample-sdk-node/package.json +7 -0
- package/test/fixtures/sample-sdk-node/src/client.ts +24 -0
- package/test/fixtures/sample-sdk-node/src/index.ts +4 -0
- package/test/fixtures/sample-sdk-node/src/models.ts +28 -0
- package/test/fixtures/sample-sdk-node/tsconfig.json +13 -0
- package/test/node/client.test.ts +165 -0
- package/test/node/enums.test.ts +128 -0
- package/test/node/errors.test.ts +65 -0
- package/test/node/models.test.ts +301 -0
- package/test/node/naming.test.ts +212 -0
- package/test/node/resources.test.ts +260 -0
- package/test/node/serializers.test.ts +206 -0
- package/test/node/type-map.test.ts +127 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +8 -0
- package/vitest.config.ts +4 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { ApiSpec, Service, Operation, Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { planOperation, toCamelCase } from '@workos/oagen';
|
|
3
|
+
import {
|
|
4
|
+
fieldName,
|
|
5
|
+
wireFieldName,
|
|
6
|
+
fileName,
|
|
7
|
+
serviceDirName,
|
|
8
|
+
servicePropertyName,
|
|
9
|
+
resolveMethodName,
|
|
10
|
+
resolveServiceName,
|
|
11
|
+
} from './naming.js';
|
|
12
|
+
import { generateFixtures } from './fixtures.js';
|
|
13
|
+
|
|
14
|
+
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
15
|
+
const files: GeneratedFile[] = [];
|
|
16
|
+
|
|
17
|
+
// Generate fixture JSON files
|
|
18
|
+
const fixtures = generateFixtures(spec, ctx);
|
|
19
|
+
for (const f of fixtures) {
|
|
20
|
+
files.push({ path: f.path, content: f.content, headerPlacement: 'skip' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Build model lookup for response field assertions
|
|
24
|
+
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
25
|
+
|
|
26
|
+
// Generate test files per service
|
|
27
|
+
for (const service of spec.services) {
|
|
28
|
+
files.push(generateServiceTest(service, spec, ctx, modelMap));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return files;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function generateServiceTest(
|
|
35
|
+
service: Service,
|
|
36
|
+
spec: ApiSpec,
|
|
37
|
+
ctx: EmitterContext,
|
|
38
|
+
modelMap: Map<string, Model>,
|
|
39
|
+
): GeneratedFile {
|
|
40
|
+
const resolvedName = resolveServiceName(service, ctx);
|
|
41
|
+
const serviceDir = serviceDirName(resolvedName);
|
|
42
|
+
const serviceClass = resolvedName;
|
|
43
|
+
const serviceProp = servicePropertyName(resolvedName);
|
|
44
|
+
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
45
|
+
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
|
|
48
|
+
lines.push("import fetch from 'jest-fetch-mock';");
|
|
49
|
+
lines.push('import {');
|
|
50
|
+
lines.push(' fetchOnce,');
|
|
51
|
+
lines.push(' fetchURL,');
|
|
52
|
+
lines.push(' fetchSearchParams,');
|
|
53
|
+
lines.push(' fetchBody,');
|
|
54
|
+
lines.push("} from '../common/utils/test-utils';");
|
|
55
|
+
lines.push("import { WorkOS } from '../workos';");
|
|
56
|
+
lines.push('');
|
|
57
|
+
|
|
58
|
+
// Import fixtures
|
|
59
|
+
const fixtureImports = new Set<string>();
|
|
60
|
+
for (const op of service.operations) {
|
|
61
|
+
const plan = planOperation(op);
|
|
62
|
+
if (plan.isPaginated && op.pagination) {
|
|
63
|
+
const itemModelName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : null;
|
|
64
|
+
if (itemModelName) {
|
|
65
|
+
fixtureImports.add(
|
|
66
|
+
`import list${itemModelName}Fixture from './fixtures/list-${fileName(itemModelName)}.fixture.json';`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
} else if (plan.responseModelName) {
|
|
70
|
+
fixtureImports.add(
|
|
71
|
+
`import ${toCamelCase(plan.responseModelName)}Fixture from './fixtures/${fileName(plan.responseModelName)}.fixture.json';`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const imp of fixtureImports) {
|
|
76
|
+
lines.push(imp);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
lines.push('');
|
|
80
|
+
lines.push("const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');");
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(`describe('${serviceClass}', () => {`);
|
|
83
|
+
lines.push(' beforeEach(() => fetch.resetMocks());');
|
|
84
|
+
|
|
85
|
+
for (const op of service.operations) {
|
|
86
|
+
const plan = planOperation(op);
|
|
87
|
+
const method = resolveMethodName(op, service, ctx);
|
|
88
|
+
|
|
89
|
+
lines.push('');
|
|
90
|
+
lines.push(` describe('${method}', () => {`);
|
|
91
|
+
|
|
92
|
+
if (plan.isPaginated) {
|
|
93
|
+
renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap);
|
|
94
|
+
} else if (plan.isDelete) {
|
|
95
|
+
renderDeleteTest(lines, op, method, serviceProp);
|
|
96
|
+
} else if (plan.hasBody && plan.responseModelName) {
|
|
97
|
+
renderBodyTest(lines, op, plan, method, serviceProp, modelMap);
|
|
98
|
+
} else if (plan.responseModelName) {
|
|
99
|
+
renderGetTest(lines, op, plan, method, serviceProp, modelMap);
|
|
100
|
+
} else {
|
|
101
|
+
renderVoidTest(lines, op, method, serviceProp);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Error case test for all non-void operations
|
|
105
|
+
if (plan.responseModelName || plan.isPaginated) {
|
|
106
|
+
renderErrorTest(lines, op, plan, method, serviceProp);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
lines.push(' });');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push('});');
|
|
113
|
+
|
|
114
|
+
return { path: testPath, content: lines.join('\n'), skipIfExists: true, integrateTarget: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderPaginatedTest(
|
|
118
|
+
lines: string[],
|
|
119
|
+
op: Operation,
|
|
120
|
+
plan: any,
|
|
121
|
+
method: string,
|
|
122
|
+
serviceProp: string,
|
|
123
|
+
modelMap: Map<string, Model>,
|
|
124
|
+
): void {
|
|
125
|
+
const itemModelName = op.pagination?.itemType.kind === 'model' ? op.pagination.itemType.name : 'Item';
|
|
126
|
+
|
|
127
|
+
lines.push(" it('returns paginated results', async () => {");
|
|
128
|
+
lines.push(` fetchOnce(list${itemModelName}Fixture);`);
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}();`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
|
|
133
|
+
lines.push(" expect(fetchSearchParams()).toHaveProperty('order');");
|
|
134
|
+
lines.push(' expect(Array.isArray(data)).toBe(true);');
|
|
135
|
+
lines.push(' expect(listMetadata).toBeDefined();');
|
|
136
|
+
|
|
137
|
+
// Assert on first item fields when item model is available
|
|
138
|
+
const itemModel = modelMap.get(itemModelName);
|
|
139
|
+
if (itemModel) {
|
|
140
|
+
const assertions = buildFieldAssertions(itemModel, 'data[0]');
|
|
141
|
+
if (assertions.length > 0) {
|
|
142
|
+
lines.push(' expect(data.length).toBeGreaterThan(0);');
|
|
143
|
+
for (const assertion of assertions) {
|
|
144
|
+
lines.push(` ${assertion}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
lines.push(' });');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderDeleteTest(lines: string[], op: Operation, method: string, serviceProp: string): void {
|
|
153
|
+
const hasPathParam = op.pathParams.length > 0;
|
|
154
|
+
const args = hasPathParam ? "'test_id'" : '';
|
|
155
|
+
|
|
156
|
+
lines.push(" it('sends a DELETE request', async () => {");
|
|
157
|
+
lines.push(' fetchOnce({}, { status: 204 });');
|
|
158
|
+
lines.push('');
|
|
159
|
+
lines.push(` await workos.${serviceProp}.${method}(${args});`);
|
|
160
|
+
lines.push('');
|
|
161
|
+
lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
|
|
162
|
+
if (hasPathParam) {
|
|
163
|
+
lines.push(" expect(fetchURL()).toContain('test_id');");
|
|
164
|
+
}
|
|
165
|
+
lines.push(' });');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderBodyTest(
|
|
169
|
+
lines: string[],
|
|
170
|
+
op: Operation,
|
|
171
|
+
plan: any,
|
|
172
|
+
method: string,
|
|
173
|
+
serviceProp: string,
|
|
174
|
+
modelMap: Map<string, Model>,
|
|
175
|
+
): void {
|
|
176
|
+
const responseModelName = plan.responseModelName!;
|
|
177
|
+
const fixture = `${toCamelCase(responseModelName)}Fixture`;
|
|
178
|
+
const hasPathParam = op.pathParams.length > 0;
|
|
179
|
+
const pathArg = hasPathParam ? "'test_id', " : '';
|
|
180
|
+
|
|
181
|
+
lines.push(" it('sends the correct request and returns result', async () => {");
|
|
182
|
+
lines.push(` fetchOnce(${fixture});`);
|
|
183
|
+
lines.push('');
|
|
184
|
+
lines.push(` const result = await workos.${serviceProp}.${method}(${pathArg}{});`);
|
|
185
|
+
lines.push('');
|
|
186
|
+
lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
|
|
187
|
+
if (hasPathParam) {
|
|
188
|
+
lines.push(" expect(fetchURL()).toContain('test_id');");
|
|
189
|
+
}
|
|
190
|
+
lines.push(' expect(fetchBody()).toBeDefined();');
|
|
191
|
+
lines.push(' expect(result).toBeDefined();');
|
|
192
|
+
|
|
193
|
+
// Response field assertions
|
|
194
|
+
const responseModel = modelMap.get(responseModelName);
|
|
195
|
+
if (responseModel) {
|
|
196
|
+
const assertions = buildFieldAssertions(responseModel, 'result');
|
|
197
|
+
for (const assertion of assertions) {
|
|
198
|
+
lines.push(` ${assertion}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
lines.push(' });');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderGetTest(
|
|
206
|
+
lines: string[],
|
|
207
|
+
op: Operation,
|
|
208
|
+
plan: any,
|
|
209
|
+
method: string,
|
|
210
|
+
serviceProp: string,
|
|
211
|
+
modelMap: Map<string, Model>,
|
|
212
|
+
): void {
|
|
213
|
+
const responseModelName = plan.responseModelName!;
|
|
214
|
+
const fixture = `${toCamelCase(responseModelName)}Fixture`;
|
|
215
|
+
const hasPathParam = op.pathParams.length > 0;
|
|
216
|
+
const args = hasPathParam ? "'test_id'" : '';
|
|
217
|
+
|
|
218
|
+
lines.push(" it('returns the expected result', async () => {");
|
|
219
|
+
lines.push(` fetchOnce(${fixture});`);
|
|
220
|
+
lines.push('');
|
|
221
|
+
lines.push(` const result = await workos.${serviceProp}.${method}(${args});`);
|
|
222
|
+
lines.push('');
|
|
223
|
+
lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
|
|
224
|
+
if (hasPathParam) {
|
|
225
|
+
lines.push(" expect(fetchURL()).toContain('test_id');");
|
|
226
|
+
}
|
|
227
|
+
lines.push(' expect(result).toBeDefined();');
|
|
228
|
+
|
|
229
|
+
// Response field assertions
|
|
230
|
+
const responseModel = modelMap.get(responseModelName);
|
|
231
|
+
if (responseModel) {
|
|
232
|
+
const assertions = buildFieldAssertions(responseModel, 'result');
|
|
233
|
+
for (const assertion of assertions) {
|
|
234
|
+
lines.push(` ${assertion}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
lines.push(' });');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderVoidTest(lines: string[], op: Operation, method: string, serviceProp: string): void {
|
|
242
|
+
const hasPathParam = op.pathParams.length > 0;
|
|
243
|
+
const args = hasPathParam ? "'test_id'" : '';
|
|
244
|
+
|
|
245
|
+
lines.push(" it('sends the request', async () => {");
|
|
246
|
+
lines.push(' fetchOnce({});');
|
|
247
|
+
lines.push('');
|
|
248
|
+
lines.push(` await workos.${serviceProp}.${method}(${args});`);
|
|
249
|
+
lines.push('');
|
|
250
|
+
lines.push(` expect(fetchURL()).toContain('${op.path.split('{')[0]}');`);
|
|
251
|
+
if (hasPathParam) {
|
|
252
|
+
lines.push(" expect(fetchURL()).toContain('test_id');");
|
|
253
|
+
}
|
|
254
|
+
lines.push(' });');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function renderErrorTest(lines: string[], op: Operation, plan: any, method: string, serviceProp: string): void {
|
|
258
|
+
const hasPathParam = op.pathParams.length > 0;
|
|
259
|
+
const isPaginated = plan.isPaginated;
|
|
260
|
+
const hasBody = plan.hasBody;
|
|
261
|
+
|
|
262
|
+
let args: string;
|
|
263
|
+
if (isPaginated) {
|
|
264
|
+
args = '';
|
|
265
|
+
} else if (hasBody && hasPathParam) {
|
|
266
|
+
args = "'test_id', {}";
|
|
267
|
+
} else if (hasBody) {
|
|
268
|
+
args = '{}';
|
|
269
|
+
} else if (hasPathParam) {
|
|
270
|
+
args = "'test_id'";
|
|
271
|
+
} else {
|
|
272
|
+
args = '';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
lines.push('');
|
|
276
|
+
lines.push(" it('throws on unauthorized', async () => {");
|
|
277
|
+
lines.push(" fetchOnce({ message: 'Unauthorized' }, { status: 401 });");
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
|
|
280
|
+
lines.push(' });');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Build field-level assertions for top-level primitive fields of a response model.
|
|
285
|
+
* Returns lines like: expect(result.fieldName).toBe(fixtureValue);
|
|
286
|
+
*/
|
|
287
|
+
function buildFieldAssertions(model: Model, accessor: string): string[] {
|
|
288
|
+
const assertions: string[] = [];
|
|
289
|
+
|
|
290
|
+
for (const field of model.fields) {
|
|
291
|
+
if (!field.required) continue;
|
|
292
|
+
const value = fixtureValueForType(field.type, field.name);
|
|
293
|
+
if (value === null) continue;
|
|
294
|
+
const domainField = fieldName(field.name);
|
|
295
|
+
assertions.push(`expect(${accessor}.${domainField}).toBe(${value});`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return assertions;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Return a JS literal string for the expected fixture value of a primitive field.
|
|
303
|
+
* Returns null for non-primitive or complex types (arrays, models, etc.).
|
|
304
|
+
*/
|
|
305
|
+
function fixtureValueForType(ref: TypeRef, name: string): string | null {
|
|
306
|
+
switch (ref.kind) {
|
|
307
|
+
case 'primitive':
|
|
308
|
+
return fixtureValueForPrimitive(ref.type, ref.format, name);
|
|
309
|
+
case 'literal':
|
|
310
|
+
return typeof ref.value === 'string' ? `'${ref.value}'` : String(ref.value);
|
|
311
|
+
default:
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function fixtureValueForPrimitive(type: string, format: string | undefined, name: string): string | null {
|
|
317
|
+
switch (type) {
|
|
318
|
+
case 'string':
|
|
319
|
+
if (format === 'date-time') return "'2023-01-01T00:00:00.000Z'";
|
|
320
|
+
if (format === 'date') return "'2023-01-01'";
|
|
321
|
+
if (format === 'uuid') return "'00000000-0000-0000-0000-000000000000'";
|
|
322
|
+
if (name.includes('id')) return `'${wireFieldName(name)}_01234'`;
|
|
323
|
+
if (name.includes('email')) return "'test@example.com'";
|
|
324
|
+
if (name.includes('url') || name.includes('uri')) return "'https://example.com'";
|
|
325
|
+
if (name.includes('name')) return "'Test'";
|
|
326
|
+
return `'test_${wireFieldName(name)}'`;
|
|
327
|
+
case 'integer':
|
|
328
|
+
return '1';
|
|
329
|
+
case 'number':
|
|
330
|
+
return '1';
|
|
331
|
+
case 'boolean':
|
|
332
|
+
return 'true';
|
|
333
|
+
default:
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { TypeRef, PrimitiveType } from '@workos/oagen';
|
|
2
|
+
import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
3
|
+
import { wireInterfaceName } from './naming.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map an IR TypeRef to a TypeScript domain type string.
|
|
7
|
+
* Domain types use PascalCase model names (e.g., `Organization`).
|
|
8
|
+
*/
|
|
9
|
+
export function mapTypeRef(ref: TypeRef): string {
|
|
10
|
+
return irMapTypeRef<string>(ref, {
|
|
11
|
+
primitive: mapPrimitive,
|
|
12
|
+
array: (_r, items) => `${parenthesizeUnion(items)}[]`,
|
|
13
|
+
model: (r) => r.name,
|
|
14
|
+
enum: (r) => r.name,
|
|
15
|
+
union: (_r, variants) => variants.join(' | '),
|
|
16
|
+
nullable: (_r, inner) => `${inner} | null`,
|
|
17
|
+
literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
|
|
18
|
+
map: (_r, value) => `Record<string, ${value}>`,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Map an IR TypeRef to a TypeScript wire/response type string.
|
|
24
|
+
* Model references get the `Response` suffix (e.g., `OrganizationResponse`).
|
|
25
|
+
*/
|
|
26
|
+
export function mapWireTypeRef(ref: TypeRef): string {
|
|
27
|
+
return irMapTypeRef<string>(ref, {
|
|
28
|
+
primitive: mapPrimitive,
|
|
29
|
+
array: (_r, items) => `${parenthesizeUnion(items)}[]`,
|
|
30
|
+
model: (r) => wireInterfaceName(r.name),
|
|
31
|
+
enum: (r) => r.name,
|
|
32
|
+
union: (_r, variants) => variants.join(' | '),
|
|
33
|
+
nullable: (_r, inner) => `${inner} | null`,
|
|
34
|
+
literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
|
|
35
|
+
map: (_r, value) => `Record<string, ${value}>`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mapPrimitive(ref: PrimitiveType): string {
|
|
40
|
+
switch (ref.type) {
|
|
41
|
+
case 'string':
|
|
42
|
+
return 'string';
|
|
43
|
+
case 'integer':
|
|
44
|
+
case 'number':
|
|
45
|
+
return 'number';
|
|
46
|
+
case 'boolean':
|
|
47
|
+
return 'boolean';
|
|
48
|
+
case 'unknown':
|
|
49
|
+
return 'any';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Wrap union types in parentheses when used as array item type. */
|
|
54
|
+
function parenthesizeUnion(type: string): string {
|
|
55
|
+
return type.includes(' | ') ? `(${type})` : type;
|
|
56
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Model, Service, TypeRef } from '@workos/oagen';
|
|
2
|
+
import { walkTypeRef } from '@workos/oagen';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Collect all model names referenced (directly or transitively) by a TypeRef.
|
|
6
|
+
*/
|
|
7
|
+
export function collectModelRefs(ref: TypeRef): string[] {
|
|
8
|
+
const names: string[] = [];
|
|
9
|
+
walkTypeRef(ref, { model: (r) => names.push(r.name) });
|
|
10
|
+
return names;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collect all enum names referenced by a TypeRef.
|
|
15
|
+
*/
|
|
16
|
+
export function collectEnumRefs(ref: TypeRef): string[] {
|
|
17
|
+
const names: string[] = [];
|
|
18
|
+
walkTypeRef(ref, { enum: (r) => names.push(r.name) });
|
|
19
|
+
return names;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Assign each model to the service that first references it.
|
|
24
|
+
* Models referenced by multiple services are assigned to the first.
|
|
25
|
+
* Models not referenced by any service are unassigned (returned as undefined).
|
|
26
|
+
*/
|
|
27
|
+
export function assignModelsToServices(models: Model[], services: Service[]): Map<string, string> {
|
|
28
|
+
const modelToService = new Map<string, string>();
|
|
29
|
+
const modelNames = new Set(models.map((m) => m.name));
|
|
30
|
+
|
|
31
|
+
for (const service of services) {
|
|
32
|
+
const referencedModels = new Set<string>();
|
|
33
|
+
|
|
34
|
+
// Collect directly referenced models from all operations
|
|
35
|
+
for (const op of service.operations) {
|
|
36
|
+
if (op.requestBody) {
|
|
37
|
+
for (const name of collectModelRefs(op.requestBody)) {
|
|
38
|
+
referencedModels.add(name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const name of collectModelRefs(op.response)) {
|
|
42
|
+
referencedModels.add(name);
|
|
43
|
+
}
|
|
44
|
+
for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
|
|
45
|
+
for (const name of collectModelRefs(param.type)) {
|
|
46
|
+
referencedModels.add(name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (op.pagination) {
|
|
50
|
+
for (const name of collectModelRefs(op.pagination.itemType)) {
|
|
51
|
+
referencedModels.add(name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Transitively collect models referenced by the directly-referenced models
|
|
57
|
+
const toVisit = [...referencedModels];
|
|
58
|
+
while (toVisit.length > 0) {
|
|
59
|
+
const name = toVisit.pop()!;
|
|
60
|
+
const model = models.find((m) => m.name === name);
|
|
61
|
+
if (!model) continue;
|
|
62
|
+
for (const field of model.fields) {
|
|
63
|
+
for (const ref of collectModelRefs(field.type)) {
|
|
64
|
+
if (!referencedModels.has(ref) && modelNames.has(ref)) {
|
|
65
|
+
referencedModels.add(ref);
|
|
66
|
+
toVisit.push(ref);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Assign models to this service (first-come)
|
|
73
|
+
for (const name of referencedModels) {
|
|
74
|
+
if (!modelToService.has(name)) {
|
|
75
|
+
modelToService.set(name, service.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return modelToService;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Collect all TypeRef-referenced model and enum names from a model's fields.
|
|
85
|
+
* Returns { models, enums } sets for generating import statements.
|
|
86
|
+
*/
|
|
87
|
+
export function collectFieldDependencies(model: Model): {
|
|
88
|
+
models: Set<string>;
|
|
89
|
+
enums: Set<string>;
|
|
90
|
+
} {
|
|
91
|
+
const models = new Set<string>();
|
|
92
|
+
const enums = new Set<string>();
|
|
93
|
+
|
|
94
|
+
for (const field of model.fields) {
|
|
95
|
+
for (const name of collectModelRefs(field.type)) {
|
|
96
|
+
if (name !== model.name) models.add(name);
|
|
97
|
+
}
|
|
98
|
+
for (const name of collectEnumRefs(field.type)) {
|
|
99
|
+
enums.add(name);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { models, enums };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Compute a relative import path between two files within the generated SDK.
|
|
108
|
+
* Strips .ts extension from the result.
|
|
109
|
+
*/
|
|
110
|
+
export function relativeImport(fromFile: string, toFile: string): string {
|
|
111
|
+
const fromDir = fromFile.split('/').slice(0, -1);
|
|
112
|
+
const toFileParts = toFile.split('/');
|
|
113
|
+
const toDir = toFileParts.slice(0, -1);
|
|
114
|
+
const toFileName = toFileParts[toFileParts.length - 1];
|
|
115
|
+
|
|
116
|
+
let common = 0;
|
|
117
|
+
while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
|
|
118
|
+
common++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ups = fromDir.length - common;
|
|
122
|
+
const downs = toDir.slice(common);
|
|
123
|
+
const parts = [...Array(ups).fill('..'), ...downs, toFileName];
|
|
124
|
+
let result = parts.join('/');
|
|
125
|
+
result = result.replace(/\.ts$/, '');
|
|
126
|
+
if (!result.startsWith('.')) result = './' + result;
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Render a JSDoc comment block from a description string.
|
|
132
|
+
* Handles multiline descriptions by prefixing each line with ` * `.
|
|
133
|
+
* Returns the lines with the given indent (default 0 spaces).
|
|
134
|
+
*/
|
|
135
|
+
export function docComment(description: string, indent = 0): string[] {
|
|
136
|
+
const pad = ' '.repeat(indent);
|
|
137
|
+
const descLines = description.split('\n');
|
|
138
|
+
if (descLines.length === 1) {
|
|
139
|
+
return [`${pad}/** ${descLines[0]} */`];
|
|
140
|
+
}
|
|
141
|
+
const lines: string[] = [`${pad}/**`];
|
|
142
|
+
for (const line of descLines) {
|
|
143
|
+
lines.push(line === '' ? `${pad} *` : `${pad} * ${line}`);
|
|
144
|
+
}
|
|
145
|
+
lines.push(`${pad} */`);
|
|
146
|
+
return lines;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Collect all model names referenced as request bodies across all services.
|
|
151
|
+
*/
|
|
152
|
+
export function collectRequestBodyModels(services: Service[]): Set<string> {
|
|
153
|
+
const result = new Set<string>();
|
|
154
|
+
for (const service of services) {
|
|
155
|
+
for (const op of service.operations) {
|
|
156
|
+
if (op.requestBody) {
|
|
157
|
+
for (const name of collectModelRefs(op.requestBody)) {
|
|
158
|
+
result.add(name);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { nodeExtractor } from '../../../src/compat/extractors/node.js';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const fixturePath = resolve(__dirname, '../../fixtures/sample-sdk-node');
|
|
6
|
+
|
|
7
|
+
describe('nodeExtractor', () => {
|
|
8
|
+
it('extracts classes with methods and properties', async () => {
|
|
9
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
10
|
+
|
|
11
|
+
expect(surface.classes).toHaveProperty('WorkOSClient');
|
|
12
|
+
const client = surface.classes['WorkOSClient'];
|
|
13
|
+
|
|
14
|
+
expect(client.methods).toHaveProperty('getOrganization');
|
|
15
|
+
expect(client.methods).toHaveProperty('listOrganizations');
|
|
16
|
+
expect(client.methods).toHaveProperty('deleteOrganization');
|
|
17
|
+
|
|
18
|
+
expect(client.properties).toHaveProperty('baseURL');
|
|
19
|
+
expect(client.properties['baseURL'].readonly).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('extracts method params and return types', async () => {
|
|
23
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
24
|
+
const client = surface.classes['WorkOSClient'];
|
|
25
|
+
|
|
26
|
+
const getOrg = client.methods['getOrganization'][0];
|
|
27
|
+
expect(getOrg.params).toMatchObject([{ name: 'id', type: 'string', optional: false }]);
|
|
28
|
+
expect(getOrg.returnType).toBe('Promise<Organization>');
|
|
29
|
+
expect(getOrg.async).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('extracts optional params', async () => {
|
|
33
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
34
|
+
const client = surface.classes['WorkOSClient'];
|
|
35
|
+
|
|
36
|
+
const listOrgs = client.methods['listOrganizations'][0];
|
|
37
|
+
expect(listOrgs.params).toMatchObject([{ name: 'limit', type: 'number | undefined', optional: true }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('extracts delete method returning void', async () => {
|
|
41
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
42
|
+
const client = surface.classes['WorkOSClient'];
|
|
43
|
+
|
|
44
|
+
const deleteOrg = client.methods['deleteOrganization'][0];
|
|
45
|
+
expect(deleteOrg.returnType).toBe('Promise<void>');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('extracts constructor params', async () => {
|
|
49
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
50
|
+
const client = surface.classes['WorkOSClient'];
|
|
51
|
+
|
|
52
|
+
expect(client.constructorParams).toMatchObject([{ name: 'options', type: 'WorkOSOptions', optional: false }]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('extracts interfaces with fields', async () => {
|
|
56
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
57
|
+
|
|
58
|
+
expect(surface.interfaces).toHaveProperty('Organization');
|
|
59
|
+
const org = surface.interfaces['Organization'];
|
|
60
|
+
expect(org.fields).toHaveProperty('id');
|
|
61
|
+
expect(org.fields).toHaveProperty('name');
|
|
62
|
+
expect(org.fields).toHaveProperty('createdAt');
|
|
63
|
+
expect(org.fields['id'].type).toBe('string');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('extracts wire response interface', async () => {
|
|
67
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
68
|
+
|
|
69
|
+
expect(surface.interfaces).toHaveProperty('OrganizationResponse');
|
|
70
|
+
const resp = surface.interfaces['OrganizationResponse'];
|
|
71
|
+
expect(resp.fields).toHaveProperty('created_at');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('extracts interfaces with optional fields', async () => {
|
|
75
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
76
|
+
|
|
77
|
+
expect(surface.interfaces).toHaveProperty('WorkOSOptions');
|
|
78
|
+
const opts = surface.interfaces['WorkOSOptions'];
|
|
79
|
+
expect(opts.fields['apiKey'].optional).toBe(false);
|
|
80
|
+
expect(opts.fields['baseUrl'].optional).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('extracts generic interfaces', async () => {
|
|
84
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
85
|
+
|
|
86
|
+
expect(surface.interfaces).toHaveProperty('ListResponse');
|
|
87
|
+
const listResp = surface.interfaces['ListResponse'];
|
|
88
|
+
expect(listResp.fields).toHaveProperty('data');
|
|
89
|
+
expect(listResp.fields).toHaveProperty('hasMore');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('extracts type aliases', async () => {
|
|
93
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
94
|
+
|
|
95
|
+
expect(surface.typeAliases).toHaveProperty('StatusType');
|
|
96
|
+
expect(surface.typeAliases['StatusType'].value).toMatchInlineSnapshot(`""active" | "inactive""`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('extracts enums', async () => {
|
|
100
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
101
|
+
|
|
102
|
+
expect(surface.enums).toHaveProperty('Status');
|
|
103
|
+
expect(surface.enums['Status'].members).toMatchObject({
|
|
104
|
+
Active: 'active',
|
|
105
|
+
Inactive: 'inactive',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('builds export map', async () => {
|
|
110
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
111
|
+
|
|
112
|
+
// Entry point should list all exported symbols
|
|
113
|
+
const entryExports = Object.values(surface.exports).flat();
|
|
114
|
+
expect(entryExports).toContain('WorkOSClient');
|
|
115
|
+
expect(entryExports).toContain('Organization');
|
|
116
|
+
expect(entryExports).toContain('Status');
|
|
117
|
+
expect(entryExports).toContain('StatusType');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('sets metadata correctly', async () => {
|
|
121
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
122
|
+
|
|
123
|
+
expect(surface.language).toBe('node');
|
|
124
|
+
expect(surface.extractedFrom).toBe(fixturePath);
|
|
125
|
+
expect(surface.extractedAt).toBeTruthy();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('produces deterministic output', async () => {
|
|
129
|
+
const surface1 = await nodeExtractor.extract(fixturePath);
|
|
130
|
+
const surface2 = await nodeExtractor.extract(fixturePath);
|
|
131
|
+
|
|
132
|
+
// Normalize timestamps for comparison
|
|
133
|
+
surface1.extractedAt = '';
|
|
134
|
+
surface2.extractedAt = '';
|
|
135
|
+
|
|
136
|
+
expect(JSON.stringify(surface1)).toBe(JSON.stringify(surface2));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not extract private members', async () => {
|
|
140
|
+
const surface = await nodeExtractor.extract(fixturePath);
|
|
141
|
+
const client = surface.classes['WorkOSClient'];
|
|
142
|
+
|
|
143
|
+
expect(client.properties).not.toHaveProperty('apiKey');
|
|
144
|
+
});
|
|
145
|
+
});
|