@workos/oagen-emitters 0.12.1 → 0.12.2
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 +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-CmfzawTp.mjs → plugin-eCuvoL1T.mjs} +2508 -1474
- package/dist/plugin-eCuvoL1T.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 +345 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +540 -351
- package/src/node/naming.ts +119 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/resources.ts +455 -46
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +108 -83
- 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 +376 -2036
- package/test/node/tests.test.ts +119 -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,7 +102,8 @@ 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
|
|
@@ -104,7 +131,7 @@ function generateServiceTest(
|
|
|
104
131
|
mountAccessors?: Map<string, string>,
|
|
105
132
|
): GeneratedFile {
|
|
106
133
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
107
|
-
const serviceDir =
|
|
134
|
+
const serviceDir = resolveResourceDir(service, ctx);
|
|
108
135
|
const serviceClass = resolvedName;
|
|
109
136
|
const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
|
|
110
137
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
@@ -148,11 +175,6 @@ function generateServiceTest(
|
|
|
148
175
|
const testUtils = ['fetchOnce', 'fetchURL', 'fetchMethod'];
|
|
149
176
|
if (hasPaginated) testUtils.push('fetchSearchParams');
|
|
150
177
|
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
178
|
lines.push('import {');
|
|
157
179
|
for (const util of testUtils) {
|
|
158
180
|
lines.push(` ${util},`);
|
|
@@ -215,24 +237,20 @@ function generateServiceTest(
|
|
|
215
237
|
lines.push(' beforeEach(() => fetch.resetMocks());');
|
|
216
238
|
|
|
217
239
|
for (const { op, plan, method } of plans) {
|
|
240
|
+
const existingMethod = baselineMethodFor(service, method, ctx);
|
|
218
241
|
lines.push('');
|
|
219
242
|
lines.push(` describe('${method}', () => {`);
|
|
220
243
|
|
|
221
244
|
if (plan.isPaginated) {
|
|
222
|
-
renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
245
|
+
renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
|
|
223
246
|
} else if (plan.isDelete) {
|
|
224
|
-
renderDeleteTest(lines, op, plan, method, serviceProp, modelMap);
|
|
247
|
+
renderDeleteTest(lines, op, plan, method, serviceProp, modelMap, ctx, existingMethod);
|
|
225
248
|
} else if (plan.hasBody && plan.responseModelName) {
|
|
226
|
-
renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
249
|
+
renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
|
|
227
250
|
} else if (plan.responseModelName) {
|
|
228
|
-
renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
251
|
+
renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
|
|
229
252
|
} 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);
|
|
253
|
+
renderVoidTest(lines, op, plan, method, serviceProp, modelMap, ctx, existingMethod);
|
|
236
254
|
}
|
|
237
255
|
|
|
238
256
|
lines.push(' });');
|
|
@@ -281,6 +299,7 @@ function renderPaginatedTest(
|
|
|
281
299
|
modelMap: Map<string, Model>,
|
|
282
300
|
ctx?: EmitterContext,
|
|
283
301
|
entityHelpers?: Set<string>,
|
|
302
|
+
baselineMethod?: BaselineMethod,
|
|
284
303
|
): void {
|
|
285
304
|
let itemModelName = op.pagination?.itemType.kind === 'model' ? op.pagination.itemType.name : 'Item';
|
|
286
305
|
// Unwrap list wrapper models to match the fixture file naming in fixtures.ts
|
|
@@ -292,11 +311,15 @@ function renderPaginatedTest(
|
|
|
292
311
|
}
|
|
293
312
|
}
|
|
294
313
|
const pathArgs = buildTestPathArgs(op);
|
|
314
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
315
|
+
const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
|
|
316
|
+
const generatedItemType = ctx ? resolveInterfaceName(itemModelName, ctx) : null;
|
|
317
|
+
const skipFieldAssertions = Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
|
|
295
318
|
|
|
296
319
|
lines.push(" it('returns paginated results', async () => {");
|
|
297
320
|
lines.push(` fetchOnce(list${itemModelName}Fixture);`);
|
|
298
321
|
lines.push('');
|
|
299
|
-
lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${pathArgs});`);
|
|
322
|
+
lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${optionsArg ?? pathArgs});`);
|
|
300
323
|
lines.push('');
|
|
301
324
|
lines.push(" expect(fetchMethod()).toBe('GET');");
|
|
302
325
|
// Fix #12: Full URL path assertion instead of toContain()
|
|
@@ -308,7 +331,9 @@ function renderPaginatedTest(
|
|
|
308
331
|
|
|
309
332
|
// Assert on first item fields — use entity helper if available
|
|
310
333
|
const paginatedHelperName = ctx ? `expect${resolveInterfaceName(itemModelName, ctx)}` : null;
|
|
311
|
-
if (
|
|
334
|
+
if (skipFieldAssertions) {
|
|
335
|
+
lines.push(' expect(data.length).toBeGreaterThan(0);');
|
|
336
|
+
} else if (paginatedHelperName && entityHelpers?.has(paginatedHelperName)) {
|
|
312
337
|
lines.push(' expect(data.length).toBeGreaterThan(0);');
|
|
313
338
|
lines.push(` ${paginatedHelperName}(data[0]);`);
|
|
314
339
|
} else {
|
|
@@ -325,17 +350,6 @@ function renderPaginatedTest(
|
|
|
325
350
|
}
|
|
326
351
|
|
|
327
352
|
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
353
|
}
|
|
340
354
|
|
|
341
355
|
function renderDeleteTest(
|
|
@@ -345,12 +359,15 @@ function renderDeleteTest(
|
|
|
345
359
|
method: string,
|
|
346
360
|
serviceProp: string,
|
|
347
361
|
modelMap: Map<string, Model>,
|
|
362
|
+
ctx?: EmitterContext,
|
|
363
|
+
baselineMethod?: BaselineMethod,
|
|
348
364
|
): void {
|
|
349
365
|
const pathArgs = buildTestPathArgs(op);
|
|
350
366
|
// Build realistic payload for body-bearing delete operations
|
|
351
367
|
const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
|
|
352
368
|
const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
|
|
353
|
-
const
|
|
369
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
370
|
+
const args = optionsArg ?? (plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs);
|
|
354
371
|
|
|
355
372
|
lines.push(" it('sends a DELETE request', async () => {");
|
|
356
373
|
lines.push(' fetchOnce({}, { status: 204 });');
|
|
@@ -380,6 +397,7 @@ function renderBodyTest(
|
|
|
380
397
|
modelMap: Map<string, Model>,
|
|
381
398
|
ctx?: EmitterContext,
|
|
382
399
|
entityHelpers?: Set<string>,
|
|
400
|
+
baselineMethod?: BaselineMethod,
|
|
383
401
|
): void {
|
|
384
402
|
const responseModelName = plan.responseModelName!;
|
|
385
403
|
const fixture = `${toCamelCase(responseModelName)}Fixture`;
|
|
@@ -388,7 +406,8 @@ function renderBodyTest(
|
|
|
388
406
|
// Build realistic payload from request body model fields
|
|
389
407
|
const payload = buildTestPayload(op, modelMap);
|
|
390
408
|
const payloadArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
|
|
391
|
-
const
|
|
409
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
410
|
+
const allArgs = optionsArg ?? (pathArgs ? `${pathArgs}, ${payloadArg}` : payloadArg);
|
|
392
411
|
|
|
393
412
|
lines.push(" it('sends the correct request and returns result', async () => {");
|
|
394
413
|
lines.push(` fetchOnce(${fixture});`);
|
|
@@ -440,15 +459,17 @@ function renderGetTest(
|
|
|
440
459
|
modelMap: Map<string, Model>,
|
|
441
460
|
ctx?: EmitterContext,
|
|
442
461
|
entityHelpers?: Set<string>,
|
|
462
|
+
baselineMethod?: BaselineMethod,
|
|
443
463
|
): void {
|
|
444
464
|
const responseModelName = plan.responseModelName!;
|
|
445
465
|
const fixture = `${toCamelCase(responseModelName)}Fixture`;
|
|
446
466
|
const pathArgs = buildTestPathArgs(op);
|
|
467
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
447
468
|
|
|
448
469
|
lines.push(" it('returns the expected result', async () => {");
|
|
449
470
|
lines.push(` fetchOnce(${fixture});`);
|
|
450
471
|
lines.push('');
|
|
451
|
-
lines.push(` const result = await workos.${serviceProp}.${method}(${pathArgs});`);
|
|
472
|
+
lines.push(` const result = await workos.${serviceProp}.${method}(${optionsArg ?? pathArgs});`);
|
|
452
473
|
lines.push('');
|
|
453
474
|
lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
|
|
454
475
|
// Fix #12: Full URL path assertion instead of toContain()
|
|
@@ -485,12 +506,15 @@ function renderVoidTest(
|
|
|
485
506
|
method: string,
|
|
486
507
|
serviceProp: string,
|
|
487
508
|
modelMap: Map<string, Model>,
|
|
509
|
+
ctx?: EmitterContext,
|
|
510
|
+
baselineMethod?: BaselineMethod,
|
|
488
511
|
): void {
|
|
489
512
|
const pathArgs = buildTestPathArgs(op);
|
|
490
513
|
// Build realistic payload for body-bearing void operations
|
|
491
514
|
const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
|
|
492
515
|
const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
|
|
493
|
-
const
|
|
516
|
+
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
517
|
+
const args = optionsArg ?? (plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs);
|
|
494
518
|
|
|
495
519
|
lines.push(" it('sends the request', async () => {");
|
|
496
520
|
lines.push(' fetchOnce({});');
|
|
@@ -507,57 +531,44 @@ function renderVoidTest(
|
|
|
507
531
|
lines.push(' });');
|
|
508
532
|
}
|
|
509
533
|
|
|
510
|
-
function
|
|
511
|
-
lines: string[],
|
|
534
|
+
function buildOptionsObjectTestArg(
|
|
512
535
|
op: Operation,
|
|
513
536
|
plan: any,
|
|
514
|
-
|
|
515
|
-
serviceProp: string,
|
|
537
|
+
baselineMethod: BaselineMethod | undefined,
|
|
516
538
|
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));
|
|
539
|
+
ctx?: EmitterContext,
|
|
540
|
+
): string | null {
|
|
541
|
+
const optionParam = optionsObjectParam(baselineMethod);
|
|
542
|
+
if (!optionParam) return null;
|
|
525
543
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
|
|
532
|
-
lines.push(' });');
|
|
544
|
+
const entries: string[] = [];
|
|
545
|
+
for (const param of op.pathParams) {
|
|
546
|
+
const localName = fieldName(param.name);
|
|
547
|
+
const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx);
|
|
548
|
+
entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
|
|
533
549
|
}
|
|
534
550
|
|
|
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(' });');
|
|
551
|
+
if (plan.hasBody) {
|
|
552
|
+
const payload = buildTestPayload(op, modelMap);
|
|
553
|
+
if (payload) entries.push(...objectLiteralEntries(payload.camelCaseObj));
|
|
542
554
|
}
|
|
555
|
+
|
|
556
|
+
return `{ ${entries.join(', ')} }`;
|
|
543
557
|
}
|
|
544
558
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const isPaginated = plan.isPaginated;
|
|
552
|
-
const hasBody = plan.hasBody;
|
|
559
|
+
function objectLiteralEntries(literal: string): string[] {
|
|
560
|
+
const trimmed = literal.trim();
|
|
561
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return [];
|
|
562
|
+
const body = trimmed.slice(1, -1).trim();
|
|
563
|
+
return body ? body.split(',').map((entry) => entry.trim()) : [];
|
|
564
|
+
}
|
|
553
565
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
return pathArgs || '';
|
|
566
|
+
function resolveOptionsObjectField(localName: string, optionType: string, ctx?: EmitterContext): string {
|
|
567
|
+
const fields = ctx?.apiSurface?.interfaces?.[optionType]?.fields;
|
|
568
|
+
if (!fields) return localName;
|
|
569
|
+
if (fields[localName]) return localName;
|
|
570
|
+
if (localName === 'omId' && fields.organizationMembershipId) return 'organizationMembershipId';
|
|
571
|
+
return localName;
|
|
561
572
|
}
|
|
562
573
|
|
|
563
574
|
/**
|
|
@@ -848,6 +859,12 @@ function modelNeedsRoundTripTest(model: Model): boolean {
|
|
|
848
859
|
return model.fields.length > 0;
|
|
849
860
|
}
|
|
850
861
|
|
|
862
|
+
function fixtureIsHandOwned(fixturePath: string, ctx: EmitterContext): boolean {
|
|
863
|
+
const root = ctx.outputDir ?? ctx.targetDir;
|
|
864
|
+
if (!root) return false;
|
|
865
|
+
return fs.existsSync(path.join(root, fixturePath));
|
|
866
|
+
}
|
|
867
|
+
|
|
851
868
|
/**
|
|
852
869
|
* Generate serializer round-trip tests for models that have both serialize and
|
|
853
870
|
* deserialize functions and have nested types requiring non-trivial serialization.
|
|
@@ -867,14 +884,17 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
867
884
|
// Skip models unchanged from baseline (no new fields) since their serializers are not regenerated.
|
|
868
885
|
// Skip models unreachable from non-event services (no model/serializer files generated).
|
|
869
886
|
const nonEventReachable = computeNonEventReachable(spec.services, spec.models);
|
|
870
|
-
const
|
|
871
|
-
|
|
887
|
+
const generatedSerializerModels = (ctx as any)._generatedSerializerModels as Set<string> | undefined;
|
|
888
|
+
const eligibleModels = spec.models.filter((m) => {
|
|
889
|
+
const service = modelToService.get(m.name);
|
|
890
|
+
return (
|
|
872
891
|
nonEventReachable.has(m.name) &&
|
|
873
892
|
modelNeedsRoundTripTest(m) &&
|
|
874
893
|
!isListMetadataModel(m) &&
|
|
875
894
|
!isListWrapperModel(m) &&
|
|
876
|
-
modelHasNewFields(m, ctx),
|
|
877
|
-
|
|
895
|
+
(generatedSerializerModels?.has(m.name) ?? (modelHasNewFields(m, ctx) || isNodeOwnedService(ctx, service)))
|
|
896
|
+
);
|
|
897
|
+
});
|
|
878
898
|
|
|
879
899
|
if (eligibleModels.length === 0) return files;
|
|
880
900
|
|
|
@@ -887,6 +907,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
887
907
|
for (const model of eligibleModels) {
|
|
888
908
|
const service = modelToService.get(model.name);
|
|
889
909
|
const dirName = resolveDir(service);
|
|
910
|
+
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
|
|
911
|
+
if (!fixtureIsHandOwned(fixturePath, ctx)) continue;
|
|
890
912
|
if (!modelsByDir.has(dirName)) {
|
|
891
913
|
modelsByDir.set(dirName, []);
|
|
892
914
|
}
|
|
@@ -901,6 +923,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
901
923
|
const serializerImports: string[] = [];
|
|
902
924
|
const interfaceImports: string[] = [];
|
|
903
925
|
const fixtureImports: string[] = [];
|
|
926
|
+
const deserializeOnlyModels = new Set<string>();
|
|
904
927
|
|
|
905
928
|
for (const model of models) {
|
|
906
929
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
@@ -909,8 +932,10 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
909
932
|
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
910
933
|
const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
911
934
|
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
|
|
935
|
+
const deserializeOnly = serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx);
|
|
936
|
+
if (deserializeOnly) deserializeOnlyModels.add(model.name);
|
|
912
937
|
|
|
913
|
-
if (
|
|
938
|
+
if (deserializeOnly) {
|
|
914
939
|
serializerImports.push(
|
|
915
940
|
`import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
916
941
|
);
|
|
@@ -941,8 +966,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
941
966
|
const fixtureName = `${toCamelCase(domainName)}Fixture`;
|
|
942
967
|
const wireName = wireInterfaceName(domainName);
|
|
943
968
|
|
|
944
|
-
if (
|
|
945
|
-
// Deserialize-only test
|
|
969
|
+
if (deserializeOnlyModels.has(model.name)) {
|
|
970
|
+
// Deserialize-only test for hand-owned fixtures or models without a serializer.
|
|
946
971
|
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
947
972
|
lines.push(" it('deserializes correctly', () => {");
|
|
948
973
|
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
|
}
|