@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.
Files changed (126) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-Dws9b6T7.mjs +21441 -0
  14. package/dist/plugin-Dws9b6T7.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +17 -41
  21. package/smoke/sdk-dotnet.ts +11 -5
  22. package/smoke/sdk-elixir.ts +11 -5
  23. package/smoke/sdk-go.ts +10 -4
  24. package/smoke/sdk-kotlin.ts +11 -5
  25. package/smoke/sdk-node.ts +11 -5
  26. package/smoke/sdk-php.ts +9 -4
  27. package/smoke/sdk-python.ts +10 -4
  28. package/smoke/sdk-ruby.ts +10 -4
  29. package/smoke/sdk-rust.ts +11 -5
  30. package/src/dotnet/index.ts +9 -7
  31. package/src/dotnet/manifest.ts +5 -11
  32. package/src/dotnet/models.ts +58 -82
  33. package/src/dotnet/naming.ts +44 -6
  34. package/src/dotnet/resources.ts +350 -29
  35. package/src/dotnet/tests.ts +44 -24
  36. package/src/dotnet/type-map.ts +44 -17
  37. package/src/dotnet/wrappers.ts +21 -10
  38. package/src/go/client.ts +35 -3
  39. package/src/go/enums.ts +4 -0
  40. package/src/go/index.ts +13 -8
  41. package/src/go/manifest.ts +5 -11
  42. package/src/go/models.ts +6 -1
  43. package/src/go/resources.ts +534 -73
  44. package/src/go/tests.ts +39 -3
  45. package/src/go/type-map.ts +8 -3
  46. package/src/go/wrappers.ts +79 -21
  47. package/src/index.ts +14 -0
  48. package/src/kotlin/client.ts +7 -2
  49. package/src/kotlin/enums.ts +30 -3
  50. package/src/kotlin/index.ts +3 -3
  51. package/src/kotlin/manifest.ts +9 -15
  52. package/src/kotlin/models.ts +97 -6
  53. package/src/kotlin/naming.ts +7 -1
  54. package/src/kotlin/resources.ts +370 -39
  55. package/src/kotlin/tests.ts +120 -6
  56. package/src/node/client.ts +38 -11
  57. package/src/node/field-plan.ts +12 -14
  58. package/src/node/fixtures.ts +39 -3
  59. package/src/node/index.ts +3 -3
  60. package/src/node/manifest.ts +4 -11
  61. package/src/node/models.ts +281 -37
  62. package/src/node/resources.ts +156 -52
  63. package/src/node/tests.ts +76 -27
  64. package/src/node/type-map.ts +1 -31
  65. package/src/node/utils.ts +96 -6
  66. package/src/node/wrappers.ts +31 -1
  67. package/src/php/index.ts +3 -3
  68. package/src/php/manifest.ts +5 -11
  69. package/src/php/models.ts +0 -33
  70. package/src/php/resources.ts +199 -18
  71. package/src/php/tests.ts +26 -2
  72. package/src/php/type-map.ts +16 -2
  73. package/src/php/wrappers.ts +6 -2
  74. package/src/plugin.ts +50 -0
  75. package/src/python/client.ts +13 -3
  76. package/src/python/enums.ts +28 -3
  77. package/src/python/index.ts +38 -30
  78. package/src/python/manifest.ts +5 -12
  79. package/src/python/models.ts +138 -1
  80. package/src/python/resources.ts +234 -17
  81. package/src/python/tests.ts +260 -16
  82. package/src/python/type-map.ts +16 -2
  83. package/src/ruby/client.ts +238 -0
  84. package/src/ruby/enums.ts +149 -0
  85. package/src/ruby/index.ts +93 -0
  86. package/src/ruby/manifest.ts +28 -0
  87. package/src/ruby/models.ts +360 -0
  88. package/src/ruby/naming.ts +187 -0
  89. package/src/ruby/rbi.ts +313 -0
  90. package/src/ruby/resources.ts +799 -0
  91. package/src/ruby/tests.ts +459 -0
  92. package/src/ruby/type-map.ts +97 -0
  93. package/src/ruby/wrappers.ts +161 -0
  94. package/src/shared/model-utils.ts +131 -7
  95. package/src/shared/naming-utils.ts +36 -0
  96. package/src/shared/non-spec-services.ts +13 -0
  97. package/src/shared/resolved-ops.ts +75 -1
  98. package/test/dotnet/client.test.ts +2 -2
  99. package/test/dotnet/manifest.test.ts +13 -12
  100. package/test/dotnet/models.test.ts +7 -9
  101. package/test/dotnet/resources.test.ts +135 -3
  102. package/test/dotnet/tests.test.ts +5 -5
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +1 -1
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/node/models.test.ts +134 -1
  109. package/test/node/resources.test.ts +134 -26
  110. package/test/node/utils.test.ts +140 -0
  111. package/test/php/models.test.ts +5 -4
  112. package/test/php/resources.test.ts +66 -1
  113. package/test/plugin.test.ts +50 -0
  114. package/test/python/client.test.ts +56 -0
  115. package/test/python/manifest.test.ts +7 -7
  116. package/test/python/models.test.ts +99 -0
  117. package/test/python/resources.test.ts +294 -0
  118. package/test/python/tests.test.ts +91 -0
  119. package/test/ruby/client.test.ts +81 -0
  120. package/test/ruby/resources.test.ts +386 -0
  121. package/test/shared/resolved-ops.test.ts +122 -0
  122. package/tsconfig.json +1 -0
  123. package/tsdown.config.ts +1 -1
  124. package/dist/index.mjs.map +0 -1
  125. package/scripts/generate-php.js +0 -13
  126. 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(models: Model[], ctx?: EmitterContext): Map<string, string> {
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
- dedup.set(model.name, existing);
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 by name
307
- group.sort((a, b) => b.fields.length - a.fields.length || a.name.localeCompare(b.name));
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
+ }
@@ -77,7 +77,37 @@ function emitWrapperMethod(
77
77
  const returnType = responseTypeName ?? 'void';
78
78
 
79
79
  // JSDoc
80
- lines.push(` /** ${formatWrapperDescription(wrapper.name)}. */`);
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 { generateManifest } from './manifest.js';
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
- generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
74
+ buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
75
75
  ensureNamingInitialized(ctx);
76
- return ensureTrailingNewlines(generateManifest(spec, ctx));
76
+ return buildOperationsMap(spec, ctx);
77
77
  },
