@workos/oagen-emitters 0.4.0 → 0.6.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +9 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -15234
- package/dist/plugin-Dws9b6T7.mjs +21441 -0
- package/dist/plugin-Dws9b6T7.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +5 -5
- package/oagen.config.ts +5 -373
- package/package.json +17 -41
- package/smoke/sdk-dotnet.ts +11 -5
- package/smoke/sdk-elixir.ts +11 -5
- package/smoke/sdk-go.ts +10 -4
- package/smoke/sdk-kotlin.ts +11 -5
- package/smoke/sdk-node.ts +11 -5
- package/smoke/sdk-php.ts +9 -4
- package/smoke/sdk-python.ts +10 -4
- package/smoke/sdk-ruby.ts +10 -4
- package/smoke/sdk-rust.ts +11 -5
- package/src/dotnet/index.ts +9 -7
- package/src/dotnet/manifest.ts +5 -11
- package/src/dotnet/models.ts +58 -82
- package/src/dotnet/naming.ts +44 -6
- package/src/dotnet/resources.ts +350 -29
- package/src/dotnet/tests.ts +44 -24
- package/src/dotnet/type-map.ts +44 -17
- package/src/dotnet/wrappers.ts +21 -10
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +13 -8
- package/src/go/manifest.ts +5 -11
- package/src/go/models.ts +6 -1
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +14 -0
- package/src/kotlin/client.ts +7 -2
- package/src/kotlin/enums.ts +30 -3
- package/src/kotlin/index.ts +3 -3
- package/src/kotlin/manifest.ts +9 -15
- package/src/kotlin/models.ts +97 -6
- package/src/kotlin/naming.ts +7 -1
- package/src/kotlin/resources.ts +370 -39
- package/src/kotlin/tests.ts +120 -6
- package/src/node/client.ts +38 -11
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +3 -3
- package/src/node/manifest.ts +4 -11
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +156 -52
- package/src/node/tests.ts +76 -27
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/index.ts +3 -3
- package/src/php/manifest.ts +5 -11
- package/src/php/models.ts +0 -33
- package/src/php/resources.ts +199 -18
- package/src/php/tests.ts +26 -2
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +6 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +13 -3
- package/src/python/enums.ts +28 -3
- package/src/python/index.ts +38 -30
- package/src/python/manifest.ts +5 -12
- package/src/python/models.ts +138 -1
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +28 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +131 -7
- package/src/shared/naming-utils.ts +36 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/test/dotnet/client.test.ts +2 -2
- package/test/dotnet/manifest.test.ts +13 -12
- package/test/dotnet/models.test.ts +7 -9
- package/test/dotnet/resources.test.ts +135 -3
- package/test/dotnet/tests.test.ts +5 -5
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +1 -1
- package/test/kotlin/resources.test.ts +210 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +134 -26
- package/test/node/utils.test.ts +140 -0
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +66 -1
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/manifest.test.ts +7 -7
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsconfig.json +1 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/node/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Model, EmitterContext, Service, Operation } from '@workos/oagen';
|
|
1
|
+
import type { Model, EmitterContext, Service, Operation, TypeRef } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
export {
|
|
4
4
|
collectModelRefs,
|
|
@@ -269,17 +269,31 @@ function modelFingerprint(model: Model): string {
|
|
|
269
269
|
*
|
|
270
270
|
* Returns a Map from duplicate model name → canonical model name.
|
|
271
271
|
*/
|
|
272
|
-
export function buildDeduplicationMap(
|
|
272
|
+
export function buildDeduplicationMap(
|
|
273
|
+
models: Model[],
|
|
274
|
+
ctx?: EmitterContext,
|
|
275
|
+
reachable?: Set<string>,
|
|
276
|
+
): Map<string, string> {
|
|
273
277
|
const dedup = new Map<string, string>();
|
|
274
278
|
|
|
275
279
|
// Pass 1: structural fingerprint dedup (exact match)
|
|
280
|
+
// When a reachability set is provided, prefer reachable models as canonicals
|
|
281
|
+
// so that aliases always point to models that will actually be generated.
|
|
276
282
|
const fingerprints = new Map<string, string>();
|
|
277
283
|
for (const model of models) {
|
|
278
284
|
if (model.fields.length === 0) continue;
|
|
279
285
|
const fp = modelFingerprint(model);
|
|
280
286
|
const existing = fingerprints.get(fp);
|
|
281
287
|
if (existing) {
|
|
282
|
-
|
|
288
|
+
// If the existing canonical is unreachable but this model is reachable,
|
|
289
|
+
// swap: make this model the canonical and demote the old one to alias.
|
|
290
|
+
if (reachable && !reachable.has(existing) && reachable.has(model.name)) {
|
|
291
|
+
dedup.delete(existing); // remove stale alias if present
|
|
292
|
+
dedup.set(existing, model.name);
|
|
293
|
+
fingerprints.set(fp, model.name);
|
|
294
|
+
} else {
|
|
295
|
+
dedup.set(model.name, existing);
|
|
296
|
+
}
|
|
283
297
|
} else {
|
|
284
298
|
fingerprints.set(fp, model.name);
|
|
285
299
|
}
|
|
@@ -287,7 +301,8 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
|
|
|
287
301
|
|
|
288
302
|
// Pass 2: name-based dedup for models that resolve to the same interface
|
|
289
303
|
// name across services. Only applies when context with name resolution is
|
|
290
|
-
// available. Picks the model with the most fields as canonical
|
|
304
|
+
// available. Picks the model with the most fields as canonical, preferring
|
|
305
|
+
// reachable models when a reachability set is provided.
|
|
291
306
|
if (ctx) {
|
|
292
307
|
const byDomainName = new Map<string, Model[]>();
|
|
293
308
|
for (const model of models) {
|
|
@@ -303,8 +318,15 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
|
|
|
303
318
|
}
|
|
304
319
|
for (const [, group] of byDomainName) {
|
|
305
320
|
if (group.length < 2) continue;
|
|
306
|
-
// Choose canonical: most fields, then alphabetically
|
|
307
|
-
group.sort((a, b) =>
|
|
321
|
+
// Choose canonical: prefer reachable, then most fields, then alphabetically
|
|
322
|
+
group.sort((a, b) => {
|
|
323
|
+
if (reachable) {
|
|
324
|
+
const aReach = reachable.has(a.name) ? 0 : 1;
|
|
325
|
+
const bReach = reachable.has(b.name) ? 0 : 1;
|
|
326
|
+
if (aReach !== bReach) return aReach - bReach;
|
|
327
|
+
}
|
|
328
|
+
return b.fields.length - a.fields.length || a.name.localeCompare(b.name);
|
|
329
|
+
});
|
|
308
330
|
const canonical = group[0];
|
|
309
331
|
for (let i = 1; i < group.length; i++) {
|
|
310
332
|
dedup.set(group[i].name, canonical.name);
|
|
@@ -397,6 +419,26 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
|
|
|
397
419
|
return false;
|
|
398
420
|
}
|
|
399
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Check whether an IR model has fields not present in the baseline interface.
|
|
424
|
+
* Returns true if the model has new fields that need generation.
|
|
425
|
+
* Returns true if no baseline exists (new model entirely).
|
|
426
|
+
*/
|
|
427
|
+
export function modelHasNewFields(model: Model, ctx: EmitterContext): boolean {
|
|
428
|
+
if (!ctx.apiSurface?.interfaces) return true; // No surface = generate everything
|
|
429
|
+
|
|
430
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
431
|
+
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
432
|
+
if (!baseline?.fields) return true; // No baseline for this model = new model
|
|
433
|
+
|
|
434
|
+
for (const field of model.fields) {
|
|
435
|
+
const camelName = fieldName(field.name);
|
|
436
|
+
if (!baseline.fields[camelName]) return true; // New field found
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return false; // All fields exist in baseline
|
|
440
|
+
}
|
|
441
|
+
|
|
400
442
|
/**
|
|
401
443
|
* Return operations in a service that are NOT covered by existing hand-written
|
|
402
444
|
* service classes. For fully uncovered services, returns all operations.
|
|
@@ -417,3 +459,51 @@ export function uncoveredOperations(service: Service, ctx: EmitterContext): Oper
|
|
|
417
459
|
return !existingClassNames.has(match.className); // Class doesn't exist → uncovered
|
|
418
460
|
});
|
|
419
461
|
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Compute the set of model names reachable from non-event service operations.
|
|
465
|
+
* The Events service pulls in hundreds of webhook payload models that the
|
|
466
|
+
* existing SDK handles via hand-written event types, so those models are
|
|
467
|
+
* excluded from generation.
|
|
468
|
+
*
|
|
469
|
+
* Shared between model generation, barrel generation, dedup, and tests to
|
|
470
|
+
* ensure consistency: every module agrees on which models will be generated.
|
|
471
|
+
*/
|
|
472
|
+
export function computeNonEventReachable(services: Service[], models: Model[]): Set<string> {
|
|
473
|
+
const seeds = new Set<string>();
|
|
474
|
+
for (const svc of services) {
|
|
475
|
+
if (svc.name.toLowerCase() === 'events') continue;
|
|
476
|
+
for (const op of svc.operations) {
|
|
477
|
+
const collectFromRef = (t: TypeRef | undefined): void => {
|
|
478
|
+
if (!t) return;
|
|
479
|
+
if (t.kind === 'model') seeds.add(t.name);
|
|
480
|
+
if (t.kind === 'array') collectFromRef(t.items);
|
|
481
|
+
if (t.kind === 'nullable') collectFromRef(t.inner);
|
|
482
|
+
if (t.kind === 'union') t.variants.forEach(collectFromRef);
|
|
483
|
+
};
|
|
484
|
+
collectFromRef(op.response);
|
|
485
|
+
collectFromRef(op.requestBody);
|
|
486
|
+
if (op.pagination?.itemType) collectFromRef(op.pagination.itemType);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const modelMap = new Map(models.map((m) => [m.name, m]));
|
|
490
|
+
const reachable = new Set<string>();
|
|
491
|
+
const queue = [...seeds];
|
|
492
|
+
while (queue.length > 0) {
|
|
493
|
+
const name = queue.pop()!;
|
|
494
|
+
if (reachable.has(name)) continue;
|
|
495
|
+
reachable.add(name);
|
|
496
|
+
const m = modelMap.get(name);
|
|
497
|
+
if (!m) continue;
|
|
498
|
+
for (const field of m.fields) {
|
|
499
|
+
const walk = (t: TypeRef): void => {
|
|
500
|
+
if (t.kind === 'model' && !reachable.has(t.name)) queue.push(t.name);
|
|
501
|
+
if (t.kind === 'array') walk(t.items);
|
|
502
|
+
if (t.kind === 'nullable') walk(t.inner);
|
|
503
|
+
if (t.kind === 'union') t.variants.forEach(walk);
|
|
504
|
+
};
|
|
505
|
+
walk(field.type);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return reachable;
|
|
509
|
+
}
|
package/src/node/wrappers.ts
CHANGED
|
@@ -77,7 +77,37 @@ function emitWrapperMethod(
|
|
|
77
77
|
const returnType = responseTypeName ?? 'void';
|
|
78
78
|
|
|
79
79
|
// JSDoc
|
|
80
|
-
|
|
80
|
+
const docParts: string[] = [];
|
|
81
|
+
docParts.push(formatWrapperDescription(wrapper.name) + '.');
|
|
82
|
+
|
|
83
|
+
for (const p of op.pathParams) {
|
|
84
|
+
if (p.description) {
|
|
85
|
+
docParts.push(`@param ${fieldName(p.name)} - ${p.description}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const { paramName, field } of wrapperParams) {
|
|
90
|
+
const tsName = fieldName(paramName);
|
|
91
|
+
if (field?.description) {
|
|
92
|
+
docParts.push(`@param ${tsName} - ${field.description}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (responseTypeName) {
|
|
97
|
+
docParts.push(`@returns {Promise<${returnType}>}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (docParts.length === 1) {
|
|
101
|
+
lines.push(` /** ${docParts[0]} */`);
|
|
102
|
+
} else {
|
|
103
|
+
lines.push(' /**');
|
|
104
|
+
for (const part of docParts) {
|
|
105
|
+
for (const line of part.split('\n')) {
|
|
106
|
+
lines.push(line === '' ? ' *' : ` * ${line}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
lines.push(' */');
|
|
110
|
+
}
|
|
81
111
|
|
|
82
112
|
// Method signature
|
|
83
113
|
lines.push(` async ${method}(${paramParts.join(', ')}): Promise<${returnType}> {`);
|
package/src/php/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { generateEnums } from './enums.js';
|
|
|
16
16
|
import { generateResources } from './resources.js';
|
|
17
17
|
import { generateClient } from './client.js';
|
|
18
18
|
import { generateTests } from './tests.js';
|
|
19
|
-
import {
|
|
19
|
+
import { buildOperationsMap } from './manifest.js';
|
|
20
20
|
import { initializeEnumDedup } from './naming.js';
|
|
21
21
|
|
|
22
22
|
/** Initialize enum deduplication from spec data. */
|
|
@@ -71,9 +71,9 @@ export const phpEmitter: Emitter = {
|
|
|
71
71
|
return ensureTrailingNewlines(generateTests(spec, ctx));
|
|
72
72
|
},
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
|
|
75
75
|
ensureNamingInitialized(ctx);
|
|
76
|
-
return
|
|
76
|
+
return buildOperationsMap(spec, ctx);
|
|
77
77
|
},
|
|
78
78
|
|
|
79
79
|
fileHeader(): string {
|
package/src/php/manifest.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { ApiSpec, EmitterContext,
|
|
1
|
+
import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
|
|
2
2
|
import { resolveMethodName } from './naming.js';
|
|
3
3
|
import { buildServiceAccessPaths } from './client.js';
|
|
4
4
|
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Build operation-to-SDK-method mapping for the manifest.
|
|
8
8
|
*/
|
|
9
|
-
export function
|
|
10
|
-
const manifest:
|
|
9
|
+
export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
|
|
10
|
+
const manifest: OperationsMap = {};
|
|
11
11
|
const accessPaths = buildServiceAccessPaths(spec.services, ctx);
|
|
12
12
|
|
|
13
13
|
for (const service of spec.services) {
|
|
@@ -26,11 +26,5 @@ export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
return
|
|
30
|
-
{
|
|
31
|
-
path: 'smoke-manifest.json',
|
|
32
|
-
content: JSON.stringify(manifest, null, 2),
|
|
33
|
-
integrateTarget: false,
|
|
34
|
-
},
|
|
35
|
-
];
|
|
29
|
+
return manifest;
|
|
36
30
|
}
|
package/src/php/models.ts
CHANGED
|
@@ -13,30 +13,6 @@ export { isListMetadataModel, isListWrapperModel };
|
|
|
13
13
|
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
14
14
|
if (models.length === 0) return [];
|
|
15
15
|
|
|
16
|
-
// Build structural hash for deduplication
|
|
17
|
-
const modelHashMap = new Map<string, string>();
|
|
18
|
-
const hashGroups = new Map<string, string[]>();
|
|
19
|
-
for (const model of models) {
|
|
20
|
-
if (isListMetadataModel(model)) continue;
|
|
21
|
-
if (isListWrapperModel(model)) continue;
|
|
22
|
-
const hash = structuralHash(model);
|
|
23
|
-
modelHashMap.set(model.name, hash);
|
|
24
|
-
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
25
|
-
hashGroups.get(hash)!.push(model.name);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Pick canonical for each duplicate group (shortest class name wins)
|
|
29
|
-
const aliasOf = new Map<string, string>();
|
|
30
|
-
for (const [hash, names] of hashGroups) {
|
|
31
|
-
if (names.length <= 1) continue;
|
|
32
|
-
if (hash === '') continue;
|
|
33
|
-
const sorted = [...names].sort((a, b) => className(a).length - className(b).length);
|
|
34
|
-
const canonical = sorted[0];
|
|
35
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
36
|
-
aliasOf.set(sorted[i], canonical);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
16
|
const files: GeneratedFile[] = [];
|
|
41
17
|
|
|
42
18
|
// Emit shared JsonSerializableTrait once
|
|
@@ -59,8 +35,6 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
59
35
|
for (const model of models) {
|
|
60
36
|
if (isListMetadataModel(model)) continue;
|
|
61
37
|
if (isListWrapperModel(model)) continue;
|
|
62
|
-
if (aliasOf.has(model.name)) continue; // skip structural duplicates
|
|
63
|
-
|
|
64
38
|
const name = className(model.name);
|
|
65
39
|
const lines: string[] = [];
|
|
66
40
|
|
|
@@ -301,10 +275,3 @@ function needsVarAnnotation(ref: TypeRef): boolean {
|
|
|
301
275
|
return false;
|
|
302
276
|
}
|
|
303
277
|
}
|
|
304
|
-
|
|
305
|
-
function structuralHash(model: Model): string {
|
|
306
|
-
return model.fields
|
|
307
|
-
.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`)
|
|
308
|
-
.sort()
|
|
309
|
-
.join('|');
|
|
310
|
-
}
|
package/src/php/resources.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Service, Operation, Model, EmitterContext, GeneratedFile, ResolvedOperation } from '@workos/oagen';
|
|
2
|
-
import { planOperation, toCamelCase } from '@workos/oagen';
|
|
2
|
+
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
3
3
|
import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
|
|
4
4
|
import { className, fieldName, resolveMethodName } from './naming.js';
|
|
5
5
|
import { isListWrapperModel } from './models.js';
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
lookupResolved,
|
|
10
10
|
getOpDefaults,
|
|
11
11
|
getOpInferFromClient,
|
|
12
|
+
collectGroupedParamNames,
|
|
13
|
+
collectBodyFieldTypes,
|
|
12
14
|
} from '../shared/resolved-ops.js';
|
|
13
15
|
import { generateWrapperMethods } from './wrappers.js';
|
|
14
16
|
import { phpDocComment } from './utils.js';
|
|
@@ -91,6 +93,13 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
91
93
|
content: lines.join('\n'),
|
|
92
94
|
overwriteExisting: true,
|
|
93
95
|
});
|
|
96
|
+
|
|
97
|
+
// Generate variant class files for operations with parameter groups
|
|
98
|
+
for (const op of operations) {
|
|
99
|
+
if ((op.parameterGroups?.length ?? 0) > 0) {
|
|
100
|
+
files.push(...generateParameterGroupFiles(op, ctx, modelMap));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
return files;
|
|
@@ -119,6 +128,100 @@ export function isRedirectEndpoint(op: Operation, resolvedOp?: ResolvedOperation
|
|
|
119
128
|
return false;
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Mutually-exclusive parameter group support
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** PHP class name for a parameter group variant (e.g. ParentResourceById). */
|
|
136
|
+
function groupVariantClassName(groupName: string, variantName: string): string {
|
|
137
|
+
return `${className(groupName)}${className(variantName)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Derive a short PHP property name for a parameter within a variant class.
|
|
142
|
+
* Strips the group name prefix when present to avoid stuttering
|
|
143
|
+
* (e.g. parent_resource_id in group parent_resource -> id -> camelCase).
|
|
144
|
+
*/
|
|
145
|
+
export function deriveVariantFieldName(paramName: string, groupName: string): string {
|
|
146
|
+
const prefix = groupName + '_';
|
|
147
|
+
const stripped = paramName.startsWith(prefix) ? paramName.slice(prefix.length) : paramName;
|
|
148
|
+
return fieldName(stripped);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate PHP variant class files for all parameter groups on an operation.
|
|
153
|
+
* Each variant becomes a simple PHP class with readonly constructor properties.
|
|
154
|
+
*/
|
|
155
|
+
function generateParameterGroupFiles(
|
|
156
|
+
op: Operation,
|
|
157
|
+
ctx: EmitterContext,
|
|
158
|
+
modelMap: Map<string, Model>,
|
|
159
|
+
): GeneratedFile[] {
|
|
160
|
+
const files: GeneratedFile[] = [];
|
|
161
|
+
const bodyFieldTypes = collectBodyFieldTypes(op, [...modelMap.values()]);
|
|
162
|
+
|
|
163
|
+
for (const group of op.parameterGroups ?? []) {
|
|
164
|
+
for (const variant of group.variants) {
|
|
165
|
+
const variantClass = groupVariantClassName(group.name, variant.name);
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
|
|
168
|
+
lines.push(`namespace ${ctx.namespacePascal}\\Service;`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push(`class ${variantClass}`);
|
|
171
|
+
lines.push('{');
|
|
172
|
+
lines.push(' public function __construct(');
|
|
173
|
+
for (let i = 0; i < variant.parameters.length; i++) {
|
|
174
|
+
const param = variant.parameters[i];
|
|
175
|
+
const effectiveType = bodyFieldTypes.get(param.name) ?? param.type;
|
|
176
|
+
const phpType = mapTypeRef(effectiveType, { qualified: true });
|
|
177
|
+
const phpName = deriveVariantFieldName(param.name, group.name);
|
|
178
|
+
const comma = ',';
|
|
179
|
+
lines.push(` public readonly ${phpType} $${phpName}${comma}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push(' ) {');
|
|
182
|
+
lines.push(' }');
|
|
183
|
+
lines.push('}');
|
|
184
|
+
|
|
185
|
+
files.push({
|
|
186
|
+
path: `lib/Service/${variantClass}.php`,
|
|
187
|
+
content: lines.join('\n'),
|
|
188
|
+
overwriteExisting: true,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return files;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Generate instanceof dispatch lines to serialize a grouped parameter
|
|
198
|
+
* into a target array ($query or $body) using each variant's wire names.
|
|
199
|
+
*/
|
|
200
|
+
function generateGroupDispatch(op: Operation, indent: string, target: '$query' | '$body' = '$query'): string[] {
|
|
201
|
+
const lines: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (const group of op.parameterGroups ?? []) {
|
|
204
|
+
const phpParamName = fieldName(group.name);
|
|
205
|
+
|
|
206
|
+
for (let vi = 0; vi < group.variants.length; vi++) {
|
|
207
|
+
const variant = group.variants[vi];
|
|
208
|
+
const variantClass = groupVariantClassName(group.name, variant.name);
|
|
209
|
+
const keyword = vi === 0 ? 'if' : 'elseif';
|
|
210
|
+
|
|
211
|
+
lines.push(`${indent}${keyword} ($${phpParamName} instanceof ${variantClass}) {`);
|
|
212
|
+
|
|
213
|
+
for (const param of variant.parameters) {
|
|
214
|
+
const phpField = deriveVariantFieldName(param.name, group.name);
|
|
215
|
+
lines.push(`${indent} ${target}['${param.name}'] = $${phpParamName}->${phpField};`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
lines.push(`${indent}}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
|
|
122
225
|
function generateMethod(
|
|
123
226
|
lines: string[],
|
|
124
227
|
op: Operation,
|
|
@@ -176,14 +279,29 @@ function generateMethod(
|
|
|
176
279
|
}
|
|
177
280
|
}
|
|
178
281
|
|
|
179
|
-
// @param for
|
|
282
|
+
// @param for parameter groups (union-typed)
|
|
283
|
+
const groupedParamNames = collectGroupedParamNames(op);
|
|
284
|
+
for (const group of op.parameterGroups ?? []) {
|
|
285
|
+
const phpName = fieldName(group.name);
|
|
286
|
+
if (seenDocParams.has(phpName)) continue;
|
|
287
|
+
seenDocParams.add(phpName);
|
|
288
|
+
const variantTypes = group.variants.map((v) => groupVariantClassName(group.name, v.name));
|
|
289
|
+
const unionDocType = variantTypes.join('|');
|
|
290
|
+
const nullPrefix = group.optional ? 'null|' : '';
|
|
291
|
+
docParts.push(`@param ${nullPrefix}${unionDocType} $${phpName}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// @param for query params (skip grouped params — they appear as group union params)
|
|
180
295
|
for (const q of op.queryParams) {
|
|
181
296
|
if (hiddenParams.has(q.name)) continue;
|
|
297
|
+
if (groupedParamNames.has(q.name)) continue;
|
|
182
298
|
const docType = mapTypeRefForPHPDoc(q.type);
|
|
183
299
|
const phpName = fieldName(q.name);
|
|
184
300
|
if (seenDocParams.has(phpName)) continue;
|
|
185
301
|
seenDocParams.add(phpName);
|
|
186
|
-
|
|
302
|
+
// order params with enum defaults are non-nullable (they default to Desc, not null)
|
|
303
|
+
const isNonNullableOrder = q.name === 'order' && q.type.kind === 'enum';
|
|
304
|
+
const nullSuffix = !q.required && !isNonNullableOrder && !docType.endsWith('|null') ? '|null' : '';
|
|
187
305
|
const prefix = q.deprecated ? '(deprecated) ' : '';
|
|
188
306
|
let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
|
|
189
307
|
if (q.default != null) desc += ` Defaults to ${JSON.stringify(q.default)}.`;
|
|
@@ -239,12 +357,18 @@ function generateMethod(
|
|
|
239
357
|
const queryLines = buildQueryArray(op, hiddenParams);
|
|
240
358
|
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
241
359
|
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
242
|
-
const
|
|
360
|
+
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
|
|
361
|
+
const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred || hasGroups;
|
|
243
362
|
|
|
244
363
|
if (needsQuery) {
|
|
245
|
-
const
|
|
364
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
365
|
+
const hasOptionalQuery = op.queryParams.some(
|
|
366
|
+
(q) => !q.required && !hiddenParams.has(q.name) && !groupedParams.has(q.name),
|
|
367
|
+
);
|
|
246
368
|
if (hasOptionalQuery) {
|
|
247
369
|
lines.push(' $query = array_filter([');
|
|
370
|
+
} else if (queryLines.length > 0) {
|
|
371
|
+
lines.push(' $query = [');
|
|
248
372
|
} else {
|
|
249
373
|
lines.push(' $query = [');
|
|
250
374
|
}
|
|
@@ -264,23 +388,33 @@ function generateMethod(
|
|
|
264
388
|
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
265
389
|
lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
266
390
|
}
|
|
267
|
-
|
|
391
|
+
// Inject parameter group dispatch (instanceof checks)
|
|
392
|
+
lines.push(...generateGroupDispatch(op, ' '));
|
|
393
|
+
lines.push(` return $this->client->buildUrl(path: ${path}, query: $query, options: $options);`);
|
|
268
394
|
} else {
|
|
269
|
-
lines.push(` return $this->client->buildUrl(${path}, [], $options);`);
|
|
395
|
+
lines.push(` return $this->client->buildUrl(path: ${path}, query: [], options: $options);`);
|
|
270
396
|
}
|
|
271
397
|
} else if (plan.isPaginated) {
|
|
272
398
|
const queryLines = buildQueryArray(op);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
399
|
+
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
|
|
400
|
+
const needsQuery = queryLines.length > 0 || hasGroups;
|
|
401
|
+
if (needsQuery) {
|
|
402
|
+
if (queryLines.length > 0) {
|
|
403
|
+
lines.push(' $query = array_filter([');
|
|
404
|
+
for (const q of queryLines) {
|
|
405
|
+
lines.push(` ${q}`);
|
|
406
|
+
}
|
|
407
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
408
|
+
} else {
|
|
409
|
+
lines.push(' $query = [];');
|
|
277
410
|
}
|
|
278
|
-
|
|
411
|
+
// Inject parameter group dispatch (instanceof checks)
|
|
412
|
+
lines.push(...generateGroupDispatch(op, ' '));
|
|
279
413
|
}
|
|
280
414
|
lines.push(' return $this->client->requestPage(');
|
|
281
415
|
lines.push(` method: '${httpMethod}',`);
|
|
282
416
|
lines.push(` path: ${path},`);
|
|
283
|
-
if (
|
|
417
|
+
if (needsQuery) {
|
|
284
418
|
lines.push(' query: $query,');
|
|
285
419
|
}
|
|
286
420
|
const itemType = op.pagination?.itemType;
|
|
@@ -330,6 +464,10 @@ function generateMethod(
|
|
|
330
464
|
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
331
465
|
lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
332
466
|
}
|
|
467
|
+
// Inject parameter group dispatch into body
|
|
468
|
+
if ((op.parameterGroups?.length ?? 0) > 0) {
|
|
469
|
+
lines.push(...generateGroupDispatch(op, ' ', '$body'));
|
|
470
|
+
}
|
|
333
471
|
}
|
|
334
472
|
// Build query params if present
|
|
335
473
|
const deleteQueryLines = buildQueryArray(op);
|
|
@@ -381,6 +519,11 @@ function generateMethod(
|
|
|
381
519
|
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
382
520
|
lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
383
521
|
}
|
|
522
|
+
// Inject parameter group dispatch into body so sensitive fields
|
|
523
|
+
// (passwords, role slugs) never leak into the URL query string.
|
|
524
|
+
if ((op.parameterGroups?.length ?? 0) > 0) {
|
|
525
|
+
lines.push(...generateGroupDispatch(op, ' ', '$body'));
|
|
526
|
+
}
|
|
384
527
|
lines.push(' $response = $this->client->request(');
|
|
385
528
|
lines.push(` method: '${httpMethod}',`);
|
|
386
529
|
lines.push(` path: ${path},`);
|
|
@@ -402,12 +545,18 @@ function generateMethod(
|
|
|
402
545
|
const queryLines = buildQueryArray(op, hiddenParams);
|
|
403
546
|
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
404
547
|
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
405
|
-
const
|
|
548
|
+
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
|
|
549
|
+
const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred || hasGroups;
|
|
406
550
|
|
|
407
551
|
if (needsQuery) {
|
|
408
|
-
const
|
|
552
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
553
|
+
const hasOptionalQuery = op.queryParams.some(
|
|
554
|
+
(q) => !q.required && !hiddenParams.has(q.name) && !groupedParams.has(q.name),
|
|
555
|
+
);
|
|
409
556
|
if (hasOptionalQuery) {
|
|
410
557
|
lines.push(' $query = array_filter([');
|
|
558
|
+
} else if (queryLines.length > 0) {
|
|
559
|
+
lines.push(' $query = [');
|
|
411
560
|
} else {
|
|
412
561
|
lines.push(' $query = [');
|
|
413
562
|
}
|
|
@@ -427,6 +576,8 @@ function generateMethod(
|
|
|
427
576
|
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
428
577
|
lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
429
578
|
}
|
|
579
|
+
// Inject parameter group dispatch (instanceof checks)
|
|
580
|
+
lines.push(...generateGroupDispatch(op, ' '));
|
|
430
581
|
}
|
|
431
582
|
lines.push(' $response = $this->client->request(');
|
|
432
583
|
lines.push(` method: '${httpMethod}',`);
|
|
@@ -465,6 +616,7 @@ function buildMethodParams(
|
|
|
465
616
|
const optional: string[] = [];
|
|
466
617
|
const usedNames = new Set<string>();
|
|
467
618
|
const hidden = hiddenParams ?? new Set();
|
|
619
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
468
620
|
|
|
469
621
|
// Path params (always required)
|
|
470
622
|
for (const p of op.pathParams) {
|
|
@@ -499,15 +651,41 @@ function buildMethodParams(
|
|
|
499
651
|
}
|
|
500
652
|
}
|
|
501
653
|
|
|
502
|
-
//
|
|
654
|
+
// Parameter group union-typed params (before individual query params)
|
|
655
|
+
for (const group of op.parameterGroups ?? []) {
|
|
656
|
+
const phpName = fieldName(group.name);
|
|
657
|
+
if (usedNames.has(phpName)) continue;
|
|
658
|
+
usedNames.add(phpName);
|
|
659
|
+
// PHP 8.0+ union syntax: VariantA|VariantB $paramName
|
|
660
|
+
const variantTypes = group.variants.map((v) => groupVariantClassName(group.name, v.name));
|
|
661
|
+
const unionType = variantTypes.join('|');
|
|
662
|
+
if (group.optional) {
|
|
663
|
+
optional.push(`null|${unionType} $${phpName} = null`);
|
|
664
|
+
} else {
|
|
665
|
+
required.push(`${unionType} $${phpName}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Query params (skip grouped params — they are serialized via group dispatch)
|
|
503
670
|
for (const q of op.queryParams) {
|
|
504
671
|
if (hidden.has(q.name)) continue;
|
|
672
|
+
if (groupedParams.has(q.name)) continue;
|
|
505
673
|
const phpType = mapTypeRef(q.type, { qualified: true });
|
|
506
674
|
let phpName = fieldName(q.name);
|
|
507
675
|
if (usedNames.has(phpName)) continue;
|
|
508
676
|
usedNames.add(phpName);
|
|
509
677
|
if (q.required) {
|
|
510
678
|
required.push(`${phpType} $${phpName}`);
|
|
679
|
+
} else if (q.name === 'order') {
|
|
680
|
+
// Hardcode order default to desc for pagination consistency
|
|
681
|
+
if (q.type.kind === 'enum') {
|
|
682
|
+
const enumType = mapTypeRef(q.type, { qualified: true });
|
|
683
|
+
const caseName = toPascalCase('desc');
|
|
684
|
+
optional.push(`${enumType} $${phpName} = ${enumType}::${caseName}`);
|
|
685
|
+
} else {
|
|
686
|
+
const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
|
|
687
|
+
optional.push(`${nullableType} $${phpName} = 'desc'`);
|
|
688
|
+
}
|
|
511
689
|
} else {
|
|
512
690
|
const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
|
|
513
691
|
optional.push(`${nullableType} $${phpName} = null`);
|
|
@@ -574,12 +752,15 @@ function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
|
|
|
574
752
|
|
|
575
753
|
function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
|
|
576
754
|
const hidden = hiddenParams ?? new Set();
|
|
755
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
577
756
|
return op.queryParams
|
|
578
|
-
.filter((q) => !hidden.has(q.name))
|
|
757
|
+
.filter((q) => !hidden.has(q.name) && !groupedParams.has(q.name))
|
|
579
758
|
.map((q) => {
|
|
580
759
|
const phpName = fieldName(q.name);
|
|
581
760
|
if (isEnumType(q.type)) {
|
|
582
|
-
|
|
761
|
+
// order params with enum defaults are non-nullable (default to Desc, not null)
|
|
762
|
+
const isNonNullableOrder = q.name === 'order' && q.type.kind === 'enum';
|
|
763
|
+
const nullsafe = q.required || isNonNullableOrder ? '' : '?';
|
|
583
764
|
return `'${q.name}' => $${phpName}${nullsafe}->value,`;
|
|
584
765
|
}
|
|
585
766
|
return `'${q.name}' => $${phpName},`;
|