@workos/oagen-emitters 0.17.0 → 0.18.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BLnR-FMi.mjs → plugin-CtU_wbid.mjs} +182 -49
- package/dist/plugin-CtU_wbid.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/src/node/client.ts +2 -2
- package/src/node/discriminated-models.ts +197 -50
- package/src/node/models.ts +44 -5
- package/src/node/options.ts +23 -0
- package/src/node/resources.ts +56 -12
- package/src/node/tests.ts +5 -3
- package/test/node/discriminated-pure-oneof.test.ts +108 -0
- package/dist/plugin-BLnR-FMi.mjs.map +0 -1
package/src/node/resources.ts
CHANGED
|
@@ -303,9 +303,26 @@ function optionsObjectInfo(
|
|
|
303
303
|
|
|
304
304
|
if (!operationHasOptionsInput(op, plan, resolvedOp)) return undefined;
|
|
305
305
|
|
|
306
|
+
const resolvedService = resolveResourceClassName(service, ctx);
|
|
307
|
+
let optionsType = methodOptionsName(method, resolvedService);
|
|
308
|
+
// A bare `${Method}Options` name (e.g. `GetGroupOptions`) can collide with a
|
|
309
|
+
// same-named baseline type owned by a DIFFERENT service (for example the
|
|
310
|
+
// Groups module's `GetGroupOptions`, shaped `{ organizationId, groupId }`).
|
|
311
|
+
// Reusing it by name would import a foreign shape. When the existing
|
|
312
|
+
// declaration lives in another service directory, qualify the name with the
|
|
313
|
+
// service (mirroring the `list` -> `${Service}ListOptions` rule) so this
|
|
314
|
+
// service generates and imports its own options type.
|
|
315
|
+
if (method !== 'list') {
|
|
316
|
+
const baselineFile = baselineTypeSourceFile(ctx, optionsType);
|
|
317
|
+
const baselineDir = baselineFile?.match(/^src\/([^/]+)\//)?.[1];
|
|
318
|
+
if (baselineDir && baselineDir !== resolveResourceDir(service, ctx)) {
|
|
319
|
+
optionsType = `${toPascalCase(resolvedService)}${toPascalCase(method)}Options`;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
306
323
|
return {
|
|
307
324
|
name: 'options',
|
|
308
|
-
type:
|
|
325
|
+
type: optionsType,
|
|
309
326
|
optional: optionsObjectShouldBeOptional(op, plan, resolvedOp),
|
|
310
327
|
generated: true,
|
|
311
328
|
};
|
|
@@ -327,6 +344,18 @@ function isValidTypeIdentifier(name: string): boolean {
|
|
|
327
344
|
return /^[A-Za-z_$][\w$]*$/.test(name);
|
|
328
345
|
}
|
|
329
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Extract candidate named type references from a compound type expression such
|
|
349
|
+
* as `GetAccessTokenOptions & { provider: string }`. PascalCase tokens are type
|
|
350
|
+
* names worth importing; lowercase tokens are property keys or primitives and
|
|
351
|
+
* are skipped. The caller only imports the ones that resolve to a known source
|
|
352
|
+
* file, so unrecognized PascalCase tokens (e.g. `Date`, `Record`) are harmless.
|
|
353
|
+
*/
|
|
354
|
+
function extractNamedTypeRefs(typeExpr: string): string[] {
|
|
355
|
+
const matches = typeExpr.match(/\b[A-Z][A-Za-z0-9_$]*\b/g) ?? [];
|
|
356
|
+
return [...new Set(matches)];
|
|
357
|
+
}
|
|
358
|
+
|
|
330
359
|
function autoPaginatableItemType(returnType: string | undefined): string | undefined {
|
|
331
360
|
// Match both AutoPaginatable<T> and the legacy List<T> pattern so baseline
|
|
332
361
|
// item types are extracted even when the hand-written code predates AutoPaginatable.
|
|
@@ -964,16 +993,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
964
993
|
|
|
965
994
|
const importedTypeNames = new Set<string>();
|
|
966
995
|
for (const optionType of optionObjectTypes) {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
996
|
+
if (isValidTypeIdentifier(optionType)) {
|
|
997
|
+
if (importedTypeNames.has(optionType)) continue;
|
|
998
|
+
importedTypeNames.add(optionType);
|
|
999
|
+
const sourceFile = baselineTypeSourceFile(ctx, optionType);
|
|
1000
|
+
const relPath = sourceFile
|
|
1001
|
+
? relativeImport(resourcePath, sourceFile)
|
|
1002
|
+
: `./interfaces/${fileName(optionType)}.interface`;
|
|
1003
|
+
lines.push(`import type { ${optionType} } from '${relPath}';`);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
// Compound option types (e.g. a baseline `GetAccessTokenOptions & { provider:
|
|
1007
|
+
// string }`) keep their inline object literal inline, but the named type(s)
|
|
1008
|
+
// they reference must still be imported. Only import names that resolve to a
|
|
1009
|
+
// known source file — inline literals, primitives, and property keys have no
|
|
1010
|
+
// importable source and are skipped.
|
|
1011
|
+
for (const typeName of extractNamedTypeRefs(optionType)) {
|
|
1012
|
+
if (importedTypeNames.has(typeName)) continue;
|
|
1013
|
+
const sourceFile = baselineTypeSourceFile(ctx, typeName) ?? liveSurfaceInterfacePath(typeName);
|
|
1014
|
+
if (!sourceFile) continue;
|
|
1015
|
+
importedTypeNames.add(typeName);
|
|
1016
|
+
lines.push(`import type { ${typeName} } from '${relativeImport(resourcePath, sourceFile)}';`);
|
|
1017
|
+
}
|
|
977
1018
|
}
|
|
978
1019
|
for (const typeName of returnTypeImports) {
|
|
979
1020
|
if (importedTypeNames.has(typeName)) continue;
|
|
@@ -1170,7 +1211,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
1170
1211
|
const baselineMethod = baselineMethodFor(service, method, ctx);
|
|
1171
1212
|
const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolved);
|
|
1172
1213
|
if (plan.isPaginated) {
|
|
1173
|
-
|
|
1214
|
+
// Skip deprecated query params: the typed options interface omits them
|
|
1215
|
+
// (the curated baseline drops deprecated params), so the wire serializer
|
|
1216
|
+
// must not reference a field that does not exist on the options type.
|
|
1217
|
+
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name) && !p.deprecated);
|
|
1174
1218
|
if (extraParams.length > 0) {
|
|
1175
1219
|
const optionsName = optionInfo?.type ?? paginatedOptionsName(method, resolvedName);
|
|
1176
1220
|
|
package/src/node/tests.ts
CHANGED
|
@@ -670,9 +670,11 @@ function buildOptionsObjectTestArg(
|
|
|
670
670
|
if (plan.isPaginated) {
|
|
671
671
|
entries.push("order: 'desc'");
|
|
672
672
|
}
|
|
673
|
-
const queryParams =
|
|
674
|
-
|
|
675
|
-
|
|
673
|
+
const queryParams = (
|
|
674
|
+
plan.isPaginated
|
|
675
|
+
? op.queryParams.filter((param) => !['limit', 'before', 'after', 'order'].includes(param.name))
|
|
676
|
+
: op.queryParams
|
|
677
|
+
).filter((param) => !param.deprecated);
|
|
676
678
|
for (const param of queryParams) {
|
|
677
679
|
const localName = fieldName(param.name);
|
|
678
680
|
const value = queryParamTestValue(param, modelMap);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { EmitterContext, ApiSpec } from '@workos/oagen';
|
|
3
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
+
import {
|
|
5
|
+
detectDiscriminatedShape,
|
|
6
|
+
generateDiscriminatedFiles,
|
|
7
|
+
type DiscriminatedPlan,
|
|
8
|
+
} from '../../src/node/discriminated-models.js';
|
|
9
|
+
|
|
10
|
+
const emptySpec: ApiSpec = {
|
|
11
|
+
name: 'Test',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
baseUrl: '',
|
|
14
|
+
services: [],
|
|
15
|
+
models: [],
|
|
16
|
+
enums: [],
|
|
17
|
+
sdk: defaultSdkBehavior(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ctx: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: emptySpec };
|
|
21
|
+
|
|
22
|
+
// A pure `oneOf` discriminated by a boolean `active` const — the Pipes token
|
|
23
|
+
// response shape that previously collapsed into a flat all-optional interface.
|
|
24
|
+
// `RawSchema` is internal to the emitter; the loose typing mirrors the raw
|
|
25
|
+
// `components.schemas` shape `detectDiscriminatedShape` consumes at runtime.
|
|
26
|
+
const rawSchemas: Record<string, any> = {
|
|
27
|
+
DataIntegrationAccessTokenResponse: {
|
|
28
|
+
oneOf: [
|
|
29
|
+
{
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
active: { type: 'boolean', const: true },
|
|
33
|
+
access_token: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
object: { type: 'string', const: 'access_token' },
|
|
37
|
+
access_token: { type: 'string' },
|
|
38
|
+
expires_at: { type: ['string', 'null'], format: 'date-time' },
|
|
39
|
+
scopes: { type: 'array', items: { type: 'string' } },
|
|
40
|
+
missing_scopes: { type: 'array', items: { type: 'string' } },
|
|
41
|
+
},
|
|
42
|
+
required: ['object', 'access_token', 'expires_at', 'scopes', 'missing_scopes'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ['active', 'access_token'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
active: { type: 'boolean', const: false },
|
|
51
|
+
error: { type: 'string', enum: ['not_installed', 'needs_reauthorization'] },
|
|
52
|
+
},
|
|
53
|
+
required: ['active', 'error'],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe('detectDiscriminatedShape — pure oneOf with boolean discriminator', () => {
|
|
60
|
+
it('detects a two-variant inline union keyed on the boolean `active`', () => {
|
|
61
|
+
const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas);
|
|
62
|
+
expect(shape).not.toBeNull();
|
|
63
|
+
expect(shape!.inlineUnion).toBe(true);
|
|
64
|
+
expect(shape!.discriminatorProperty).toBe('active');
|
|
65
|
+
expect(shape!.variants).toHaveLength(2);
|
|
66
|
+
expect(shape!.variants.map((v) => v.discriminatorValue).sort()).toEqual(['false', 'true']);
|
|
67
|
+
expect(shape!.variants.every((v) => v.discriminatorIsBoolean)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('emits a discriminated union interface (not a flat optional bag)', () => {
|
|
71
|
+
const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas)!;
|
|
72
|
+
const plan: DiscriminatedPlan = { shape, modelDir: 'pipes', depDirMap: new Map() };
|
|
73
|
+
const files = generateDiscriminatedFiles(new Map([['DataIntegrationAccessTokenResponse', plan]]), ctx);
|
|
74
|
+
|
|
75
|
+
const iface = files.find((f) => f.path.endsWith('.interface.ts'))!;
|
|
76
|
+
expect(iface).toBeDefined();
|
|
77
|
+
// Union alias, two variants, boolean discriminator (unquoted), required fields.
|
|
78
|
+
expect(iface.content).toContain('export type DataIntegrationAccessTokenResponse =');
|
|
79
|
+
expect(iface.content).toContain('active: true');
|
|
80
|
+
expect(iface.content).toContain('active: false');
|
|
81
|
+
expect(iface.content).toContain('accessToken: DataIntegrationAccessTokenResponseAccessToken');
|
|
82
|
+
expect(iface.content).toContain("error: 'not_installed' | 'needs_reauthorization'");
|
|
83
|
+
// No optional discriminator — narrowing must work.
|
|
84
|
+
expect(iface.content).not.toContain('active?: true');
|
|
85
|
+
|
|
86
|
+
const ser = files.find((f) => f.path.endsWith('.serializer.ts'))!;
|
|
87
|
+
expect(ser.content).toContain('switch (response.active)');
|
|
88
|
+
expect(ser.content).toContain('case true:');
|
|
89
|
+
expect(ser.content).toContain('case false:');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('resolves a cross-service inline-object dep to a relative import path', () => {
|
|
93
|
+
// The nested `access_token` object is a synthetic IR model. Its dep is
|
|
94
|
+
// carried in snake form (`Parent_field`) but keyed in depDirMap under the
|
|
95
|
+
// PascalCase IR name. When that model resolves to a different service dir,
|
|
96
|
+
// collectImports must emit a cross-service path rather than defaulting to
|
|
97
|
+
// a same-dir import.
|
|
98
|
+
const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas)!;
|
|
99
|
+
const depDirMap = new Map<string, string>([['DataIntegrationAccessTokenResponseAccessToken', 'connect']]);
|
|
100
|
+
const plan: DiscriminatedPlan = { shape, modelDir: 'pipes', depDirMap };
|
|
101
|
+
const files = generateDiscriminatedFiles(new Map([['DataIntegrationAccessTokenResponse', plan]]), ctx);
|
|
102
|
+
|
|
103
|
+
const iface = files.find((f) => f.path.endsWith('.interface.ts'))!;
|
|
104
|
+
expect(iface.content).toContain(
|
|
105
|
+
"from '../../connect/interfaces/data-integration-access-token-response-access-token.interface'",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|