78
78
 
79
79
  fileHeader(): string {
@@ -1,13 +1,13 @@
1
- import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
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
- * Generate smoke test manifest mapping HTTP operations to SDK methods.
7
+ * Build operation-to-SDK-method mapping for the manifest.
8
8
  */
9
- export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
10
- const manifest: Record<string, { sdkMethod: string; service: string }> = {};
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
- }
@@ -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 query params
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
- const nullSuffix = !q.required && !docType.endsWith('|null') ? '|null' : '';
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 needsQuery = queryLines.length > 0 || hasDefaults || hasInferred;
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 hasOptionalQuery = op.queryParams.some((q) => !q.required && !hiddenParams.has(q.name));
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
- lines.push(` return $this->client->buildUrl(${path}, $query, $options);`);
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
- if (queryLines.length > 0) {
274
- lines.push(' $query = array_filter([');
275
- for (const q of queryLines) {
276
- lines.push(` ${q}`);
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
- lines.push(' ], fn ($v) => $v !== null);');
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 (queryLines.length > 0) {
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 needsQuery = queryLines.length > 0 || hasDefaults || hasInferred;
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 hasOptionalQuery = op.queryParams.some((q) => !q.required && !hiddenParams.has(q.name));
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
- // Query params
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
- const nullsafe = q.required ? '' : '?';
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},`;