@workos/oagen-emitters 0.18.3 → 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 +9 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-1ckLMpgo.mjs → plugin-Cciic50q.mjs} +443 -99
- 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 +5 -2
- 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 +55 -17
- 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/rust/models.test.ts +49 -0
- package/dist/plugin-1ckLMpgo.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 {
|
|
@@ -568,7 +569,7 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
568
569
|
|
|
569
570
|
const plans = service.operations.map((op) => ({
|
|
570
571
|
op,
|
|
571
|
-
plan:
|
|
572
|
+
plan: planOperationFor(op, ctx),
|
|
572
573
|
method: resolveMethodName(op, service, ctx),
|
|
573
574
|
}));
|
|
574
575
|
|
|
@@ -577,7 +578,13 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
577
578
|
const baselineMethod = baselineMethodFor(service, method, ctx);
|
|
578
579
|
const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolvedOp);
|
|
579
580
|
if (!optionInfo?.generated) continue;
|
|
580
|
-
|
|
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;
|
|
581
588
|
|
|
582
589
|
const optionsName = optionInfo.type;
|
|
583
590
|
const optionFileStem = `${fileName(optionsName)}.interface`;
|
|
@@ -645,9 +652,10 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
645
652
|
headerParts.push(` ${name}${opt}: ${type};`);
|
|
646
653
|
};
|
|
647
654
|
|
|
655
|
+
const optionsPathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
648
656
|
for (const param of op.pathParams) {
|
|
649
657
|
pushField(
|
|
650
|
-
fieldName(param.name),
|
|
658
|
+
optionsPathFieldMap?.[fieldName(param.name)] ?? fieldName(param.name),
|
|
651
659
|
true,
|
|
652
660
|
mapParamType(param.type, specEnumNames),
|
|
653
661
|
param.description,
|
|
@@ -779,7 +787,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
779
787
|
|
|
780
788
|
let plans = service.operations.map((op) => ({
|
|
781
789
|
op,
|
|
782
|
-
plan:
|
|
790
|
+
plan: planOperationFor(op, ctx),
|
|
783
791
|
method: resolveMethodName(op, service, ctx),
|
|
784
792
|
}));
|
|
785
793
|
|
|
@@ -1038,6 +1046,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
1038
1046
|
}
|
|
1039
1047
|
|
|
1040
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');
|
|
1041
1053
|
for (const optionType of optionObjectTypes) {
|
|
1042
1054
|
if (isValidTypeIdentifier(optionType)) {
|
|
1043
1055
|
if (importedTypeNames.has(optionType)) continue;
|
|
@@ -1954,7 +1966,20 @@ function renderOptionsObjectMethod(
|
|
|
1954
1966
|
return true;
|
|
1955
1967
|
}
|
|
1956
1968
|
|
|
1957
|
-
|
|
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
|
+
}
|
|
1958
1983
|
}
|
|
1959
1984
|
|
|
1960
1985
|
function renderOptionsObjectDestructure(lines: string[], pathBindings: string[], restName?: string): void {
|
|
@@ -1988,7 +2013,8 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
|
|
|
1988
2013
|
// Return resolved SDK field names directly — the URL template uses these
|
|
1989
2014
|
// names too (via the param-name map threaded into buildNodePathExpression),
|
|
1990
2015
|
// so the destructure no longer needs `optionField: localName` renames.
|
|
1991
|
-
|
|
2016
|
+
const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
2017
|
+
return op.pathParams.map((param) => resolveOptionsObjectField(fieldName(param.name), optionType, ctx, pathFieldMap));
|
|
1992
2018
|
}
|
|
1993
2019
|
|
|
1994
2020
|
/**
|
|
@@ -2000,15 +2026,27 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
|
|
|
2000
2026
|
*/
|
|
2001
2027
|
function buildOptionsObjectPathParamMap(op: Operation, optionType: string, ctx: EmitterContext): Map<string, string> {
|
|
2002
2028
|
const map = new Map<string, string>();
|
|
2029
|
+
const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
2003
2030
|
for (const param of op.pathParams) {
|
|
2004
2031
|
const localName = fieldName(param.name);
|
|
2005
|
-
const sdkField = resolveOptionsObjectField(localName, optionType, ctx);
|
|
2032
|
+
const sdkField = resolveOptionsObjectField(localName, optionType, ctx, pathFieldMap);
|
|
2006
2033
|
if (sdkField !== localName) map.set(param.name, sdkField);
|
|
2007
2034
|
}
|
|
2008
2035
|
return map;
|
|
2009
2036
|
}
|
|
2010
2037
|
|
|
2011
|
-
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;
|
|
2012
2050
|
const fields = ctx.apiSurface?.interfaces?.[optionType]?.fields;
|
|
2013
2051
|
if (!fields) return localName;
|
|
2014
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()`.
|
package/src/php/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { generateTests } from './tests.js';
|
|
|
19
19
|
import { buildOperationsMap } from './manifest.js';
|
|
20
20
|
import { initializeEnumDedup } from './naming.js';
|
|
21
21
|
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
22
|
+
import { AUTOGEN_NOTICE } from '../shared/file-header.js';
|
|
22
23
|
|
|
23
24
|
/** Initialize enum deduplication from spec data. */
|
|
24
25
|
function ensureNamingInitialized(ctx: EmitterContext): void {
|
|
@@ -100,7 +101,7 @@ export const phpEmitter: Emitter = {
|
|
|
100
101
|
},
|
|
101
102
|
|
|
102
103
|
fileHeader(): string {
|
|
103
|
-
return
|
|
104
|
+
return `<?php\n\ndeclare(strict_types=1);\n\n// ${AUTOGEN_NOTICE}`;
|
|
104
105
|
},
|
|
105
106
|
|
|
106
107
|
formatCommand(targetDir: string): FormatCommand | null {
|