@workos/oagen-emitters 0.18.3 → 0.19.0
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 +16 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-1ckLMpgo.mjs → plugin-BXDPA9pJ.mjs} +581 -172
- package/dist/plugin-BXDPA9pJ.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +2 -2
- package/package.json +5 -5
- package/src/dotnet/enums.ts +11 -5
- package/src/dotnet/fixtures.ts +5 -2
- package/src/dotnet/index.ts +2 -1
- package/src/dotnet/models.ts +41 -10
- package/src/dotnet/naming.ts +10 -0
- package/src/dotnet/resources.ts +3 -3
- package/src/dotnet/tests.ts +8 -4
- 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 +22 -9
- package/src/go/tests.ts +3 -3
- package/src/kotlin/enums.ts +21 -11
- package/src/kotlin/index.ts +2 -1
- package/src/kotlin/models.ts +24 -9
- package/src/kotlin/naming.ts +11 -0
- package/src/kotlin/resources.ts +2 -2
- package/src/kotlin/tests.ts +7 -3
- package/src/node/enums.ts +8 -5
- package/src/node/field-plan.ts +3 -3
- package/src/node/index.ts +2 -1
- package/src/node/models.ts +69 -22
- package/src/node/naming.ts +10 -0
- package/src/node/options.ts +45 -1
- package/src/node/resources.ts +67 -18
- package/src/node/tests.ts +302 -31
- package/src/php/enums.ts +18 -5
- package/src/php/index.ts +13 -4
- package/src/php/models.ts +22 -10
- package/src/php/naming.ts +10 -0
- package/src/php/resources.ts +6 -4
- package/src/php/tests.ts +17 -5
- package/src/python/enums.ts +39 -28
- package/src/python/fixtures.ts +4 -3
- package/src/python/index.ts +2 -1
- package/src/python/models.ts +39 -24
- package/src/python/naming.ts +10 -0
- package/src/python/resources.ts +3 -3
- package/src/python/tests.ts +14 -9
- package/src/ruby/enums.ts +28 -19
- package/src/ruby/index.ts +2 -1
- package/src/ruby/models.ts +33 -19
- package/src/ruby/naming.ts +10 -0
- package/src/ruby/rbi.ts +20 -7
- package/src/ruby/resources.ts +2 -2
- package/src/ruby/tests.ts +6 -3
- package/src/rust/enums.ts +9 -1
- package/src/rust/index.ts +2 -1
- package/src/rust/models.ts +100 -15
- package/src/rust/naming.ts +10 -0
- package/src/rust/resources.ts +14 -3
- package/src/rust/tests.ts +2 -2
- package/src/shared/file-header.ts +13 -0
- package/src/shared/resolved-ops.ts +47 -0
- package/test/rust/models.test.ts +49 -0
- package/test/shared/synthetic-enum-seed.test.ts +79 -0
- package/dist/plugin-1ckLMpgo.mjs.map +0 -1
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, isMountInScope } 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 {
|
|
@@ -134,7 +137,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
const testEntries: Array<{ name: string; operations: Operation[] }> =
|
|
137
|
-
mountGroups.size > 0
|
|
140
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
138
141
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
139
142
|
: spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
|
|
140
143
|
|
|
@@ -156,11 +159,27 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
for (const { name: mountName, operations } of testEntries) {
|
|
162
|
+
// Scope gate: in a scoped (`--services`) run, only emit per-service test
|
|
163
|
+
// files for the selected post-mount names. `mountName` is the mount-group
|
|
164
|
+
// key (the POST-MOUNT name that matches `ctx.scopedServices`). Applied as an
|
|
165
|
+
// additional early continue ahead of the node-owned/coverage skip logic.
|
|
166
|
+
if (!isMountInScope(mountName, ctx)) continue;
|
|
159
167
|
if (operations.length === 0) continue;
|
|
160
168
|
const mergedService: Service = { name: mountName, operations };
|
|
161
169
|
const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
|
|
162
170
|
const ops = isOwnedService ? operations : uncoveredOperations(mergedService, ctx);
|
|
163
171
|
if (ops.length === 0) continue;
|
|
172
|
+
// Methods hand-owned inside an `@oagen-ignore` region of the resource
|
|
173
|
+
// file: resources.ts skips generating those methods, so a generated test
|
|
174
|
+
// would reference a shape that no longer matches the preserved hand-written
|
|
175
|
+
// signature (e.g. SSO's `getAuthorizationUrl` returns a string, not
|
|
176
|
+
// `{ url }`). Their `describe` blocks are skipped below, but the ops are
|
|
177
|
+
// still passed through so entity-assertion helpers their preserved
|
|
178
|
+
// hand-written test blocks rely on continue to be emitted.
|
|
179
|
+
const ignoredMethodNames = ignoredResourceMethodNames(
|
|
180
|
+
ctx,
|
|
181
|
+
`src/${resolveResourceDir(mergedService, ctx)}/${fileName(resolveResourceClassName(mergedService, ctx))}.ts`,
|
|
182
|
+
);
|
|
164
183
|
|
|
165
184
|
// Skip tests for services without a WorkOS property in the baseline
|
|
166
185
|
const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
|
|
@@ -176,7 +195,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
176
195
|
if (resourceClass !== mountName) continue;
|
|
177
196
|
|
|
178
197
|
const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
|
|
179
|
-
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
|
|
198
|
+
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors, ignoredMethodNames));
|
|
180
199
|
}
|
|
181
200
|
|
|
182
201
|
// Generate serializer round-trip tests
|
|
@@ -194,6 +213,7 @@ function generateServiceTest(
|
|
|
194
213
|
ctx: EmitterContext,
|
|
195
214
|
modelMap: Map<string, Model>,
|
|
196
215
|
mountAccessors?: Map<string, string>,
|
|
216
|
+
ignoredMethodNames: Set<string> = new Set(),
|
|
197
217
|
): GeneratedFile {
|
|
198
218
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
199
219
|
const serviceDir = resolveResourceDir(service, ctx);
|
|
@@ -203,11 +223,31 @@ function generateServiceTest(
|
|
|
203
223
|
const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolveServiceName(service, ctx));
|
|
204
224
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
205
225
|
|
|
206
|
-
const
|
|
226
|
+
const allPlans = service.operations.map((op) => ({
|
|
207
227
|
op,
|
|
208
|
-
plan:
|
|
228
|
+
plan: planOperationFor(op, ctx),
|
|
209
229
|
method: resolveMethodName(op, service, ctx),
|
|
210
230
|
}));
|
|
231
|
+
// `plans` drives everything that becomes generated text (fixture imports,
|
|
232
|
+
// test-util imports, `describe` blocks): exclude hand-owned methods so we
|
|
233
|
+
// don't emit broken tests against their preserved signatures. `allPlans`
|
|
234
|
+
// is retained only for entity-helper counting — a hand-owned method's
|
|
235
|
+
// preserved `@oagen-ignore` test block may still rely on an
|
|
236
|
+
// `expect<Model>()` helper that the generated tests would otherwise drop.
|
|
237
|
+
//
|
|
238
|
+
// URL-builder ops (e.g. `getLogoutUrl`) return a string synchronously and
|
|
239
|
+
// make no HTTP call, so the fetch-mock test shape (`fetchMethod()` etc.)
|
|
240
|
+
// throws at runtime. Skip them — like the hand-owned URL helpers.
|
|
241
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
242
|
+
const preservedScopes = methodsWithPreservedTestBlocks(ctx, testPath);
|
|
243
|
+
const plans = allPlans.filter((p) => {
|
|
244
|
+
// Keep the describe when a preserved hand-written `@oagen-ignore` test
|
|
245
|
+
// block is nested under it, so it isn't orphaned out of scope.
|
|
246
|
+
if (preservedScopes.has(p.method)) return true;
|
|
247
|
+
if (ignoredMethodNames.has(p.method)) return false;
|
|
248
|
+
if (lookupResolved(p.op, resolvedLookup)?.urlBuilder) return false;
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
211
251
|
|
|
212
252
|
// Sort plans to match the existing file's method order (same as resources.ts).
|
|
213
253
|
if (ctx.overlayLookup?.methodByOperation) {
|
|
@@ -294,8 +334,20 @@ function generateServiceTest(
|
|
|
294
334
|
|
|
295
335
|
// Generate per-entity assertion helpers for models used in 2+ tests.
|
|
296
336
|
// This deduplicates the field assertion blocks that would otherwise be
|
|
297
|
-
// copy-pasted across list/find/create/update test cases.
|
|
298
|
-
|
|
337
|
+
// copy-pasted across list/find/create/update test cases. Count across
|
|
338
|
+
// `allPlans` (incl. hand-owned methods) so a helper a preserved hand test
|
|
339
|
+
// block needs survives, but only emit one that an emitted `describe` or a
|
|
340
|
+
// preserved `@oagen-ignore` block actually references — otherwise it would
|
|
341
|
+
// be an unused function.
|
|
342
|
+
const ignoreRegionText = existingTestIgnoreText(ctx, testPath);
|
|
343
|
+
const { lines: helperLines, helpers: entityHelperNames } = generateEntityHelpers(
|
|
344
|
+
service,
|
|
345
|
+
allPlans,
|
|
346
|
+
plans,
|
|
347
|
+
ignoreRegionText,
|
|
348
|
+
modelMap,
|
|
349
|
+
ctx,
|
|
350
|
+
);
|
|
299
351
|
for (const line of helperLines) {
|
|
300
352
|
lines.push(line);
|
|
301
353
|
}
|
|
@@ -325,6 +377,24 @@ function generateServiceTest(
|
|
|
325
377
|
|
|
326
378
|
lines.push('});');
|
|
327
379
|
|
|
380
|
+
// Inject imports for real TS enums referenced as member values (e.g.
|
|
381
|
+
// `ConnectionType.ADFSSAML`). Import each from its own source file so the
|
|
382
|
+
// value's enum is the same nominal declaration the field is typed with.
|
|
383
|
+
const body = lines.join('\n');
|
|
384
|
+
const enumImportLines: string[] = [];
|
|
385
|
+
for (const [name, info] of Object.entries(ctx.apiSurface?.enums ?? {})) {
|
|
386
|
+
if (!new RegExp(`\\b${name}\\.[A-Za-z_$]`).test(body)) continue;
|
|
387
|
+
if (new RegExp(`import\\b[^;]*\\b${name}\\b[^;]*from`).test(body)) continue;
|
|
388
|
+
const sourceFile = (info as { sourceFile?: string }).sourceFile;
|
|
389
|
+
const spec = sourceFile ? relativeImport(testPath, sourceFile).replace(/\.ts$/, '') : './interfaces';
|
|
390
|
+
enumImportLines.push(`import { ${name} } from '${spec}';`);
|
|
391
|
+
}
|
|
392
|
+
if (enumImportLines.length > 0) {
|
|
393
|
+
const anchor = lines.indexOf("import { WorkOS } from '../workos';");
|
|
394
|
+
const at = anchor >= 0 ? anchor + 1 : 0;
|
|
395
|
+
lines.splice(at, 0, ...enumImportLines);
|
|
396
|
+
}
|
|
397
|
+
|
|
328
398
|
return { path: testPath, content: lines.join('\n'), overwriteExisting: true };
|
|
329
399
|
}
|
|
330
400
|
|
|
@@ -342,16 +412,123 @@ function pathParamTestValue(param: { type: TypeRef; name?: string } | undefined,
|
|
|
342
412
|
return 'test_id';
|
|
343
413
|
}
|
|
344
414
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
415
|
+
/** Render an example value as a valid TS literal expression.
|
|
416
|
+
* Objects and nested arrays go through JSON.stringify so map-typed params
|
|
417
|
+
* (e.g. providerQueryParams) don't coerce to the literal text `[object Object]`.
|
|
418
|
+
*/
|
|
419
|
+
function renderExampleLiteral(value: unknown): string {
|
|
420
|
+
if (typeof value === 'string') return `'${value}'`;
|
|
421
|
+
if (Array.isArray(value)) {
|
|
422
|
+
return `[${value.map(renderExampleLiteral).join(', ')}]`;
|
|
423
|
+
}
|
|
424
|
+
if (value !== null && typeof value === 'object') {
|
|
425
|
+
return JSON.stringify(value);
|
|
426
|
+
}
|
|
427
|
+
return String(value);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Resolve the enum members a query-param test value must satisfy. An enum is
|
|
431
|
+
* expressed several ways: an inline `EnumRef` with `values`, an `enum`/`model`
|
|
432
|
+
* ref by name, or — when the IR flattened the param to a bare string but the
|
|
433
|
+
* hand-written options interface still types the field as an enum — the
|
|
434
|
+
* baseline options field type (e.g. `ConnectionType`). */
|
|
435
|
+
/** All valid values for an enum named `name`. The extracted SDK surface is
|
|
436
|
+
* consulted first: when an options field is typed with a hand-written enum
|
|
437
|
+
* (e.g. `ConnectionType` from `connection-type.enum.ts`), that enum — not a
|
|
438
|
+
* same-named IR enum that may carry extra members like `Pending` — is what
|
|
439
|
+
* the generated test must type-check against. Falls back to the IR spec. */
|
|
440
|
+
function enumValuesByName(name: string, ctx?: EmitterContext): (string | number)[] | undefined {
|
|
441
|
+
const members = ctx?.apiSurface?.enums?.[name]?.members;
|
|
442
|
+
if (members && Object.keys(members).length > 0) return Object.values(members);
|
|
443
|
+
const specValues = ctx?.spec.enums.find((e) => e.name === name)?.values;
|
|
444
|
+
if (specValues?.length) return specValues.map((v) => v.value);
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function resolveParamEnumValues(
|
|
449
|
+
param: Parameter,
|
|
450
|
+
optionFieldType: string | undefined,
|
|
451
|
+
ctx?: EmitterContext,
|
|
452
|
+
): (string | number)[] | undefined {
|
|
453
|
+
// Prefer the enum the GENERATED options field is actually typed with — the
|
|
454
|
+
// operation's own IR enum can diverge from it (e.g. the connection_type
|
|
455
|
+
// query param resolves to `ConnectionsConnectionType` whose value
|
|
456
|
+
// 'GithubOAuth' is absent from the `ConnectionType` enum the hand-written
|
|
457
|
+
// options interface uses, where the member is 'GitHubOAuth').
|
|
458
|
+
if (optionFieldType) {
|
|
459
|
+
const bare = optionFieldType
|
|
460
|
+
.replace(/\[\]/g, '')
|
|
461
|
+
.replace(/\|\s*(undefined|null)/g, '')
|
|
462
|
+
.trim();
|
|
463
|
+
if (/^[A-Za-z_$][\w$]*$/.test(bare)) {
|
|
464
|
+
const values = enumValuesByName(bare, ctx);
|
|
465
|
+
if (values) return values;
|
|
349
466
|
}
|
|
467
|
+
}
|
|
468
|
+
if ((param.type.kind === 'enum' || param.type.kind === 'model') && param.type.name) {
|
|
469
|
+
const values = enumValuesByName(param.type.name, ctx);
|
|
470
|
+
if (values) return values;
|
|
471
|
+
}
|
|
472
|
+
if (param.type.kind === 'enum' && param.type.values?.length) return param.type.values;
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** When an options field is typed with a real (nominal) TS `enum` from the SDK
|
|
477
|
+
* surface, a string literal won't type-check — the value must be a member
|
|
478
|
+
* reference (`ConnectionType.ADFSSAML`). Returns the enum's name + members so
|
|
479
|
+
* the caller can both render the reference and import the right declaration. */
|
|
480
|
+
function resolveRealEnum(
|
|
481
|
+
param: Parameter,
|
|
482
|
+
optionFieldType: string | undefined,
|
|
483
|
+
ctx?: EmitterContext,
|
|
484
|
+
): { name: string; members: Record<string, string | number> } | null {
|
|
485
|
+
const candidates: string[] = [];
|
|
486
|
+
if (optionFieldType) {
|
|
487
|
+
const bare = optionFieldType
|
|
488
|
+
.replace(/\[\]/g, '')
|
|
489
|
+
.replace(/\|\s*(undefined|null)/g, '')
|
|
490
|
+
.trim();
|
|
491
|
+
if (/^[A-Za-z_$][\w$]*$/.test(bare)) candidates.push(bare);
|
|
492
|
+
}
|
|
493
|
+
if ((param.type.kind === 'enum' || param.type.kind === 'model') && param.type.name) candidates.push(param.type.name);
|
|
494
|
+
for (const name of candidates) {
|
|
495
|
+
const members = ctx?.apiSurface?.enums?.[name]?.members;
|
|
496
|
+
if (members && Object.keys(members).length > 0) return { name, members };
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function queryParamTestValue(
|
|
502
|
+
param: Parameter,
|
|
503
|
+
modelMap?: Map<string, Model>,
|
|
504
|
+
ctx?: EmitterContext,
|
|
505
|
+
optionFieldType?: string,
|
|
506
|
+
): string {
|
|
507
|
+
// Fields typed with a real TS enum need a member reference, not a string
|
|
508
|
+
// literal (string enums are nominal). Pick the member whose value matches the
|
|
509
|
+
// example, else the first member — `enumImportsToInject` later adds the import.
|
|
510
|
+
const realEnum = resolveRealEnum(param, optionFieldType, ctx);
|
|
511
|
+
if (realEnum) {
|
|
512
|
+
const entries = Object.entries(realEnum.members);
|
|
513
|
+
const match = typeof param.example === 'string' ? entries.find(([, v]) => v === param.example) : undefined;
|
|
514
|
+
const [memberKey] = match ?? entries[0];
|
|
515
|
+
return `${realEnum.name}.${memberKey}`;
|
|
516
|
+
}
|
|
517
|
+
// Otherwise (string-literal-union enums, inline enum refs) emit a value. A
|
|
518
|
+
// spec `example` can drift from the enum (e.g. connection_type example
|
|
519
|
+
// 'GithubOAuth' vs member 'GitHubOAuth'); prefer the example only when valid.
|
|
520
|
+
const enumValues = resolveParamEnumValues(param, optionFieldType, ctx);
|
|
521
|
+
if (enumValues?.length) {
|
|
522
|
+
const valid =
|
|
523
|
+
typeof param.example === 'string' && enumValues.includes(param.example) ? param.example : enumValues[0];
|
|
524
|
+
return typeof valid === 'string' ? `'${valid}'` : String(valid);
|
|
525
|
+
}
|
|
526
|
+
if (param.example !== undefined) {
|
|
350
527
|
const isDateTime = param.type.kind === 'primitive' && param.type.format === 'date-time';
|
|
351
528
|
if (isDateTime && typeof param.example === 'string') {
|
|
352
529
|
return `new Date('${param.example}')`;
|
|
353
530
|
}
|
|
354
|
-
return
|
|
531
|
+
return renderExampleLiteral(param.example);
|
|
355
532
|
}
|
|
356
533
|
return fixtureValueForType(param.type, param.name, 'Options', modelMap) ?? "'test'";
|
|
357
534
|
}
|
|
@@ -661,9 +838,10 @@ function buildOptionsObjectTestArg(
|
|
|
661
838
|
if (!optionParam) return null;
|
|
662
839
|
|
|
663
840
|
const entries: string[] = [];
|
|
841
|
+
const pathFieldMap = ctx ? operationOverrideFor(ctx, op)?.pathFieldMap : undefined;
|
|
664
842
|
for (const param of op.pathParams) {
|
|
665
843
|
const localName = fieldName(param.name);
|
|
666
|
-
const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx);
|
|
844
|
+
const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx, pathFieldMap);
|
|
667
845
|
entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
|
|
668
846
|
}
|
|
669
847
|
|
|
@@ -677,7 +855,8 @@ function buildOptionsObjectTestArg(
|
|
|
677
855
|
).filter((param) => !param.deprecated);
|
|
678
856
|
for (const param of queryParams) {
|
|
679
857
|
const localName = fieldName(param.name);
|
|
680
|
-
const
|
|
858
|
+
const optionFieldType = ctx?.apiSurface?.interfaces?.[optionParam.type]?.fields?.[localName]?.type;
|
|
859
|
+
const value = queryParamTestValue(param, modelMap, ctx, optionFieldType);
|
|
681
860
|
entries.push(`${localName}: ${value}`);
|
|
682
861
|
}
|
|
683
862
|
|
|
@@ -713,7 +892,16 @@ function objectLiteralEntries(literal: string): string[] {
|
|
|
713
892
|
return body ? body.split(',').map((entry) => entry.trim()) : [];
|
|
714
893
|
}
|
|
715
894
|
|
|
716
|
-
function resolveOptionsObjectField(
|
|
895
|
+
function resolveOptionsObjectField(
|
|
896
|
+
localName: string,
|
|
897
|
+
optionType: string,
|
|
898
|
+
ctx?: EmitterContext,
|
|
899
|
+
pathFieldMap?: Record<string, string>,
|
|
900
|
+
): string {
|
|
901
|
+
// An explicit pathFieldMap wins unconditionally (mirrors resources.ts) so the
|
|
902
|
+
// generated test destructures the same renamed field the resource does.
|
|
903
|
+
const mapped = pathFieldMap?.[localName];
|
|
904
|
+
if (mapped) return mapped;
|
|
717
905
|
const fields = ctx?.apiSurface?.interfaces?.[optionType]?.fields;
|
|
718
906
|
if (!fields) return localName;
|
|
719
907
|
if (fields[localName]) return localName;
|
|
@@ -729,28 +917,109 @@ function resolveOptionsObjectField(localName: string, optionType: string, ctx?:
|
|
|
729
917
|
* Generate per-entity assertion helper functions for models used in 2+ tests.
|
|
730
918
|
* Returns { lines, helpers } where helpers is a Set of helper function names.
|
|
731
919
|
*/
|
|
920
|
+
/** Describe-scope names (i.e. method names) that have an `@oagen-ignore` block
|
|
921
|
+
* nested inside them in the existing test file. Such a `describe` must keep
|
|
922
|
+
* being emitted even when the method is hand-owned — otherwise the engine has
|
|
923
|
+
* no `describe` to re-nest the preserved block under and orphans it to the top
|
|
924
|
+
* of the file (out of `beforeEach` scope), which hangs at runtime. Mirrors the
|
|
925
|
+
* engine's `findContainingDescribeScope`. */
|
|
926
|
+
function methodsWithPreservedTestBlocks(ctx: EmitterContext, relPath: string): Set<string> {
|
|
927
|
+
const scopes = new Set<string>();
|
|
928
|
+
const root = ctx.outputDir ?? ctx.targetDir;
|
|
929
|
+
if (!root) return scopes;
|
|
930
|
+
let content: string;
|
|
931
|
+
try {
|
|
932
|
+
content = fs.readFileSync(path.join(root, relPath), 'utf8');
|
|
933
|
+
} catch {
|
|
934
|
+
return scopes;
|
|
935
|
+
}
|
|
936
|
+
const lines = content.split('\n');
|
|
937
|
+
const indentOf = (l: string) => l.length - l.trimStart().length;
|
|
938
|
+
for (let i = 0; i < lines.length; i++) {
|
|
939
|
+
if (!lines[i].includes('@oagen-ignore-start')) continue;
|
|
940
|
+
if (/^\S/.test(lines[i])) continue; // top-level block — no enclosing describe
|
|
941
|
+
const markerIndent = indentOf(lines[i]);
|
|
942
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
943
|
+
if (indentOf(lines[j]) >= markerIndent) continue;
|
|
944
|
+
const m = lines[j].match(/^\s*describe\((['"`])(.+?)\1\s*,/);
|
|
945
|
+
if (m) {
|
|
946
|
+
scopes.add(m[2]);
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
if (/^\s*(?:export\s+)?class\s+\w+/.test(lines[j])) break;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return scopes;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Concatenated text of the existing test file's `@oagen-ignore` regions, so
|
|
956
|
+
* helper generation can see which `expect<Model>()` helpers preserved
|
|
957
|
+
* hand-written test blocks still reference. */
|
|
958
|
+
function existingTestIgnoreText(ctx: EmitterContext, relPath: string): string {
|
|
959
|
+
const root = ctx.outputDir ?? ctx.targetDir;
|
|
960
|
+
if (!root) return '';
|
|
961
|
+
let content: string;
|
|
962
|
+
try {
|
|
963
|
+
content = fs.readFileSync(path.join(root, relPath), 'utf8');
|
|
964
|
+
} catch {
|
|
965
|
+
return '';
|
|
966
|
+
}
|
|
967
|
+
return [...content.matchAll(/@oagen-ignore-start[\s\S]*?@oagen-ignore-end/g)].map((m) => m[0]).join('\n');
|
|
968
|
+
}
|
|
969
|
+
|
|
732
970
|
function generateEntityHelpers(
|
|
733
|
-
|
|
971
|
+
service: Service,
|
|
972
|
+
allPlans: { op: Operation; plan: any; method: string }[],
|
|
973
|
+
renderedPlans: { op: Operation; plan: any; method: string }[],
|
|
974
|
+
ignoreRegionText: string,
|
|
734
975
|
modelMap: Map<string, Model>,
|
|
735
976
|
ctx: EmitterContext,
|
|
736
977
|
): { lines: string[]; helpers: Set<string> } {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
for (const { op, plan } of plans) {
|
|
740
|
-
let modelName: string | null = null;
|
|
978
|
+
const responseModelOf = (entry: { op: Operation; plan: any }): string | null => {
|
|
979
|
+
const { op, plan } = entry;
|
|
741
980
|
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
742
|
-
modelName = op.pagination.itemType.name;
|
|
981
|
+
let modelName = op.pagination.itemType.name;
|
|
743
982
|
const rawModel = modelMap.get(modelName);
|
|
744
983
|
if (rawModel) {
|
|
745
984
|
const unwrapped = unwrapListModel(rawModel, modelMap);
|
|
746
985
|
if (unwrapped) modelName = unwrapped.name;
|
|
747
986
|
}
|
|
748
|
-
|
|
749
|
-
modelName = plan.responseModelName;
|
|
987
|
+
return modelName;
|
|
750
988
|
}
|
|
751
|
-
|
|
752
|
-
|
|
989
|
+
return plan.responseModelName ?? null;
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
// Count how many tests reference each response model — across ALL plans so a
|
|
993
|
+
// model that a hand-owned method's preserved test block asserts on still
|
|
994
|
+
// clears the 2-use threshold.
|
|
995
|
+
const modelUsage = new Map<string, number>();
|
|
996
|
+
for (const entry of allPlans) {
|
|
997
|
+
const modelName = responseModelOf(entry);
|
|
998
|
+
if (modelName) modelUsage.set(modelName, (modelUsage.get(modelName) ?? 0) + 1);
|
|
999
|
+
}
|
|
1000
|
+
// Models an emitted `describe` will actually assert on. A helper only earns
|
|
1001
|
+
// its place if an emitted test references it or a preserved `@oagen-ignore`
|
|
1002
|
+
// block names it — otherwise it's an unused function.
|
|
1003
|
+
const renderedModels = new Set<string>();
|
|
1004
|
+
for (const entry of renderedPlans) {
|
|
1005
|
+
const modelName = responseModelOf(entry);
|
|
1006
|
+
if (!modelName) continue;
|
|
1007
|
+
// A paginated test that skips item field assertions (baseline returns a
|
|
1008
|
+
// non-paginatable type, or a different item type than the spec) never
|
|
1009
|
+
// invokes the per-item entity helper — mirror renderPaginatedTest's
|
|
1010
|
+
// `skipFieldAssertions` here so we don't emit a helper no call site
|
|
1011
|
+
// references (TS6133). A model also reached by a non-skipped test is still
|
|
1012
|
+
// added via that entry, so a genuinely-called helper is never dropped.
|
|
1013
|
+
if (entry.plan.isPaginated && entry.op.pagination?.itemType.kind === 'model') {
|
|
1014
|
+
const baselineMethod = optionsMethodFor(service, entry.method, entry.op, entry.plan, ctx);
|
|
1015
|
+
const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
|
|
1016
|
+
const generatedItemType = resolveInterfaceName(modelName, ctx);
|
|
1017
|
+
const skipFieldAssertions =
|
|
1018
|
+
Boolean(baselineMethod?.returnType && !baselineItemType) ||
|
|
1019
|
+
Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
|
|
1020
|
+
if (skipFieldAssertions) continue;
|
|
753
1021
|
}
|
|
1022
|
+
renderedModels.add(modelName);
|
|
754
1023
|
}
|
|
755
1024
|
|
|
756
1025
|
const lines: string[] = [];
|
|
@@ -765,6 +1034,8 @@ function generateEntityHelpers(
|
|
|
765
1034
|
const domainName = resolveInterfaceName(modelName, ctx);
|
|
766
1035
|
const helperName = `expect${domainName}`;
|
|
767
1036
|
if (helpers.has(helperName)) continue;
|
|
1037
|
+
const referenced = renderedModels.has(modelName) || ignoreRegionText.includes(`${helperName}(`);
|
|
1038
|
+
if (!referenced) continue;
|
|
768
1039
|
helpers.add(helperName);
|
|
769
1040
|
|
|
770
1041
|
lines.push(`function ${helperName}(result: any) {`);
|
|
@@ -797,7 +1068,7 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
|
|
|
797
1068
|
|
|
798
1069
|
for (const field of model.fields) {
|
|
799
1070
|
if (!field.required) continue;
|
|
800
|
-
const domainField = fieldName(field.name);
|
|
1071
|
+
const domainField = fieldName(field.domainName ?? field.name);
|
|
801
1072
|
// `string` + `format: 'date-time'` is deserialized to `Date` by the
|
|
802
1073
|
// serializer (see `mapPrimitive` in type-map.ts). Asserting against a
|
|
803
1074
|
// string literal would fail Object.is — compare via `.toISOString()`.
|
package/src/php/enums.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
import { className, resolveEnumName } from './naming.js';
|
|
4
4
|
import { phpDocComment } from './utils.js';
|
|
5
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Generate PHP enum files from IR enums.
|
|
@@ -17,6 +18,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
17
18
|
if (emittedCanonical.has(canonical)) continue; // skip aliases
|
|
18
19
|
emittedCanonical.add(canonical);
|
|
19
20
|
|
|
21
|
+
// FR-1.4: write the per-enum FILE only when in scope. PHP dedupes
|
|
22
|
+
// value-identical enums onto a single canonical class, so the canonical
|
|
23
|
+
// file is needed when EITHER the canonical name OR any alias resolving to
|
|
24
|
+
// it is reachable from the selected services. PSR-4 (one class per file
|
|
25
|
+
// under lib/Resource/, no barrel) means an out-of-scope enum is simply
|
|
26
|
+
// left untouched on disk and stays loadable.
|
|
27
|
+
const enumInScope = enums.some(
|
|
28
|
+
(other) => resolveEnumName(other.name) === canonical && isEnumInScope(other.name, ctx),
|
|
29
|
+
);
|
|
30
|
+
|
|
20
31
|
const name = className(canonical);
|
|
21
32
|
const _isAllStrings = e.values.every((v) => typeof v.value === 'string');
|
|
22
33
|
const isAllInts = e.values.every((v) => typeof v.value === 'number' && Number.isInteger(v.value));
|
|
@@ -56,11 +67,13 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
56
67
|
|
|
57
68
|
lines.push('}');
|
|
58
69
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
if (enumInScope) {
|
|
71
|
+
files.push({
|
|
72
|
+
path: `lib/Resource/${name}.php`,
|
|
73
|
+
content: lines.join('\n'),
|
|
74
|
+
overwriteExisting: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
return files;
|
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 {
|
|
@@ -41,9 +42,17 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
41
42
|
* classes (no sum types), so a discriminated base whose IR fields the
|
|
42
43
|
* parser stripped (post-allOf-aware detection) gets its original fields
|
|
43
44
|
* restored to avoid silently dropping variant data.
|
|
45
|
+
*
|
|
46
|
+
* `enums` is forwarded to seed `enrichModelsFromSpec`'s collision set: an
|
|
47
|
+
* inline oneOf enum whose synthetic name (`Parent_field`) snake-collapses
|
|
48
|
+
* onto an existing IR enum (e.g. `DataIntegrationAccessTokenResponse_error`
|
|
49
|
+
* vs `DataIntegrationAccessTokenResponseError`) must NOT spawn a duplicate
|
|
50
|
+
* synthetic. Otherwise both collapse to the same `lib/Resource/X.php` path
|
|
51
|
+
* and the later writer wins by array order — which differs between a full
|
|
52
|
+
* and a scoped (`--services`) run, producing a non-deterministic case order.
|
|
44
53
|
*/
|
|
45
|
-
function enrichModelsForPhp(models: Model[]): Model[] {
|
|
46
|
-
const enriched = enrichModelsFromSpec(models);
|
|
54
|
+
function enrichModelsForPhp(models: Model[], enums: Enum[]): Model[] {
|
|
55
|
+
const enriched = enrichModelsFromSpec(models, enums);
|
|
47
56
|
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
48
57
|
return enriched.map((m) => {
|
|
49
58
|
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
@@ -61,7 +70,7 @@ export const phpEmitter: Emitter = {
|
|
|
61
70
|
|
|
62
71
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
63
72
|
ensureNamingInitialized(ctx);
|
|
64
|
-
return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models), ctx));
|
|
73
|
+
return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models, ctx.spec.enums), ctx));
|
|
65
74
|
},
|
|
66
75
|
|
|
67
76
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -100,7 +109,7 @@ export const phpEmitter: Emitter = {
|
|
|
100
109
|
},
|
|
101
110
|
|
|
102
111
|
fileHeader(): string {
|
|
103
|
-
return
|
|
112
|
+
return `<?php\n\ndeclare(strict_types=1);\n\n// ${AUTOGEN_NOTICE}`;
|
|
104
113
|
},
|
|
105
114
|
|
|
106
115
|
formatCommand(targetDir: string): FormatCommand | null {
|
package/src/php/models.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
|
|
3
|
-
import { className, enumClassName,
|
|
3
|
+
import { className, enumClassName, domainFieldName } from './naming.js';
|
|
4
4
|
import { phpDocComment } from './utils.js';
|
|
5
5
|
|
|
6
6
|
// Import and re-export shared model detection utilities
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
collectNonPaginatedResponseModelNames,
|
|
11
11
|
collectReferencedListMetadataModels,
|
|
12
12
|
} from '../shared/model-utils.js';
|
|
13
|
+
import { isModelInScope } from '../shared/resolved-ops.js';
|
|
13
14
|
export { isListMetadataModel, isListWrapperModel };
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -67,7 +68,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
67
68
|
// Deduplicate fields that map to the same PHP name
|
|
68
69
|
const seenNames = new Set<string>();
|
|
69
70
|
const allFields = [...requiredFields, ...optionalFields].filter((f) => {
|
|
70
|
-
|
|
71
|
+
// DOMAIN identifier: the PHP property name (honors a `domainName` override).
|
|
72
|
+
const phpName = domainFieldName(f);
|
|
71
73
|
if (seenNames.has(phpName)) return false;
|
|
72
74
|
seenNames.add(phpName);
|
|
73
75
|
return true;
|
|
@@ -75,7 +77,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
75
77
|
|
|
76
78
|
for (let i = 0; i < allFields.length; i++) {
|
|
77
79
|
const field = allFields[i];
|
|
78
|
-
|
|
80
|
+
// DOMAIN identifier: the promoted constructor property name.
|
|
81
|
+
const phpName = domainFieldName(field);
|
|
79
82
|
const phpType = mapTypeRef(field.type);
|
|
80
83
|
const isOptional = !field.required;
|
|
81
84
|
const comma = i < allFields.length - 1 ? ',' : ',';
|
|
@@ -108,7 +111,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
108
111
|
lines.push(` return new self(`);
|
|
109
112
|
for (let i = 0; i < allFields.length; i++) {
|
|
110
113
|
const field = allFields[i];
|
|
111
|
-
|
|
114
|
+
// DOMAIN identifier: the named constructor argument (PHP property).
|
|
115
|
+
const phpName = domainFieldName(field);
|
|
116
|
+
// WIRE key: the JSON key read from `$data[...]` (stays `field.name`).
|
|
112
117
|
const wireName = field.name;
|
|
113
118
|
const comma = i < allFields.length - 1 ? ',' : ',';
|
|
114
119
|
const accessor = generateFromArrayAccessor(field.type, wireName, field.required);
|
|
@@ -124,7 +129,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
124
129
|
lines.push(' {');
|
|
125
130
|
lines.push(' return [');
|
|
126
131
|
for (const field of allFields) {
|
|
127
|
-
|
|
132
|
+
// DOMAIN identifier: the `$this->...` property being serialized.
|
|
133
|
+
const phpName = domainFieldName(field);
|
|
134
|
+
// WIRE key: the JSON key emitted into the array (stays `field.name`).
|
|
128
135
|
const wireName = field.name;
|
|
129
136
|
const serialized = generateToArrayValue(field.type, `$this->${phpName}`, !field.required);
|
|
130
137
|
lines.push(` '${wireName}' => ${serialized},`);
|
|
@@ -134,11 +141,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
134
141
|
|
|
135
142
|
lines.push('}');
|
|
136
143
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
// FR-1.4: write the per-model FILE only when in scope. PHP uses PSR-4
|
|
145
|
+
// (one class per file under lib/Resource/, no barrel/index), so an
|
|
146
|
+
// out-of-scope model is simply left untouched on disk and stays loadable.
|
|
147
|
+
if (isModelInScope(model.name, ctx)) {
|
|
148
|
+
files.push({
|
|
149
|
+
path: `lib/Resource/${name}.php`,
|
|
150
|
+
content: lines.join('\n'),
|
|
151
|
+
overwriteExisting: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
return files;
|
package/src/php/naming.ts
CHANGED
|
@@ -138,6 +138,16 @@ export function fieldName(name: string): string {
|
|
|
138
138
|
return toCamelCase(name);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* camelCase DOMAIN property name for a model field, honoring a `domainName`
|
|
143
|
+
* override (set via the `fieldHints` config) so a wire field can surface under
|
|
144
|
+
* a friendlier PHP property name. The wire key (see {@link wireName}) still
|
|
145
|
+
* derives from `field.name`. No-op when `domainName` is unset.
|
|
146
|
+
*/
|
|
147
|
+
export function domainFieldName(field: { name: string; domainName?: string }): string {
|
|
148
|
+
return fieldName(field.domainName ?? field.name);
|
|
149
|
+
}
|
|
150
|
+
|
|
141
151
|
/** snake_case name for fixtures and other snake_case contexts. */
|
|
142
152
|
export function snakeName(name: string): string {
|
|
143
153
|
return toSnakeCase(stripUrnPrefix(name));
|