@workos/oagen-emitters 0.12.1 → 0.12.3
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 +1 -1
- package/.github/workflows/lint-pr-title.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
- package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +354 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +547 -351
- package/src/node/naming.ts +122 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/path-expression.ts +11 -4
- package/src/node/resources.ts +473 -48
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +152 -93
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +435 -2025
- package/test/node/tests.test.ts +214 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/dist/plugin-CmfzawTp.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
package/src/node/sdk-errors.ts
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
import type { SdkBehavior } from '@workos/oagen';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Node-specific overrides for exception kind names.
|
|
5
|
-
*
|
|
6
|
-
* The IR `statusCodeMap` uses canonical kind names (e.g. 'Authentication'),
|
|
7
|
-
* but the Node SDK historically uses different names for some status codes.
|
|
8
|
-
* This map translates the IR kind name to the Node-specific name before
|
|
9
|
-
* appending the 'Exception' suffix.
|
|
10
|
-
*/
|
|
11
3
|
const NODE_EXCEPTION_KIND_OVERRIDES: Record<string, string> = {
|
|
12
4
|
Authentication: 'Unauthorized',
|
|
13
5
|
};
|
|
14
6
|
|
|
15
|
-
/** Fallback status code map when no SDK behavior is provided. */
|
|
16
7
|
const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
|
|
17
8
|
'400': 'BadRequest',
|
|
18
9
|
'401': 'Authentication',
|
|
@@ -23,13 +14,6 @@ const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
|
|
|
23
14
|
'429': 'RateLimitExceeded',
|
|
24
15
|
};
|
|
25
16
|
|
|
26
|
-
/**
|
|
27
|
-
* Build the status-code-to-exception-class-name map from SDK behavior,
|
|
28
|
-
* applying Node-specific naming overrides.
|
|
29
|
-
*
|
|
30
|
-
* Example: IR `401: 'Authentication'` becomes `401: 'UnauthorizedException'`
|
|
31
|
-
* because Node uses `UnauthorizedException` instead of `AuthenticationException`.
|
|
32
|
-
*/
|
|
33
17
|
export function buildNodeStatusExceptions(sdk?: SdkBehavior): Record<number, string> {
|
|
34
18
|
const statusCodeMap = sdk?.errors?.statusCodeMap ?? DEFAULT_STATUS_CODE_MAP;
|
|
35
19
|
return Object.fromEntries(
|
package/src/node/tests.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import type { ApiSpec, Service, Operation, Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
4
|
import { planOperation, toCamelCase } from '@workos/oagen';
|
|
3
5
|
import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
|
|
@@ -12,7 +14,7 @@ import {
|
|
|
12
14
|
wireInterfaceName,
|
|
13
15
|
} from './naming.js';
|
|
14
16
|
import { generateFixtures } from './fixtures.js';
|
|
15
|
-
import { resolveResourceClassName } from './resources.js';
|
|
17
|
+
import { resolveResourceClassName, resolveResourceDir } from './resources.js';
|
|
16
18
|
import {
|
|
17
19
|
assignModelsToServices,
|
|
18
20
|
createServiceDirResolver,
|
|
@@ -24,6 +26,30 @@ import {
|
|
|
24
26
|
computeNonEventReachable,
|
|
25
27
|
} from './utils.js';
|
|
26
28
|
import { groupByMount } from '../shared/resolved-ops.js';
|
|
29
|
+
import { isNodeOwnedService } from './options.js';
|
|
30
|
+
|
|
31
|
+
type BaselineMethod = {
|
|
32
|
+
params: Array<{ name: string; type: string; optional?: boolean; passingStyle?: string }>;
|
|
33
|
+
returnType?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
|
|
37
|
+
const serviceClass = resolveResourceClassName(service, ctx);
|
|
38
|
+
return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function optionsObjectParam(method: BaselineMethod | undefined): { name: string; type: string } | undefined {
|
|
42
|
+
if (!method || method.params.length !== 1) return undefined;
|
|
43
|
+
const [param] = method.params;
|
|
44
|
+
if (param.name !== 'options') return undefined;
|
|
45
|
+
if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
|
|
46
|
+
if (!param.type || /^(Record|object|any|unknown)\b/.test(param.type)) return undefined;
|
|
47
|
+
return { name: param.name, type: param.type };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function autoPaginatableItemType(returnType: string | undefined): string | undefined {
|
|
51
|
+
return returnType?.match(/\bAutoPaginatable<\s*([A-Za-z_$][\w$]*)/)?.[1];
|
|
52
|
+
}
|
|
27
53
|
|
|
28
54
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
29
55
|
const files: GeneratedFile[] = [];
|
|
@@ -76,13 +102,23 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
76
102
|
for (const { name: mountName, operations } of testEntries) {
|
|
77
103
|
if (operations.length === 0) continue;
|
|
78
104
|
const mergedService: Service = { name: mountName, operations };
|
|
79
|
-
const
|
|
105
|
+
const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
|
|
106
|
+
const ops = isOwnedService ? operations : uncoveredOperations(mergedService, ctx);
|
|
80
107
|
if (ops.length === 0) continue;
|
|
81
108
|
|
|
82
109
|
// Skip tests for services without a WorkOS property in the baseline
|
|
83
110
|
const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
|
|
84
111
|
if (ctx.apiSurface && baselineWorkOSProps.size > 0 && !baselineWorkOSProps.has(propName)) continue;
|
|
85
112
|
|
|
113
|
+
// Skip when the resource class diverges from the mount accessor — this
|
|
114
|
+
// happens for services with constructor-incompatible baselines (e.g.
|
|
115
|
+
// hand-written `Webhooks(crypto)` forks the emitted ops onto
|
|
116
|
+
// `WebhooksEndpoints`). The generated test would do
|
|
117
|
+
// `workos.<propName>.fooMethod(...)`, but those methods live on a
|
|
118
|
+
// different class, so the test would fail to compile.
|
|
119
|
+
const resourceClass = resolveResourceClassName(mergedService, ctx);
|
|
120
|
+
if (resourceClass !== mountName) continue;
|
|
121
|
+
|
|
86
122
|
const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
|
|
87
123
|
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
|
|
88
124
|
}
|
|
@@ -104,7 +140,7 @@ function generateServiceTest(
|
|
|
104
140
|
mountAccessors?: Map<string, string>,
|
|
105
141
|
): GeneratedFile {
|
|
106
142
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
107
|
-
const serviceDir =
|
|
143
|
+
const serviceDir = resolveResourceDir(service, ctx);
|
|
108
144
|
const serviceClass = resolvedName;
|
|
109
145
|
const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
|
|
110
146
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
@@ -148,11 +184,6 @@ function generateServiceTest(
|
|
|
148
184
|
const testUtils = ['fetchOnce', 'fetchURL', 'fetchMethod'];
|
|
149
185
|
if (hasPaginated) testUtils.push('fetchSearchParams');
|
|
150
186
|
if (hasBody) testUtils.push('fetchBody');
|
|
151
|
-
// Import shared test helpers for error and pagination tests
|
|
152
|
-
if (hasPaginated) testUtils.push('testEmptyResults', 'testPaginationParams');
|
|
153
|
-
// Only import testUnauthorized when at least one operation has a response model or is paginated
|
|
154
|
-
const hasErrorTests = plans.some((p) => p.plan.responseModelName || p.plan.isPaginated);
|
|
155
|
-
if (hasErrorTests) testUtils.push('testUnauthorized');
|
|
156
187
|
lines.push('import {');
|
|
157
188
|
for (const util of testUtils) {
|
|
158
189
|
lines.push(` ${util},`);
|
|
@@ -215,24 +246,20 @@ function generateServiceTest(
|
|
|
215
246
|
lines.push(' beforeEach(() => fetch.resetMocks());');
|
|
216
247
|
|
|
217
248
|
for (const { op, plan, method } of plans) {
|
|
249
|
+
const existingMethod = baselineMethodFor(service, method, ctx);
|
|
218
250
|
lines.push('');
|
|
219
251
|
lines.push(` describe('${method}', () => {`);
|
|
220
252
|
|
|
221
253
|
if (plan.isPaginated) {
|
|
222
|
-
renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
254
|
+
renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
|
|
223
255
|
} else if (plan.isDelete) {
|
|
224
|
-
renderDeleteTest(lines, op, plan, method, serviceProp, modelMap);
|
|
256
|
+
renderDeleteTest(lines, op, plan, method, serviceProp, modelMap, ctx, existingMethod);
|
|
225
257
|
} else if (plan.hasBody && plan.responseModelName) {
|
|
226
|
-
renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
258
|
+
renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
|
|
227
259
|
} else if (plan.responseModelName) {
|
|
228
|
-
renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
260
|
+
renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
|
|
229
261
|
} else {
|
|
230
|
-
renderVoidTest(lines, op, plan, method, serviceProp, modelMap);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Error case test for all non-void operations
|
|
234
|
-
if (plan.responseModelName || plan.isPaginated) {
|
|
235
|
-
renderErrorTest(lines, op, plan, method, serviceProp, modelMap);
|
|
262
|
+
renderVoidTest(lines, op, plan, method, serviceProp, modelMap, ctx, existingMethod);
|
|
236
263
|
}
|
|
237
264
|
|
|
238
265
|
lines.push(' });');
|
|
@@ -281,6 +308,7 @@ function renderPaginatedTest(
|
|
|
281
308
|
modelMap: Map<string, Model>,
|
|
282
309
|
ctx?: EmitterContext,
|
|
283
310
|
entityHelpers?: Set<string>,
|
|
311
|
+
baselineMethod?: BaselineMethod,
|
|
284
312
|
): void {
|
|
285
313
|
let itemModelName = op.pagination?.itemType.kind === 'model' ? op.pagination.itemType.name : 'Item';
|
|
286
314
|
// Unwrap list wrapper models to match the fixture file naming in fixtures.ts
|
|
@@ -292,11 +320,15 @@ function renderPaginatedTest(
|
|
|
292
320
|
}
|
|
293
321
|
}
|
|
294
322
|
const pathArgs = buildTestPathArgs(op);
|
|
323
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
324
|
+
const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
|
|
325
|
+
const generatedItemType = ctx ? resolveInterfaceName(itemModelName, ctx) : null;
|
|
326
|
+
const skipFieldAssertions = Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
|
|
295
327
|
|
|
296
328
|
lines.push(" it('returns paginated results', async () => {");
|
|
297
329
|
lines.push(` fetchOnce(list${itemModelName}Fixture);`);
|
|
298
330
|
lines.push('');
|
|
299
|
-
lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${pathArgs});`);
|
|
331
|
+
lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${optionsArg ?? pathArgs});`);
|
|
300
332
|
lines.push('');
|
|
301
333
|
lines.push(" expect(fetchMethod()).toBe('GET');");
|
|
302
334
|
// Fix #12: Full URL path assertion instead of toContain()
|
|
@@ -308,7 +340,9 @@ function renderPaginatedTest(
|
|
|
308
340
|
|
|
309
341
|
// Assert on first item fields — use entity helper if available
|
|
310
342
|
const paginatedHelperName = ctx ? `expect${resolveInterfaceName(itemModelName, ctx)}` : null;
|
|
311
|
-
if (
|
|
343
|
+
if (skipFieldAssertions) {
|
|
344
|
+
lines.push(' expect(data.length).toBeGreaterThan(0);');
|
|
345
|
+
} else if (paginatedHelperName && entityHelpers?.has(paginatedHelperName)) {
|
|
312
346
|
lines.push(' expect(data.length).toBeGreaterThan(0);');
|
|
313
347
|
lines.push(` ${paginatedHelperName}(data[0]);`);
|
|
314
348
|
} else {
|
|
@@ -325,17 +359,6 @@ function renderPaginatedTest(
|
|
|
325
359
|
}
|
|
326
360
|
|
|
327
361
|
lines.push(' });');
|
|
328
|
-
|
|
329
|
-
// Edge case: handles empty results — use shared helper
|
|
330
|
-
lines.push('');
|
|
331
|
-
lines.push(` testEmptyResults(() => workos.${serviceProp}.${method}(${pathArgs}));`);
|
|
332
|
-
|
|
333
|
-
// Edge case: forwards pagination params — use shared helper
|
|
334
|
-
lines.push('');
|
|
335
|
-
lines.push(` testPaginationParams(`);
|
|
336
|
-
lines.push(` (opts) => workos.${serviceProp}.${method}(${pathArgs ? pathArgs + ', ' : ''}opts),`);
|
|
337
|
-
lines.push(` list${itemModelName}Fixture,`);
|
|
338
|
-
lines.push(' );');
|
|
339
362
|
}
|
|
340
363
|
|
|
341
364
|
function renderDeleteTest(
|
|
@@ -345,12 +368,15 @@ function renderDeleteTest(
|
|
|
345
368
|
method: string,
|
|
346
369
|
serviceProp: string,
|
|
347
370
|
modelMap: Map<string, Model>,
|
|
371
|
+
ctx?: EmitterContext,
|
|
372
|
+
baselineMethod?: BaselineMethod,
|
|
348
373
|
): void {
|
|
349
374
|
const pathArgs = buildTestPathArgs(op);
|
|
350
375
|
// Build realistic payload for body-bearing delete operations
|
|
351
376
|
const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
|
|
352
377
|
const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
|
|
353
|
-
const
|
|
378
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
379
|
+
const args = optionsArg ?? (plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs);
|
|
354
380
|
|
|
355
381
|
lines.push(" it('sends a DELETE request', async () => {");
|
|
356
382
|
lines.push(' fetchOnce({}, { status: 204 });');
|
|
@@ -380,6 +406,7 @@ function renderBodyTest(
|
|
|
380
406
|
modelMap: Map<string, Model>,
|
|
381
407
|
ctx?: EmitterContext,
|
|
382
408
|
entityHelpers?: Set<string>,
|
|
409
|
+
baselineMethod?: BaselineMethod,
|
|
383
410
|
): void {
|
|
384
411
|
const responseModelName = plan.responseModelName!;
|
|
385
412
|
const fixture = `${toCamelCase(responseModelName)}Fixture`;
|
|
@@ -388,10 +415,15 @@ function renderBodyTest(
|
|
|
388
415
|
// Build realistic payload from request body model fields
|
|
389
416
|
const payload = buildTestPayload(op, modelMap);
|
|
390
417
|
const payloadArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
|
|
391
|
-
const
|
|
418
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
419
|
+
const allArgs = optionsArg ?? (pathArgs ? `${pathArgs}, ${payloadArg}` : payloadArg);
|
|
420
|
+
|
|
421
|
+
const isArrayResponse = !!plan.isArrayResponse;
|
|
422
|
+
const fixtureExpr = isArrayResponse ? `[${fixture}]` : fixture;
|
|
423
|
+
const accessor = isArrayResponse ? 'result[0]' : 'result';
|
|
392
424
|
|
|
393
425
|
lines.push(" it('sends the correct request and returns result', async () => {");
|
|
394
|
-
lines.push(` fetchOnce(${
|
|
426
|
+
lines.push(` fetchOnce(${fixtureExpr});`);
|
|
395
427
|
lines.push('');
|
|
396
428
|
lines.push(` const result = await workos.${serviceProp}.${method}(${allArgs});`);
|
|
397
429
|
lines.push('');
|
|
@@ -408,14 +440,18 @@ function renderBodyTest(
|
|
|
408
440
|
lines.push(' expect(fetchBody()).toBeDefined();');
|
|
409
441
|
}
|
|
410
442
|
|
|
443
|
+
if (isArrayResponse) {
|
|
444
|
+
lines.push(' expect(Array.isArray(result)).toBe(true);');
|
|
445
|
+
}
|
|
446
|
+
|
|
411
447
|
// Use entity helper if available, otherwise inline assertions
|
|
412
448
|
const bodyHelperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
|
|
413
449
|
if (bodyHelperName && entityHelpers?.has(bodyHelperName)) {
|
|
414
|
-
lines.push(` ${bodyHelperName}(
|
|
450
|
+
lines.push(` ${bodyHelperName}(${accessor});`);
|
|
415
451
|
} else {
|
|
416
452
|
const responseModel = modelMap.get(responseModelName);
|
|
417
453
|
if (responseModel) {
|
|
418
|
-
const assertions = buildFieldAssertions(responseModel,
|
|
454
|
+
const assertions = buildFieldAssertions(responseModel, accessor, modelMap);
|
|
419
455
|
if (assertions.length > 0) {
|
|
420
456
|
for (const assertion of assertions) {
|
|
421
457
|
lines.push(` ${assertion}`);
|
|
@@ -440,29 +476,38 @@ function renderGetTest(
|
|
|
440
476
|
modelMap: Map<string, Model>,
|
|
441
477
|
ctx?: EmitterContext,
|
|
442
478
|
entityHelpers?: Set<string>,
|
|
479
|
+
baselineMethod?: BaselineMethod,
|
|
443
480
|
): void {
|
|
444
481
|
const responseModelName = plan.responseModelName!;
|
|
445
482
|
const fixture = `${toCamelCase(responseModelName)}Fixture`;
|
|
446
483
|
const pathArgs = buildTestPathArgs(op);
|
|
484
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
485
|
+
|
|
486
|
+
const isArrayResponse = !!plan.isArrayResponse;
|
|
487
|
+
const fixtureExpr = isArrayResponse ? `[${fixture}]` : fixture;
|
|
488
|
+
const accessor = isArrayResponse ? 'result[0]' : 'result';
|
|
447
489
|
|
|
448
490
|
lines.push(" it('returns the expected result', async () => {");
|
|
449
|
-
lines.push(` fetchOnce(${
|
|
491
|
+
lines.push(` fetchOnce(${fixtureExpr});`);
|
|
450
492
|
lines.push('');
|
|
451
|
-
lines.push(` const result = await workos.${serviceProp}.${method}(${pathArgs});`);
|
|
493
|
+
lines.push(` const result = await workos.${serviceProp}.${method}(${optionsArg ?? pathArgs});`);
|
|
452
494
|
lines.push('');
|
|
453
495
|
lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
|
|
454
496
|
// Fix #12: Full URL path assertion instead of toContain()
|
|
455
497
|
const expectedPathGet = buildExpectedPath(op);
|
|
456
498
|
lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPathGet}');`);
|
|
499
|
+
if (isArrayResponse) {
|
|
500
|
+
lines.push(' expect(Array.isArray(result)).toBe(true);');
|
|
501
|
+
}
|
|
457
502
|
|
|
458
503
|
// Use entity helper if available, otherwise inline assertions
|
|
459
504
|
const helperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
|
|
460
505
|
if (helperName && entityHelpers?.has(helperName)) {
|
|
461
|
-
lines.push(` ${helperName}(
|
|
506
|
+
lines.push(` ${helperName}(${accessor});`);
|
|
462
507
|
} else {
|
|
463
508
|
const responseModel = modelMap.get(responseModelName);
|
|
464
509
|
if (responseModel) {
|
|
465
|
-
const assertions = buildFieldAssertions(responseModel,
|
|
510
|
+
const assertions = buildFieldAssertions(responseModel, accessor, modelMap);
|
|
466
511
|
if (assertions.length > 0) {
|
|
467
512
|
for (const assertion of assertions) {
|
|
468
513
|
lines.push(` ${assertion}`);
|
|
@@ -485,12 +530,15 @@ function renderVoidTest(
|
|
|
485
530
|
method: string,
|
|
486
531
|
serviceProp: string,
|
|
487
532
|
modelMap: Map<string, Model>,
|
|
533
|
+
ctx?: EmitterContext,
|
|
534
|
+
baselineMethod?: BaselineMethod,
|
|
488
535
|
): void {
|
|
489
536
|
const pathArgs = buildTestPathArgs(op);
|
|
490
537
|
// Build realistic payload for body-bearing void operations
|
|
491
538
|
const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
|
|
492
539
|
const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
|
|
493
|
-
const
|
|
540
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
541
|
+
const args = optionsArg ?? (plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs);
|
|
494
542
|
|
|
495
543
|
lines.push(" it('sends the request', async () => {");
|
|
496
544
|
lines.push(' fetchOnce({});');
|
|
@@ -507,57 +555,44 @@ function renderVoidTest(
|
|
|
507
555
|
lines.push(' });');
|
|
508
556
|
}
|
|
509
557
|
|
|
510
|
-
function
|
|
511
|
-
lines: string[],
|
|
558
|
+
function buildOptionsObjectTestArg(
|
|
512
559
|
op: Operation,
|
|
513
560
|
plan: any,
|
|
514
|
-
|
|
515
|
-
serviceProp: string,
|
|
561
|
+
baselineMethod: BaselineMethod | undefined,
|
|
516
562
|
modelMap: Map<string, Model>,
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
lines.push(` testUnauthorized(() => workos.${serviceProp}.${method}(${args}));`);
|
|
522
|
-
|
|
523
|
-
// Add error-status tests based on the operation's error responses
|
|
524
|
-
const errorStatuses = new Set(op.errors.map((e) => e.statusCode));
|
|
563
|
+
ctx?: EmitterContext,
|
|
564
|
+
): string | null {
|
|
565
|
+
const optionParam = optionsObjectParam(baselineMethod);
|
|
566
|
+
if (!optionParam) return null;
|
|
525
567
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
|
|
532
|
-
lines.push(' });');
|
|
568
|
+
const entries: string[] = [];
|
|
569
|
+
for (const param of op.pathParams) {
|
|
570
|
+
const localName = fieldName(param.name);
|
|
571
|
+
const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx);
|
|
572
|
+
entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
|
|
533
573
|
}
|
|
534
574
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
lines.push(" it('throws UnprocessableEntityException on 422', async () => {");
|
|
539
|
-
lines.push(" fetchOnce('', { status: 422 });");
|
|
540
|
-
lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
|
|
541
|
-
lines.push(' });');
|
|
575
|
+
if (plan.hasBody) {
|
|
576
|
+
const payload = buildTestPayload(op, modelMap);
|
|
577
|
+
if (payload) entries.push(...objectLiteralEntries(payload.camelCaseObj));
|
|
542
578
|
}
|
|
579
|
+
|
|
580
|
+
return `{ ${entries.join(', ')} }`;
|
|
543
581
|
}
|
|
544
582
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const isPaginated = plan.isPaginated;
|
|
552
|
-
const hasBody = plan.hasBody;
|
|
583
|
+
function objectLiteralEntries(literal: string): string[] {
|
|
584
|
+
const trimmed = literal.trim();
|
|
585
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return [];
|
|
586
|
+
const body = trimmed.slice(1, -1).trim();
|
|
587
|
+
return body ? body.split(',').map((entry) => entry.trim()) : [];
|
|
588
|
+
}
|
|
553
589
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
return pathArgs || '';
|
|
590
|
+
function resolveOptionsObjectField(localName: string, optionType: string, ctx?: EmitterContext): string {
|
|
591
|
+
const fields = ctx?.apiSurface?.interfaces?.[optionType]?.fields;
|
|
592
|
+
if (!fields) return localName;
|
|
593
|
+
if (fields[localName]) return localName;
|
|
594
|
+
if (localName === 'omId' && fields.organizationMembershipId) return 'organizationMembershipId';
|
|
595
|
+
return localName;
|
|
561
596
|
}
|
|
562
597
|
|
|
563
598
|
/**
|
|
@@ -625,27 +660,37 @@ function generateEntityHelpers(
|
|
|
625
660
|
* nested models so we still get meaningful assertions instead of a bare
|
|
626
661
|
* `toBeDefined()`.
|
|
627
662
|
*/
|
|
663
|
+
function isDateTimeFieldType(type: TypeRef): boolean {
|
|
664
|
+
if (type.kind === 'primitive') return type.format === 'date-time';
|
|
665
|
+
if (type.kind === 'nullable') return isDateTimeFieldType(type.inner);
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
|
|
628
669
|
function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<string, Model>): string[] {
|
|
629
670
|
const assertions: string[] = [];
|
|
630
671
|
|
|
631
672
|
for (const field of model.fields) {
|
|
632
673
|
if (!field.required) continue;
|
|
674
|
+
const domainField = fieldName(field.name);
|
|
675
|
+
// `string` + `format: 'date-time'` is deserialized to `Date` by the
|
|
676
|
+
// serializer (see `mapPrimitive` in type-map.ts). Asserting against a
|
|
677
|
+
// string literal would fail Object.is — compare via `.toISOString()`.
|
|
678
|
+
const isDateTime = isDateTimeFieldType(field.type);
|
|
679
|
+
const fieldAccessor = isDateTime ? `${accessor}.${domainField}.toISOString()` : `${accessor}.${domainField}`;
|
|
633
680
|
// When a field has an example value, use it as the expected assertion value
|
|
634
681
|
if (field.example !== undefined) {
|
|
635
|
-
const domainField = fieldName(field.name);
|
|
636
682
|
if (typeof field.example === 'object' && field.example !== null) {
|
|
637
683
|
// Objects and arrays need toEqual with JSON serialization
|
|
638
684
|
assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
|
|
639
685
|
} else {
|
|
640
686
|
const exampleLiteral = typeof field.example === 'string' ? `'${field.example}'` : String(field.example);
|
|
641
|
-
assertions.push(`expect(${
|
|
687
|
+
assertions.push(`expect(${fieldAccessor}).toBe(${exampleLiteral});`);
|
|
642
688
|
}
|
|
643
689
|
continue;
|
|
644
690
|
}
|
|
645
691
|
const value = fixtureValueForType(field.type, field.name, model.name);
|
|
646
692
|
if (value === null) continue;
|
|
647
|
-
|
|
648
|
-
assertions.push(`expect(${accessor}.${domainField}).toBe(${value});`);
|
|
693
|
+
assertions.push(`expect(${fieldAccessor}).toBe(${value});`);
|
|
649
694
|
}
|
|
650
695
|
|
|
651
696
|
// When no primitive assertions were found (e.g. wrapper types like
|
|
@@ -848,6 +893,12 @@ function modelNeedsRoundTripTest(model: Model): boolean {
|
|
|
848
893
|
return model.fields.length > 0;
|
|
849
894
|
}
|
|
850
895
|
|
|
896
|
+
function fixtureIsHandOwned(fixturePath: string, ctx: EmitterContext): boolean {
|
|
897
|
+
const root = ctx.outputDir ?? ctx.targetDir;
|
|
898
|
+
if (!root) return false;
|
|
899
|
+
return fs.existsSync(path.join(root, fixturePath));
|
|
900
|
+
}
|
|
901
|
+
|
|
851
902
|
/**
|
|
852
903
|
* Generate serializer round-trip tests for models that have both serialize and
|
|
853
904
|
* deserialize functions and have nested types requiring non-trivial serialization.
|
|
@@ -867,14 +918,17 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
867
918
|
// Skip models unchanged from baseline (no new fields) since their serializers are not regenerated.
|
|
868
919
|
// Skip models unreachable from non-event services (no model/serializer files generated).
|
|
869
920
|
const nonEventReachable = computeNonEventReachable(spec.services, spec.models);
|
|
870
|
-
const
|
|
871
|
-
|
|
921
|
+
const generatedSerializerModels = (ctx as any)._generatedSerializerModels as Set<string> | undefined;
|
|
922
|
+
const eligibleModels = spec.models.filter((m) => {
|
|
923
|
+
const service = modelToService.get(m.name);
|
|
924
|
+
return (
|
|
872
925
|
nonEventReachable.has(m.name) &&
|
|
873
926
|
modelNeedsRoundTripTest(m) &&
|
|
874
927
|
!isListMetadataModel(m) &&
|
|
875
928
|
!isListWrapperModel(m) &&
|
|
876
|
-
modelHasNewFields(m, ctx),
|
|
877
|
-
|
|
929
|
+
(generatedSerializerModels?.has(m.name) ?? (modelHasNewFields(m, ctx) || isNodeOwnedService(ctx, service)))
|
|
930
|
+
);
|
|
931
|
+
});
|
|
878
932
|
|
|
879
933
|
if (eligibleModels.length === 0) return files;
|
|
880
934
|
|
|
@@ -887,6 +941,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
887
941
|
for (const model of eligibleModels) {
|
|
888
942
|
const service = modelToService.get(model.name);
|
|
889
943
|
const dirName = resolveDir(service);
|
|
944
|
+
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
|
|
945
|
+
if (!fixtureIsHandOwned(fixturePath, ctx)) continue;
|
|
890
946
|
if (!modelsByDir.has(dirName)) {
|
|
891
947
|
modelsByDir.set(dirName, []);
|
|
892
948
|
}
|
|
@@ -901,6 +957,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
901
957
|
const serializerImports: string[] = [];
|
|
902
958
|
const interfaceImports: string[] = [];
|
|
903
959
|
const fixtureImports: string[] = [];
|
|
960
|
+
const deserializeOnlyModels = new Set<string>();
|
|
904
961
|
|
|
905
962
|
for (const model of models) {
|
|
906
963
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
@@ -909,8 +966,10 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
909
966
|
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
910
967
|
const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
911
968
|
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
|
|
969
|
+
const deserializeOnly = serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx);
|
|
970
|
+
if (deserializeOnly) deserializeOnlyModels.add(model.name);
|
|
912
971
|
|
|
913
|
-
if (
|
|
972
|
+
if (deserializeOnly) {
|
|
914
973
|
serializerImports.push(
|
|
915
974
|
`import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
916
975
|
);
|
|
@@ -941,8 +1000,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
941
1000
|
const fixtureName = `${toCamelCase(domainName)}Fixture`;
|
|
942
1001
|
const wireName = wireInterfaceName(domainName);
|
|
943
1002
|
|
|
944
|
-
if (
|
|
945
|
-
// Deserialize-only test
|
|
1003
|
+
if (deserializeOnlyModels.has(model.name)) {
|
|
1004
|
+
// Deserialize-only test for hand-owned fixtures or models without a serializer.
|
|
946
1005
|
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
947
1006
|
lines.push(" it('deserializes correctly', () => {");
|
|
948
1007
|
lines.push(` const fixture = ${fixtureName} as ${wireName};`);
|
package/src/node/type-map.ts
CHANGED
|
@@ -3,24 +3,56 @@ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
|
3
3
|
import { wireInterfaceName } from './naming.js';
|
|
4
4
|
|
|
5
5
|
export interface MapTypeRefOpts {
|
|
6
|
-
/** Map from model name → default type args (e.g., `'<Record<string, unknown>>'`).
|
|
7
|
-
* When present, model refs for generic models get their defaults appended. */
|
|
8
6
|
genericDefaults?: Map<string, string>;
|
|
9
7
|
}
|
|
10
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Map of enum name → inlined string-union TS source.
|
|
11
|
+
*
|
|
12
|
+
* Set by `index.ts` once per generation run, sourced from `spec.enums` for
|
|
13
|
+
* enums that have no baseline definition in the live SDK. When populated,
|
|
14
|
+
* `mapTypeRef`/`mapWireTypeRef` substitute the union directly at the
|
|
15
|
+
* reference site instead of emitting a separate import — this collapses
|
|
16
|
+
* ~100 single-line enum files into inline literal types.
|
|
17
|
+
*/
|
|
18
|
+
let inlineEnumUnions: Map<string, string> = new Map();
|
|
19
|
+
export function setInlineEnumUnions(map: Map<string, string>): void {
|
|
20
|
+
inlineEnumUnions = map;
|
|
21
|
+
}
|
|
22
|
+
export function isInlineEnum(name: string): boolean {
|
|
23
|
+
return inlineEnumUnions.has(name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Optional callback that resolves an IR model name to its live-SDK interface
|
|
28
|
+
* name. Set by `index.ts` once per run. When present, `mapTypeRef` and
|
|
29
|
+
* `mapWireTypeRef` use it instead of the raw IR name in their `model:` cases
|
|
30
|
+
* — keeping field-type references in sync with import statements that the
|
|
31
|
+
* caller emits via the same resolver. Without this, a structural match like
|
|
32
|
+
* IR `AuditLogSchemaJson` → live `AuditLogSchemaResponse` would produce
|
|
33
|
+
* `schema: AuditLogSchemaJson` in the body but
|
|
34
|
+
* `import type { AuditLogSchemaResponse }` in the imports, leaving
|
|
35
|
+
* `AuditLogSchemaJson` unbound.
|
|
36
|
+
*/
|
|
37
|
+
let domainNameResolver: ((irName: string) => string) | null = null;
|
|
38
|
+
export function setDomainNameResolver(fn: ((irName: string) => string) | null): void {
|
|
39
|
+
domainNameResolver = fn;
|
|
40
|
+
}
|
|
41
|
+
function resolveDomainName(irName: string): string {
|
|
42
|
+
return domainNameResolver ? domainNameResolver(irName) : irName;
|
|
43
|
+
}
|
|
44
|
+
|
|
11
45
|
/**
|
|
12
46
|
* Map an IR TypeRef to a TypeScript domain type string.
|
|
13
47
|
* Domain types use PascalCase model names (e.g., `Organization`).
|
|
14
|
-
*
|
|
15
|
-
* @param opts.genericDefaults - When present, appends default type args to generic model refs.
|
|
16
48
|
*/
|
|
17
49
|
export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
|
|
18
50
|
const genericDefaults = opts?.genericDefaults;
|
|
19
51
|
return irMapTypeRef<string>(ref, {
|
|
20
52
|
primitive: mapPrimitive,
|
|
21
53
|
array: (_r, items) => `${parenthesizeUnion(items)}[]`,
|
|
22
|
-
model: (r) => r.name + (genericDefaults?.get(r.name) ?? ''),
|
|
23
|
-
enum: (r) => r.name,
|
|
54
|
+
model: (r) => resolveDomainName(r.name) + (genericDefaults?.get(r.name) ?? ''),
|
|
55
|
+
enum: (r) => inlineEnumUnions.get(r.name) ?? r.name,
|
|
24
56
|
union: (r, variants) => joinUnionVariants(r, variants),
|
|
25
57
|
nullable: (_r, inner) => `${inner} | null`,
|
|
26
58
|
literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
|
|
@@ -31,15 +63,14 @@ export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
|
|
|
31
63
|
/**
|
|
32
64
|
* Map an IR TypeRef to a TypeScript wire/response type string.
|
|
33
65
|
* Model references get the `Response` suffix (e.g., `OrganizationResponse`).
|
|
34
|
-
* Wire types use JSON-native types (string for date-time, number/string for int64).
|
|
35
66
|
*/
|
|
36
67
|
export function mapWireTypeRef(ref: TypeRef, opts?: { genericDefaults?: Map<string, string> }): string {
|
|
37
68
|
const genericDefaults = opts?.genericDefaults;
|
|
38
69
|
return irMapTypeRef<string>(ref, {
|
|
39
70
|
primitive: mapWirePrimitive,
|
|
40
71
|
array: (_r, items) => `${parenthesizeUnion(items)}[]`,
|
|
41
|
-
model: (r) => wireInterfaceName(r.name) + (genericDefaults?.get(r.name) ?? ''),
|
|
42
|
-
enum: (r) => r.name,
|
|
72
|
+
model: (r) => wireInterfaceName(resolveDomainName(r.name)) + (genericDefaults?.get(r.name) ?? ''),
|
|
73
|
+
enum: (r) => inlineEnumUnions.get(r.name) ?? r.name,
|
|
43
74
|
union: (r, variants) => joinUnionVariants(r, variants),
|
|
44
75
|
nullable: (_r, inner) => `${inner} | null`,
|
|
45
76
|
literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
|
|
@@ -69,10 +100,6 @@ function mapPrimitive(ref: PrimitiveType): string {
|
|
|
69
100
|
}
|
|
70
101
|
}
|
|
71
102
|
|
|
72
|
-
/**
|
|
73
|
-
* Map an IR PrimitiveType to a TypeScript wire/JSON type string.
|
|
74
|
-
* Wire types match JSON encoding: date-time stays string, int64 stays string/number.
|
|
75
|
-
*/
|
|
76
103
|
function mapWirePrimitive(ref: PrimitiveType): string {
|
|
77
104
|
switch (ref.type) {
|
|
78
105
|
case 'string':
|
|
@@ -87,10 +114,6 @@ function mapWirePrimitive(ref: PrimitiveType): string {
|
|
|
87
114
|
}
|
|
88
115
|
}
|
|
89
116
|
|
|
90
|
-
/**
|
|
91
|
-
* Join union variant type strings using the appropriate operator.
|
|
92
|
-
* allOf unions use `&` (intersection), oneOf/anyOf/unspecified use `|` (union).
|
|
93
|
-
*/
|
|
94
117
|
function joinUnionVariants(ref: UnionType, variants: string[]): string {
|
|
95
118
|
const unique = [...new Set(variants)];
|
|
96
119
|
if (ref.compositionKind === 'allOf') {
|
|
@@ -100,7 +123,6 @@ function joinUnionVariants(ref: UnionType, variants: string[]): string {
|
|
|
100
123
|
return unique.join(' | ');
|
|
101
124
|
}
|
|
102
125
|
|
|
103
|
-
/** Wrap union/intersection types in parentheses when used as array item type. */
|
|
104
126
|
function parenthesizeUnion(type: string): string {
|
|
105
127
|
return type.includes(' | ') || type.includes(' & ') ? `(${type})` : type;
|
|
106
128
|
}
|