@workos/oagen-emitters 0.18.2 → 0.18.4
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 +17 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-bqfwowQ3.mjs → plugin-Cciic50q.mjs} +457 -101
- package/dist/plugin-Cciic50q.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +2 -2
- package/package.json +3 -3
- package/src/dotnet/fixtures.ts +17 -3
- package/src/dotnet/index.ts +2 -1
- package/src/dotnet/models.ts +30 -5
- package/src/dotnet/naming.ts +10 -0
- package/src/dotnet/tests.ts +5 -1
- package/src/go/fixtures.ts +4 -2
- package/src/go/index.ts +4 -0
- package/src/go/models.ts +4 -2
- package/src/go/naming.ts +10 -0
- package/src/go/resources.ts +19 -6
- package/src/kotlin/index.ts +2 -1
- package/src/kotlin/models.ts +5 -2
- package/src/kotlin/naming.ts +11 -0
- package/src/kotlin/tests.ts +5 -1
- package/src/node/field-plan.ts +3 -3
- package/src/node/index.ts +2 -1
- package/src/node/models.ts +40 -1
- package/src/node/naming.ts +10 -0
- package/src/node/options.ts +45 -1
- package/src/node/resources.ts +76 -19
- package/src/node/tests.ts +296 -30
- package/src/php/index.ts +2 -1
- package/src/php/models.ts +11 -5
- package/src/php/naming.ts +10 -0
- package/src/php/tests.ts +11 -2
- package/src/python/fixtures.ts +4 -3
- package/src/python/index.ts +2 -1
- package/src/python/models.ts +12 -6
- package/src/python/naming.ts +10 -0
- package/src/python/tests.ts +11 -6
- package/src/ruby/index.ts +2 -1
- package/src/ruby/models.ts +10 -7
- package/src/ruby/naming.ts +10 -0
- package/src/ruby/rbi.ts +3 -1
- package/src/ruby/tests.ts +4 -1
- package/src/rust/index.ts +2 -1
- package/src/rust/models.ts +87 -15
- package/src/rust/naming.ts +10 -0
- package/src/rust/resources.ts +6 -2
- package/src/shared/file-header.ts +13 -0
- package/test/node/resources.test.ts +31 -2
- package/test/rust/models.test.ts +49 -0
- package/dist/plugin-bqfwowQ3.mjs.map +0 -1
package/src/node/resources.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
Model,
|
|
12
12
|
ResolvedOperation,
|
|
13
13
|
} from '@workos/oagen';
|
|
14
|
-
import {
|
|
14
|
+
import { toPascalCase, toCamelCase } from '@workos/oagen';
|
|
15
15
|
import type { OperationPlan } from '@workos/oagen';
|
|
16
16
|
import { mapTypeRef, isInlineEnum } from './type-map.js';
|
|
17
17
|
import {
|
|
@@ -84,7 +84,7 @@ import {
|
|
|
84
84
|
import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
|
|
85
85
|
import { buildNodePathExpression } from './path-expression.js';
|
|
86
86
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
87
|
-
import { isNodeOwnedService,
|
|
87
|
+
import { isNodeOwnedService, operationOverrideFor, planOperationFor } from './options.js';
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Check whether the baseline (hand-written) class has a constructor compatible
|
|
@@ -202,16 +202,12 @@ function existingInterfaceBarrelExports(ctx: EmitterContext, serviceDir: string,
|
|
|
202
202
|
return new RegExp(`export\\s+(?:type\\s+)?(?:\\*|\\{[^}]+\\})\\s+from\\s+['"]\\./${escapedStem}['"]`).test(content);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
function operationOverrideFor(ctx: EmitterContext, op: Operation) {
|
|
206
|
-
return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
|
|
207
|
-
}
|
|
208
|
-
|
|
209
205
|
function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
|
|
210
206
|
const serviceClass = resolveResourceClassName(service, ctx);
|
|
211
207
|
return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
|
|
212
208
|
}
|
|
213
209
|
|
|
214
|
-
function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string): Set<string> {
|
|
210
|
+
export function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string): Set<string> {
|
|
215
211
|
const root = ctx.outputDir ?? ctx.targetDir;
|
|
216
212
|
if (!root) return new Set();
|
|
217
213
|
|
|
@@ -242,8 +238,13 @@ function optionsObjectParam(method: BaselineMethod | undefined): OptionsObjectPa
|
|
|
242
238
|
const [param] = method.params;
|
|
243
239
|
if (param.name !== 'options') return undefined;
|
|
244
240
|
if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
// An optional param's surface type is `Options | undefined`; the optionality
|
|
242
|
+
// is carried by `param.optional`, so strip the nullable arm to recover the
|
|
243
|
+
// bare type NAME (used for `serialize${type}` + imports). Leaving it in emits
|
|
244
|
+
// `serializeOptions | undefined` — a syntax error.
|
|
245
|
+
const type = param.type?.replace(/(?:\s*\|\s*(?:undefined|null))+\s*$/, '').trim();
|
|
246
|
+
if (!type || /^(Record|object|any|unknown)\b/.test(type)) return undefined;
|
|
247
|
+
return { name: 'options', type, optional: param.optional === true, generated: false };
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
function methodOptionsName(method: string, resolvedServiceName: string): string {
|
|
@@ -279,6 +280,24 @@ function operationHasOptionsInput(op: Operation, plan: OperationPlan, resolvedOp
|
|
|
279
280
|
);
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
// True only when the type is a SINGLE closed object literal (`{ ... }`), not
|
|
284
|
+
// the head of a compound type such as `{ ... } & X` or `{ ... } | Y`. Counting
|
|
285
|
+
// brace depth locates the literal's matching close brace (handling nesting like
|
|
286
|
+
// `{ a: { b: string } }`); any non-whitespace after it means the type is not a
|
|
287
|
+
// pure literal and must be preserved verbatim rather than replaced by a named
|
|
288
|
+
// request interface.
|
|
289
|
+
function isClosedObjectLiteral(type: string): boolean {
|
|
290
|
+
const t = type.trim();
|
|
291
|
+
if (!t.startsWith('{')) return false;
|
|
292
|
+
let depth = 0;
|
|
293
|
+
for (let i = 0; i < t.length; i++) {
|
|
294
|
+
const ch = t[i];
|
|
295
|
+
if (ch === '{') depth++;
|
|
296
|
+
else if (ch === '}' && --depth === 0) return i === t.length - 1;
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
282
301
|
function optionsObjectInfo(
|
|
283
302
|
service: Service,
|
|
284
303
|
method: string,
|
|
@@ -300,9 +319,10 @@ function optionsObjectInfo(
|
|
|
300
319
|
// operation owns a named request-body model, adopt that interface so the
|
|
301
320
|
// method signature, the serializer, and the request model all agree.
|
|
302
321
|
// Named baseline types (`CreateOrganizationApiKeyOptions`) and compound
|
|
303
|
-
// intersections (`X & { ... }`) are still preserved
|
|
322
|
+
// intersections (`X & { ... }` or `{ ... } & X`) are still preserved
|
|
323
|
+
// verbatim — only a single, closed object literal is eligible for adoption.
|
|
304
324
|
if (
|
|
305
|
-
baseline.type
|
|
325
|
+
isClosedObjectLiteral(baseline.type) &&
|
|
306
326
|
isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx))
|
|
307
327
|
) {
|
|
308
328
|
const body = extractRequestBodyType(op, ctx);
|
|
@@ -549,7 +569,7 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
549
569
|
|
|
550
570
|
const plans = service.operations.map((op) => ({
|
|
551
571
|
op,
|
|
552
|
-
plan:
|
|
572
|
+
plan: planOperationFor(op, ctx),
|
|
553
573
|
method: resolveMethodName(op, service, ctx),
|
|
554
574
|
}));
|
|
555
575
|
|
|
@@ -558,7 +578,13 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
558
578
|
const baselineMethod = baselineMethodFor(service, method, ctx);
|
|
559
579
|
const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolvedOp);
|
|
560
580
|
if (!optionInfo?.generated) continue;
|
|
561
|
-
|
|
581
|
+
// A baseline type of the same name normally means "hand-owned / preserved —
|
|
582
|
+
// do not regenerate" (guards the alias-feedback loop). But when the op has
|
|
583
|
+
// path params, the modernized resource folds them INTO the options object
|
|
584
|
+
// (`const { organizationId, ...payload } = options`), so a body-only
|
|
585
|
+
// baseline interface (from a legacy `(pathParam, options)` signature) is
|
|
586
|
+
// definitionally incompatible. Regenerate it to include the path params.
|
|
587
|
+
if (op.pathParams.length === 0 && baselineTypeSourceFile(ctx, optionInfo.type)) continue;
|
|
562
588
|
|
|
563
589
|
const optionsName = optionInfo.type;
|
|
564
590
|
const optionFileStem = `${fileName(optionsName)}.interface`;
|
|
@@ -626,9 +652,10 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
626
652
|
headerParts.push(` ${name}${opt}: ${type};`);
|
|
627
653
|
};
|
|
628
654
|
|
|
655
|
+
const optionsPathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
629
656
|
for (const param of op.pathParams) {
|
|
630
657
|
pushField(
|
|
631
|
-
fieldName(param.name),
|
|
658
|
+
optionsPathFieldMap?.[fieldName(param.name)] ?? fieldName(param.name),
|
|
632
659
|
true,
|
|
633
660
|
mapParamType(param.type, specEnumNames),
|
|
634
661
|
param.description,
|
|
@@ -760,7 +787,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
760
787
|
|
|
761
788
|
let plans = service.operations.map((op) => ({
|
|
762
789
|
op,
|
|
763
|
-
plan:
|
|
790
|
+
plan: planOperationFor(op, ctx),
|
|
764
791
|
method: resolveMethodName(op, service, ctx),
|
|
765
792
|
}));
|
|
766
793
|
|
|
@@ -1019,6 +1046,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
1019
1046
|
}
|
|
1020
1047
|
|
|
1021
1048
|
const importedTypeNames = new Set<string>();
|
|
1049
|
+
// `PaginationOptions` is already imported once above when any method
|
|
1050
|
+
// paginates; a method whose options type IS `PaginationOptions` would
|
|
1051
|
+
// otherwise re-import it here (TS2300 duplicate identifier).
|
|
1052
|
+
if (needsPaginationOptionsImport) importedTypeNames.add('PaginationOptions');
|
|
1022
1053
|
for (const optionType of optionObjectTypes) {
|
|
1023
1054
|
if (isValidTypeIdentifier(optionType)) {
|
|
1024
1055
|
if (importedTypeNames.has(optionType)) continue;
|
|
@@ -1935,7 +1966,20 @@ function renderOptionsObjectMethod(
|
|
|
1935
1966
|
return true;
|
|
1936
1967
|
}
|
|
1937
1968
|
|
|
1938
|
-
|
|
1969
|
+
// Body-less, response-less mutation with path params and/or query folded into
|
|
1970
|
+
// the options object (e.g. POST /feature-flags/{slug}/targets/{targetId} -> 204).
|
|
1971
|
+
// The DELETE equivalent is handled above; this covers POST/PUT/PATCH (and any
|
|
1972
|
+
// other verb) that returns no content. Without this branch such operations
|
|
1973
|
+
// fall through to the positional renderVoidMethod, which is inconsistent with
|
|
1974
|
+
// the options-object surface the rest of an owned service uses.
|
|
1975
|
+
{
|
|
1976
|
+
lines.push(` async ${method}(${renderOptionsParam(optionParam)}): Promise<void> {`);
|
|
1977
|
+
renderOptionsObjectDestructure(lines, pathBindings);
|
|
1978
|
+
const emptyBodyArg = httpMethodNeedsBody(op.httpMethod) ? ', {}' : '';
|
|
1979
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}${emptyBodyArg}${queryOptionsArg});`);
|
|
1980
|
+
lines.push(' }');
|
|
1981
|
+
return true;
|
|
1982
|
+
}
|
|
1939
1983
|
}
|
|
1940
1984
|
|
|
1941
1985
|
function renderOptionsObjectDestructure(lines: string[], pathBindings: string[], restName?: string): void {
|
|
@@ -1969,7 +2013,8 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
|
|
|
1969
2013
|
// Return resolved SDK field names directly — the URL template uses these
|
|
1970
2014
|
// names too (via the param-name map threaded into buildNodePathExpression),
|
|
1971
2015
|
// so the destructure no longer needs `optionField: localName` renames.
|
|
1972
|
-
|
|
2016
|
+
const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
2017
|
+
return op.pathParams.map((param) => resolveOptionsObjectField(fieldName(param.name), optionType, ctx, pathFieldMap));
|
|
1973
2018
|
}
|
|
1974
2019
|
|
|
1975
2020
|
/**
|
|
@@ -1981,15 +2026,27 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
|
|
|
1981
2026
|
*/
|
|
1982
2027
|
function buildOptionsObjectPathParamMap(op: Operation, optionType: string, ctx: EmitterContext): Map<string, string> {
|
|
1983
2028
|
const map = new Map<string, string>();
|
|
2029
|
+
const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
1984
2030
|
for (const param of op.pathParams) {
|
|
1985
2031
|
const localName = fieldName(param.name);
|
|
1986
|
-
const sdkField = resolveOptionsObjectField(localName, optionType, ctx);
|
|
2032
|
+
const sdkField = resolveOptionsObjectField(localName, optionType, ctx, pathFieldMap);
|
|
1987
2033
|
if (sdkField !== localName) map.set(param.name, sdkField);
|
|
1988
2034
|
}
|
|
1989
2035
|
return map;
|
|
1990
2036
|
}
|
|
1991
2037
|
|
|
1992
|
-
function resolveOptionsObjectField(
|
|
2038
|
+
function resolveOptionsObjectField(
|
|
2039
|
+
localName: string,
|
|
2040
|
+
optionType: string,
|
|
2041
|
+
ctx: EmitterContext,
|
|
2042
|
+
pathFieldMap?: Record<string, string>,
|
|
2043
|
+
): string {
|
|
2044
|
+
// Operation-override rename (Node-scoped) wins unconditionally: an explicit
|
|
2045
|
+
// pathFieldMap is honored even when the (freshly generated) options interface
|
|
2046
|
+
// isn't in the baseline surface yet — generateOptionsInterfaces applies the
|
|
2047
|
+
// same map to the emitted field, so destructure and interface stay in lockstep.
|
|
2048
|
+
const mapped = pathFieldMap?.[localName];
|
|
2049
|
+
if (mapped) return mapped;
|
|
1993
2050
|
const fields = ctx.apiSurface?.interfaces?.[optionType]?.fields;
|
|
1994
2051
|
if (!fields) return localName;
|
|
1995
2052
|
if (fields[localName]) return localName;
|
package/src/node/tests.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
EmitterContext,
|
|
11
11
|
GeneratedFile,
|
|
12
12
|
} from '@workos/oagen';
|
|
13
|
-
import {
|
|
13
|
+
import { toCamelCase, toPascalCase } from '@workos/oagen';
|
|
14
14
|
import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
|
|
15
15
|
import {
|
|
16
16
|
fieldName,
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
wireInterfaceName,
|
|
25
25
|
} from './naming.js';
|
|
26
26
|
import { generateFixtures } from './fixtures.js';
|
|
27
|
-
import { resolveResourceClassName, resolveResourceDir } from './resources.js';
|
|
27
|
+
import { resolveResourceClassName, resolveResourceDir, ignoredResourceMethodNames } from './resources.js';
|
|
28
28
|
import {
|
|
29
29
|
assignModelsToServices,
|
|
30
30
|
createServiceDirResolver,
|
|
@@ -35,8 +35,8 @@ import {
|
|
|
35
35
|
modelHasNewFields,
|
|
36
36
|
computeNonEventReachable,
|
|
37
37
|
} from './utils.js';
|
|
38
|
-
import { groupByMount } from '../shared/resolved-ops.js';
|
|
39
|
-
import { isNodeOwnedService, nodeOptions } from './options.js';
|
|
38
|
+
import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
39
|
+
import { isNodeOwnedService, nodeOptions, planOperationFor } from './options.js';
|
|
40
40
|
|
|
41
41
|
type BaselineMethod = {
|
|
42
42
|
params: Array<{ name: string; type: string; optional?: boolean; passingStyle?: string }>;
|
|
@@ -57,8 +57,11 @@ function optionsObjectParam(method: BaselineMethod | undefined): { name: string;
|
|
|
57
57
|
const [param] = method.params;
|
|
58
58
|
if (param.name !== 'options') return undefined;
|
|
59
59
|
if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// Strip the `| undefined` arm of an optional param's surface type so the
|
|
61
|
+
// bare type name is used (mirrors resources.ts optionsObjectParam).
|
|
62
|
+
const type = param.type?.replace(/(?:\s*\|\s*(?:undefined|null))+\s*$/, '').trim();
|
|
63
|
+
if (!type || /^(Record|object|any|unknown)\b/.test(type)) return undefined;
|
|
64
|
+
return { name: param.name, type };
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
function configuredOptionsMethod(ctx: EmitterContext, op: Operation): BaselineMethod | undefined {
|
|
@@ -161,6 +164,17 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
161
164
|
const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
|
|
162
165
|
const ops = isOwnedService ? operations : uncoveredOperations(mergedService, ctx);
|
|
163
166
|
if (ops.length === 0) continue;
|
|
167
|
+
// Methods hand-owned inside an `@oagen-ignore` region of the resource
|
|
168
|
+
// file: resources.ts skips generating those methods, so a generated test
|
|
169
|
+
// would reference a shape that no longer matches the preserved hand-written
|
|
170
|
+
// signature (e.g. SSO's `getAuthorizationUrl` returns a string, not
|
|
171
|
+
// `{ url }`). Their `describe` blocks are skipped below, but the ops are
|
|
172
|
+
// still passed through so entity-assertion helpers their preserved
|
|
173
|
+
// hand-written test blocks rely on continue to be emitted.
|
|
174
|
+
const ignoredMethodNames = ignoredResourceMethodNames(
|
|
175
|
+
ctx,
|
|
176
|
+
`src/${resolveResourceDir(mergedService, ctx)}/${fileName(resolveResourceClassName(mergedService, ctx))}.ts`,
|
|
177
|
+
);
|
|
164
178
|
|
|
165
179
|
// Skip tests for services without a WorkOS property in the baseline
|
|
166
180
|
const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
|
|
@@ -176,7 +190,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
176
190
|
if (resourceClass !== mountName) continue;
|
|
177
191
|
|
|
178
192
|
const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
|
|
179
|
-
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
|
|
193
|
+
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors, ignoredMethodNames));
|
|
180
194
|
}
|
|
181
195
|
|
|
182
196
|
// Generate serializer round-trip tests
|
|
@@ -194,6 +208,7 @@ function generateServiceTest(
|
|
|
194
208
|
ctx: EmitterContext,
|
|
195
209
|
modelMap: Map<string, Model>,
|
|
196
210
|
mountAccessors?: Map<string, string>,
|
|
211
|
+
ignoredMethodNames: Set<string> = new Set(),
|
|
197
212
|
): GeneratedFile {
|
|
198
213
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
199
214
|
const serviceDir = resolveResourceDir(service, ctx);
|
|
@@ -203,11 +218,31 @@ function generateServiceTest(
|
|
|
203
218
|
const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolveServiceName(service, ctx));
|
|
204
219
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
205
220
|
|
|
206
|
-
const
|
|
221
|
+
const allPlans = service.operations.map((op) => ({
|
|
207
222
|
op,
|
|
208
|
-
plan:
|
|
223
|
+
plan: planOperationFor(op, ctx),
|
|
209
224
|
method: resolveMethodName(op, service, ctx),
|
|
210
225
|
}));
|
|
226
|
+
// `plans` drives everything that becomes generated text (fixture imports,
|
|
227
|
+
// test-util imports, `describe` blocks): exclude hand-owned methods so we
|
|
228
|
+
// don't emit broken tests against their preserved signatures. `allPlans`
|
|
229
|
+
// is retained only for entity-helper counting — a hand-owned method's
|
|
230
|
+
// preserved `@oagen-ignore` test block may still rely on an
|
|
231
|
+
// `expect<Model>()` helper that the generated tests would otherwise drop.
|
|
232
|
+
//
|
|
233
|
+
// URL-builder ops (e.g. `getLogoutUrl`) return a string synchronously and
|
|
234
|
+
// make no HTTP call, so the fetch-mock test shape (`fetchMethod()` etc.)
|
|
235
|
+
// throws at runtime. Skip them — like the hand-owned URL helpers.
|
|
236
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
237
|
+
const preservedScopes = methodsWithPreservedTestBlocks(ctx, testPath);
|
|
238
|
+
const plans = allPlans.filter((p) => {
|
|
239
|
+
// Keep the describe when a preserved hand-written `@oagen-ignore` test
|
|
240
|
+
// block is nested under it, so it isn't orphaned out of scope.
|
|
241
|
+
if (preservedScopes.has(p.method)) return true;
|
|
242
|
+
if (ignoredMethodNames.has(p.method)) return false;
|
|
243
|
+
if (lookupResolved(p.op, resolvedLookup)?.urlBuilder) return false;
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
211
246
|
|
|
212
247
|
// Sort plans to match the existing file's method order (same as resources.ts).
|
|
213
248
|
if (ctx.overlayLookup?.methodByOperation) {
|
|
@@ -294,8 +329,20 @@ function generateServiceTest(
|
|
|
294
329
|
|
|
295
330
|
// Generate per-entity assertion helpers for models used in 2+ tests.
|
|
296
331
|
// This deduplicates the field assertion blocks that would otherwise be
|
|
297
|
-
// copy-pasted across list/find/create/update test cases.
|
|
298
|
-
|
|
332
|
+
// copy-pasted across list/find/create/update test cases. Count across
|
|
333
|
+
// `allPlans` (incl. hand-owned methods) so a helper a preserved hand test
|
|
334
|
+
// block needs survives, but only emit one that an emitted `describe` or a
|
|
335
|
+
// preserved `@oagen-ignore` block actually references — otherwise it would
|
|
336
|
+
// be an unused function.
|
|
337
|
+
const ignoreRegionText = existingTestIgnoreText(ctx, testPath);
|
|
338
|
+
const { lines: helperLines, helpers: entityHelperNames } = generateEntityHelpers(
|
|
339
|
+
service,
|
|
340
|
+
allPlans,
|
|
341
|
+
plans,
|
|
342
|
+
ignoreRegionText,
|
|
343
|
+
modelMap,
|
|
344
|
+
ctx,
|
|
345
|
+
);
|
|
299
346
|
for (const line of helperLines) {
|
|
300
347
|
lines.push(line);
|
|
301
348
|
}
|
|
@@ -325,6 +372,24 @@ function generateServiceTest(
|
|
|
325
372
|
|
|
326
373
|
lines.push('});');
|
|
327
374
|
|
|
375
|
+
// Inject imports for real TS enums referenced as member values (e.g.
|
|
376
|
+
// `ConnectionType.ADFSSAML`). Import each from its own source file so the
|
|
377
|
+
// value's enum is the same nominal declaration the field is typed with.
|
|
378
|
+
const body = lines.join('\n');
|
|
379
|
+
const enumImportLines: string[] = [];
|
|
380
|
+
for (const [name, info] of Object.entries(ctx.apiSurface?.enums ?? {})) {
|
|
381
|
+
if (!new RegExp(`\\b${name}\\.[A-Za-z_$]`).test(body)) continue;
|
|
382
|
+
if (new RegExp(`import\\b[^;]*\\b${name}\\b[^;]*from`).test(body)) continue;
|
|
383
|
+
const sourceFile = (info as { sourceFile?: string }).sourceFile;
|
|
384
|
+
const spec = sourceFile ? relativeImport(testPath, sourceFile).replace(/\.ts$/, '') : './interfaces';
|
|
385
|
+
enumImportLines.push(`import { ${name} } from '${spec}';`);
|
|
386
|
+
}
|
|
387
|
+
if (enumImportLines.length > 0) {
|
|
388
|
+
const anchor = lines.indexOf("import { WorkOS } from '../workos';");
|
|
389
|
+
const at = anchor >= 0 ? anchor + 1 : 0;
|
|
390
|
+
lines.splice(at, 0, ...enumImportLines);
|
|
391
|
+
}
|
|
392
|
+
|
|
328
393
|
return { path: testPath, content: lines.join('\n'), overwriteExisting: true };
|
|
329
394
|
}
|
|
330
395
|
|
|
@@ -342,16 +407,123 @@ function pathParamTestValue(param: { type: TypeRef; name?: string } | undefined,
|
|
|
342
407
|
return 'test_id';
|
|
343
408
|
}
|
|
344
409
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
410
|
+
/** Render an example value as a valid TS literal expression.
|
|
411
|
+
* Objects and nested arrays go through JSON.stringify so map-typed params
|
|
412
|
+
* (e.g. providerQueryParams) don't coerce to the literal text `[object Object]`.
|
|
413
|
+
*/
|
|
414
|
+
function renderExampleLiteral(value: unknown): string {
|
|
415
|
+
if (typeof value === 'string') return `'${value}'`;
|
|
416
|
+
if (Array.isArray(value)) {
|
|
417
|
+
return `[${value.map(renderExampleLiteral).join(', ')}]`;
|
|
418
|
+
}
|
|
419
|
+
if (value !== null && typeof value === 'object') {
|
|
420
|
+
return JSON.stringify(value);
|
|
421
|
+
}
|
|
422
|
+
return String(value);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Resolve the enum members a query-param test value must satisfy. An enum is
|
|
426
|
+
* expressed several ways: an inline `EnumRef` with `values`, an `enum`/`model`
|
|
427
|
+
* ref by name, or — when the IR flattened the param to a bare string but the
|
|
428
|
+
* hand-written options interface still types the field as an enum — the
|
|
429
|
+
* baseline options field type (e.g. `ConnectionType`). */
|
|
430
|
+
/** All valid values for an enum named `name`. The extracted SDK surface is
|
|
431
|
+
* consulted first: when an options field is typed with a hand-written enum
|
|
432
|
+
* (e.g. `ConnectionType` from `connection-type.enum.ts`), that enum — not a
|
|
433
|
+
* same-named IR enum that may carry extra members like `Pending` — is what
|
|
434
|
+
* the generated test must type-check against. Falls back to the IR spec. */
|
|
435
|
+
function enumValuesByName(name: string, ctx?: EmitterContext): (string | number)[] | undefined {
|
|
436
|
+
const members = ctx?.apiSurface?.enums?.[name]?.members;
|
|
437
|
+
if (members && Object.keys(members).length > 0) return Object.values(members);
|
|
438
|
+
const specValues = ctx?.spec.enums.find((e) => e.name === name)?.values;
|
|
439
|
+
if (specValues?.length) return specValues.map((v) => v.value);
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function resolveParamEnumValues(
|
|
444
|
+
param: Parameter,
|
|
445
|
+
optionFieldType: string | undefined,
|
|
446
|
+
ctx?: EmitterContext,
|
|
447
|
+
): (string | number)[] | undefined {
|
|
448
|
+
// Prefer the enum the GENERATED options field is actually typed with — the
|
|
449
|
+
// operation's own IR enum can diverge from it (e.g. the connection_type
|
|
450
|
+
// query param resolves to `ConnectionsConnectionType` whose value
|
|
451
|
+
// 'GithubOAuth' is absent from the `ConnectionType` enum the hand-written
|
|
452
|
+
// options interface uses, where the member is 'GitHubOAuth').
|
|
453
|
+
if (optionFieldType) {
|
|
454
|
+
const bare = optionFieldType
|
|
455
|
+
.replace(/\[\]/g, '')
|
|
456
|
+
.replace(/\|\s*(undefined|null)/g, '')
|
|
457
|
+
.trim();
|
|
458
|
+
if (/^[A-Za-z_$][\w$]*$/.test(bare)) {
|
|
459
|
+
const values = enumValuesByName(bare, ctx);
|
|
460
|
+
if (values) return values;
|
|
349
461
|
}
|
|
462
|
+
}
|
|
463
|
+
if ((param.type.kind === 'enum' || param.type.kind === 'model') && param.type.name) {
|
|
464
|
+
const values = enumValuesByName(param.type.name, ctx);
|
|
465
|
+
if (values) return values;
|
|
466
|
+
}
|
|
467
|
+
if (param.type.kind === 'enum' && param.type.values?.length) return param.type.values;
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** When an options field is typed with a real (nominal) TS `enum` from the SDK
|
|
472
|
+
* surface, a string literal won't type-check — the value must be a member
|
|
473
|
+
* reference (`ConnectionType.ADFSSAML`). Returns the enum's name + members so
|
|
474
|
+
* the caller can both render the reference and import the right declaration. */
|
|
475
|
+
function resolveRealEnum(
|
|
476
|
+
param: Parameter,
|
|
477
|
+
optionFieldType: string | undefined,
|
|
478
|
+
ctx?: EmitterContext,
|
|
479
|
+
): { name: string; members: Record<string, string | number> } | null {
|
|
480
|
+
const candidates: string[] = [];
|
|
481
|
+
if (optionFieldType) {
|
|
482
|
+
const bare = optionFieldType
|
|
483
|
+
.replace(/\[\]/g, '')
|
|
484
|
+
.replace(/\|\s*(undefined|null)/g, '')
|
|
485
|
+
.trim();
|
|
486
|
+
if (/^[A-Za-z_$][\w$]*$/.test(bare)) candidates.push(bare);
|
|
487
|
+
}
|
|
488
|
+
if ((param.type.kind === 'enum' || param.type.kind === 'model') && param.type.name) candidates.push(param.type.name);
|
|
489
|
+
for (const name of candidates) {
|
|
490
|
+
const members = ctx?.apiSurface?.enums?.[name]?.members;
|
|
491
|
+
if (members && Object.keys(members).length > 0) return { name, members };
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function queryParamTestValue(
|
|
497
|
+
param: Parameter,
|
|
498
|
+
modelMap?: Map<string, Model>,
|
|
499
|
+
ctx?: EmitterContext,
|
|
500
|
+
optionFieldType?: string,
|
|
501
|
+
): string {
|
|
502
|
+
// Fields typed with a real TS enum need a member reference, not a string
|
|
503
|
+
// literal (string enums are nominal). Pick the member whose value matches the
|
|
504
|
+
// example, else the first member — `enumImportsToInject` later adds the import.
|
|
505
|
+
const realEnum = resolveRealEnum(param, optionFieldType, ctx);
|
|
506
|
+
if (realEnum) {
|
|
507
|
+
const entries = Object.entries(realEnum.members);
|
|
508
|
+
const match = typeof param.example === 'string' ? entries.find(([, v]) => v === param.example) : undefined;
|
|
509
|
+
const [memberKey] = match ?? entries[0];
|
|
510
|
+
return `${realEnum.name}.${memberKey}`;
|
|
511
|
+
}
|
|
512
|
+
// Otherwise (string-literal-union enums, inline enum refs) emit a value. A
|
|
513
|
+
// spec `example` can drift from the enum (e.g. connection_type example
|
|
514
|
+
// 'GithubOAuth' vs member 'GitHubOAuth'); prefer the example only when valid.
|
|
515
|
+
const enumValues = resolveParamEnumValues(param, optionFieldType, ctx);
|
|
516
|
+
if (enumValues?.length) {
|
|
517
|
+
const valid =
|
|
518
|
+
typeof param.example === 'string' && enumValues.includes(param.example) ? param.example : enumValues[0];
|
|
519
|
+
return typeof valid === 'string' ? `'${valid}'` : String(valid);
|
|
520
|
+
}
|
|
521
|
+
if (param.example !== undefined) {
|
|
350
522
|
const isDateTime = param.type.kind === 'primitive' && param.type.format === 'date-time';
|
|
351
523
|
if (isDateTime && typeof param.example === 'string') {
|
|
352
524
|
return `new Date('${param.example}')`;
|
|
353
525
|
}
|
|
354
|
-
return
|
|
526
|
+
return renderExampleLiteral(param.example);
|
|
355
527
|
}
|
|
356
528
|
return fixtureValueForType(param.type, param.name, 'Options', modelMap) ?? "'test'";
|
|
357
529
|
}
|
|
@@ -661,9 +833,10 @@ function buildOptionsObjectTestArg(
|
|
|
661
833
|
if (!optionParam) return null;
|
|
662
834
|
|
|
663
835
|
const entries: string[] = [];
|
|
836
|
+
const pathFieldMap = ctx ? operationOverrideFor(ctx, op)?.pathFieldMap : undefined;
|
|
664
837
|
for (const param of op.pathParams) {
|
|
665
838
|
const localName = fieldName(param.name);
|
|
666
|
-
const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx);
|
|
839
|
+
const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx, pathFieldMap);
|
|
667
840
|
entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
|
|
668
841
|
}
|
|
669
842
|
|
|
@@ -677,7 +850,8 @@ function buildOptionsObjectTestArg(
|
|
|
677
850
|
).filter((param) => !param.deprecated);
|
|
678
851
|
for (const param of queryParams) {
|
|
679
852
|
const localName = fieldName(param.name);
|
|
680
|
-
const
|
|
853
|
+
const optionFieldType = ctx?.apiSurface?.interfaces?.[optionParam.type]?.fields?.[localName]?.type;
|
|
854
|
+
const value = queryParamTestValue(param, modelMap, ctx, optionFieldType);
|
|
681
855
|
entries.push(`${localName}: ${value}`);
|
|
682
856
|
}
|
|
683
857
|
|
|
@@ -713,7 +887,16 @@ function objectLiteralEntries(literal: string): string[] {
|
|
|
713
887
|
return body ? body.split(',').map((entry) => entry.trim()) : [];
|
|
714
888
|
}
|
|
715
889
|
|
|
716
|
-
function resolveOptionsObjectField(
|
|
890
|
+
function resolveOptionsObjectField(
|
|
891
|
+
localName: string,
|
|
892
|
+
optionType: string,
|
|
893
|
+
ctx?: EmitterContext,
|
|
894
|
+
pathFieldMap?: Record<string, string>,
|
|
895
|
+
): string {
|
|
896
|
+
// An explicit pathFieldMap wins unconditionally (mirrors resources.ts) so the
|
|
897
|
+
// generated test destructures the same renamed field the resource does.
|
|
898
|
+
const mapped = pathFieldMap?.[localName];
|
|
899
|
+
if (mapped) return mapped;
|
|
717
900
|
const fields = ctx?.apiSurface?.interfaces?.[optionType]?.fields;
|
|
718
901
|
if (!fields) return localName;
|
|
719
902
|
if (fields[localName]) return localName;
|
|
@@ -729,28 +912,109 @@ function resolveOptionsObjectField(localName: string, optionType: string, ctx?:
|
|
|
729
912
|
* Generate per-entity assertion helper functions for models used in 2+ tests.
|
|
730
913
|
* Returns { lines, helpers } where helpers is a Set of helper function names.
|
|
731
914
|
*/
|
|
915
|
+
/** Describe-scope names (i.e. method names) that have an `@oagen-ignore` block
|
|
916
|
+
* nested inside them in the existing test file. Such a `describe` must keep
|
|
917
|
+
* being emitted even when the method is hand-owned — otherwise the engine has
|
|
918
|
+
* no `describe` to re-nest the preserved block under and orphans it to the top
|
|
919
|
+
* of the file (out of `beforeEach` scope), which hangs at runtime. Mirrors the
|
|
920
|
+
* engine's `findContainingDescribeScope`. */
|
|
921
|
+
function methodsWithPreservedTestBlocks(ctx: EmitterContext, relPath: string): Set<string> {
|
|
922
|
+
const scopes = new Set<string>();
|
|
923
|
+
const root = ctx.outputDir ?? ctx.targetDir;
|
|
924
|
+
if (!root) return scopes;
|
|
925
|
+
let content: string;
|
|
926
|
+
try {
|
|
927
|
+
content = fs.readFileSync(path.join(root, relPath), 'utf8');
|
|
928
|
+
} catch {
|
|
929
|
+
return scopes;
|
|
930
|
+
}
|
|
931
|
+
const lines = content.split('\n');
|
|
932
|
+
const indentOf = (l: string) => l.length - l.trimStart().length;
|
|
933
|
+
for (let i = 0; i < lines.length; i++) {
|
|
934
|
+
if (!lines[i].includes('@oagen-ignore-start')) continue;
|
|
935
|
+
if (/^\S/.test(lines[i])) continue; // top-level block — no enclosing describe
|
|
936
|
+
const markerIndent = indentOf(lines[i]);
|
|
937
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
938
|
+
if (indentOf(lines[j]) >= markerIndent) continue;
|
|
939
|
+
const m = lines[j].match(/^\s*describe\((['"`])(.+?)\1\s*,/);
|
|
940
|
+
if (m) {
|
|
941
|
+
scopes.add(m[2]);
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
if (/^\s*(?:export\s+)?class\s+\w+/.test(lines[j])) break;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return scopes;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Concatenated text of the existing test file's `@oagen-ignore` regions, so
|
|
951
|
+
* helper generation can see which `expect<Model>()` helpers preserved
|
|
952
|
+
* hand-written test blocks still reference. */
|
|
953
|
+
function existingTestIgnoreText(ctx: EmitterContext, relPath: string): string {
|
|
954
|
+
const root = ctx.outputDir ?? ctx.targetDir;
|
|
955
|
+
if (!root) return '';
|
|
956
|
+
let content: string;
|
|
957
|
+
try {
|
|
958
|
+
content = fs.readFileSync(path.join(root, relPath), 'utf8');
|
|
959
|
+
} catch {
|
|
960
|
+
return '';
|
|
961
|
+
}
|
|
962
|
+
return [...content.matchAll(/@oagen-ignore-start[\s\S]*?@oagen-ignore-end/g)].map((m) => m[0]).join('\n');
|
|
963
|
+
}
|
|
964
|
+
|
|
732
965
|
function generateEntityHelpers(
|
|
733
|
-
|
|
966
|
+
service: Service,
|
|
967
|
+
allPlans: { op: Operation; plan: any; method: string }[],
|
|
968
|
+
renderedPlans: { op: Operation; plan: any; method: string }[],
|
|
969
|
+
ignoreRegionText: string,
|
|
734
970
|
modelMap: Map<string, Model>,
|
|
735
971
|
ctx: EmitterContext,
|
|
736
972
|
): { lines: string[]; helpers: Set<string> } {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
for (const { op, plan } of plans) {
|
|
740
|
-
let modelName: string | null = null;
|
|
973
|
+
const responseModelOf = (entry: { op: Operation; plan: any }): string | null => {
|
|
974
|
+
const { op, plan } = entry;
|
|
741
975
|
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
742
|
-
modelName = op.pagination.itemType.name;
|
|
976
|
+
let modelName = op.pagination.itemType.name;
|
|
743
977
|
const rawModel = modelMap.get(modelName);
|
|
744
978
|
if (rawModel) {
|
|
745
979
|
const unwrapped = unwrapListModel(rawModel, modelMap);
|
|
746
980
|
if (unwrapped) modelName = unwrapped.name;
|
|
747
981
|
}
|
|
748
|
-
|
|
749
|
-
modelName = plan.responseModelName;
|
|
982
|
+
return modelName;
|
|
750
983
|
}
|
|
751
|
-
|
|
752
|
-
|
|
984
|
+
return plan.responseModelName ?? null;
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
// Count how many tests reference each response model — across ALL plans so a
|
|
988
|
+
// model that a hand-owned method's preserved test block asserts on still
|
|
989
|
+
// clears the 2-use threshold.
|
|
990
|
+
const modelUsage = new Map<string, number>();
|
|
991
|
+
for (const entry of allPlans) {
|
|
992
|
+
const modelName = responseModelOf(entry);
|
|
993
|
+
if (modelName) modelUsage.set(modelName, (modelUsage.get(modelName) ?? 0) + 1);
|
|
994
|
+
}
|
|
995
|
+
// Models an emitted `describe` will actually assert on. A helper only earns
|
|
996
|
+
// its place if an emitted test references it or a preserved `@oagen-ignore`
|
|
997
|
+
// block names it — otherwise it's an unused function.
|
|
998
|
+
const renderedModels = new Set<string>();
|
|
999
|
+
for (const entry of renderedPlans) {
|
|
1000
|
+
const modelName = responseModelOf(entry);
|
|
1001
|
+
if (!modelName) continue;
|
|
1002
|
+
// A paginated test that skips item field assertions (baseline returns a
|
|
1003
|
+
// non-paginatable type, or a different item type than the spec) never
|
|
1004
|
+
// invokes the per-item entity helper — mirror renderPaginatedTest's
|
|
1005
|
+
// `skipFieldAssertions` here so we don't emit a helper no call site
|
|
1006
|
+
// references (TS6133). A model also reached by a non-skipped test is still
|
|
1007
|
+
// added via that entry, so a genuinely-called helper is never dropped.
|
|
1008
|
+
if (entry.plan.isPaginated && entry.op.pagination?.itemType.kind === 'model') {
|
|
1009
|
+
const baselineMethod = optionsMethodFor(service, entry.method, entry.op, entry.plan, ctx);
|
|
1010
|
+
const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
|
|
1011
|
+
const generatedItemType = resolveInterfaceName(modelName, ctx);
|
|
1012
|
+
const skipFieldAssertions =
|
|
1013
|
+
Boolean(baselineMethod?.returnType && !baselineItemType) ||
|
|
1014
|
+
Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
|
|
1015
|
+
if (skipFieldAssertions) continue;
|
|
753
1016
|
}
|
|
1017
|
+
renderedModels.add(modelName);
|
|
754
1018
|
}
|
|
755
1019
|
|
|
756
1020
|
const lines: string[] = [];
|
|
@@ -765,6 +1029,8 @@ function generateEntityHelpers(
|
|
|
765
1029
|
const domainName = resolveInterfaceName(modelName, ctx);
|
|
766
1030
|
const helperName = `expect${domainName}`;
|
|
767
1031
|
if (helpers.has(helperName)) continue;
|
|
1032
|
+
const referenced = renderedModels.has(modelName) || ignoreRegionText.includes(`${helperName}(`);
|
|
1033
|
+
if (!referenced) continue;
|
|
768
1034
|
helpers.add(helperName);
|
|
769
1035
|
|
|
770
1036
|
lines.push(`function ${helperName}(result: any) {`);
|
|
@@ -797,7 +1063,7 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
|
|
|
797
1063
|
|
|
798
1064
|
for (const field of model.fields) {
|
|
799
1065
|
if (!field.required) continue;
|
|
800
|
-
const domainField = fieldName(field.name);
|
|
1066
|
+
const domainField = fieldName(field.domainName ?? field.name);
|
|
801
1067
|
// `string` + `format: 'date-time'` is deserialized to `Date` by the
|
|
802
1068
|
// serializer (see `mapPrimitive` in type-map.ts). Asserting against a
|
|
803
1069
|
// string literal would fail Object.is — compare via `.toISOString()`.
|