@workos/oagen-emitters 0.3.0 → 0.5.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 (128) 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 +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.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 +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  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 +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -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';
@@ -50,8 +52,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
50
52
  lines.push(`namespace ${ctx.namespacePascal}\\Service;`);
51
53
  lines.push('');
52
54
 
55
+ // Build resolved lookup early — used by both imports and method generation
56
+ const resolvedLookup = buildResolvedLookup(ctx);
57
+
53
58
  // Collect imports
54
- const imports = collectImports(mergedService, ctx);
59
+ const imports = collectImports(mergedService, ctx, resolvedLookup);
55
60
  for (const imp of imports) {
56
61
  lines.push(`use ${imp};`);
57
62
  }
@@ -66,7 +71,6 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
66
71
 
67
72
  // Track emitted method names to avoid duplicates
68
73
  const emittedMethods = new Set<string>();
69
- const resolvedLookup = buildResolvedLookup(ctx);
70
74
  for (const op of operations) {
71
75
  const method = resolveMethodName(op, mergedService, ctx);
72
76
  if (emittedMethods.has(method)) continue;
@@ -89,11 +93,135 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
89
93
  content: lines.join('\n'),
90
94
  overwriteExisting: true,
91
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
+ }
103
+ }
104
+
105
+ return files;
106
+ }
107
+
108
+ /**
109
+ * Check if an operation is a redirect endpoint that should construct a URL
110
+ * instead of making an HTTP request.
111
+ *
112
+ * Detection: GET endpoints with no response body (primitive unknown) and query
113
+ * params are redirect endpoints (e.g., SSO/OAuth authorize and logout flows).
114
+ * Also respects an explicit urlBuilder flag on the resolved operation and
115
+ * catches endpoints with 302 success responses.
116
+ */
117
+ export function isRedirectEndpoint(op: Operation, resolvedOp?: ResolvedOperation): boolean {
118
+ if ((resolvedOp as any)?.urlBuilder) return true;
119
+ if ((op as any).successResponses?.some((r: any) => r.statusCode >= 300 && r.statusCode < 400)) return true;
120
+ if (
121
+ op.httpMethod === 'get' &&
122
+ op.response.kind === 'primitive' &&
123
+ (op.response as any).type === 'unknown' &&
124
+ op.queryParams.length > 0
125
+ ) {
126
+ return true;
127
+ }
128
+ return false;
129
+ }
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
+ }
92
191
  }
93
192
 
94
193
  return files;
95
194
  }
96
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
+
97
225
  function generateMethod(
98
226
  lines: string[],
99
227
  op: Operation,
@@ -112,8 +240,9 @@ function generateMethod(
112
240
  ...getOpInferFromClient(resolvedOp),
113
241
  ]);
114
242
 
243
+ const isRedirect = isRedirectEndpoint(op, resolvedOp);
115
244
  const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams);
116
- const returnType = getReturnType(plan, ctx);
245
+ const returnType = isRedirect ? 'string' : getReturnType(plan, ctx);
117
246
 
118
247
  // PHPDoc block
119
248
  const docParts: string[] = [];
@@ -150,14 +279,29 @@ function generateMethod(
150
279
  }
151
280
  }
152
281
 
153
- // @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)
154
295
  for (const q of op.queryParams) {
155
296
  if (hiddenParams.has(q.name)) continue;
297
+ if (groupedParamNames.has(q.name)) continue;
156
298
  const docType = mapTypeRefForPHPDoc(q.type);
157
299
  const phpName = fieldName(q.name);
158
300
  if (seenDocParams.has(phpName)) continue;
159
301
  seenDocParams.add(phpName);
160
- 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' : '';
161
305
  const prefix = q.deprecated ? '(deprecated) ' : '';
162
306
  let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
163
307
  if (q.default != null) desc += ` Defaults to ${JSON.stringify(q.default)}.`;
@@ -183,6 +327,16 @@ function generateMethod(
183
327
  docParts.push(`@return ${returnType}`);
184
328
  }
185
329
 
330
+ // @throws — scope to what the method actually calls
331
+ if (!isRedirect) {
332
+ // HTTP methods can throw any WorkOSException (config, transport, API response)
333
+ docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\WorkOSException`);
334
+ } else if (getOpInferFromClient(resolvedOp).length > 0) {
335
+ // Redirect endpoints that inject client fields can throw ConfigurationException
336
+ docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\ConfigurationException`);
337
+ }
338
+ // Redirect endpoints with no inferFromClient: buildUrl() is pure, no @throws
339
+
186
340
  if (op.deprecated) docParts.push('@deprecated');
187
341
  lines.push(...phpDocComment(docParts.join('\n'), 4));
188
342
 
@@ -198,19 +352,69 @@ function generateMethod(
198
352
  const httpMethod = op.httpMethod.toUpperCase();
199
353
  const path = buildPathString(op);
200
354
 
201
- if (plan.isPaginated) {
202
- const queryLines = buildQueryArray(op);
203
- if (queryLines.length > 0) {
204
- lines.push(' $query = array_filter([');
355
+ if (isRedirect) {
356
+ // Redirect endpoint: construct URL client-side instead of making HTTP request
357
+ const queryLines = buildQueryArray(op, hiddenParams);
358
+ const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
359
+ const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
360
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
361
+ const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred || hasGroups;
362
+
363
+ if (needsQuery) {
364
+ const groupedParams = collectGroupedParamNames(op);
365
+ const hasOptionalQuery = op.queryParams.some(
366
+ (q) => !q.required && !hiddenParams.has(q.name) && !groupedParams.has(q.name),
367
+ );
368
+ if (hasOptionalQuery) {
369
+ lines.push(' $query = array_filter([');
370
+ } else if (queryLines.length > 0) {
371
+ lines.push(' $query = [');
372
+ } else {
373
+ lines.push(' $query = [');
374
+ }
205
375
  for (const q of queryLines) {
206
376
  lines.push(` ${q}`);
207
377
  }
208
- lines.push(' ], fn ($v) => $v !== null);');
378
+ // Inject constant defaults
379
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
380
+ lines.push(` '${key}' => ${phpLiteral(value)},`);
381
+ }
382
+ if (hasOptionalQuery) {
383
+ lines.push(' ], fn ($v) => $v !== null);');
384
+ } else {
385
+ lines.push(' ];');
386
+ }
387
+ // Inject fields from client config
388
+ for (const clientField of getOpInferFromClient(resolvedOp)) {
389
+ lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
390
+ }
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);`);
394
+ } else {
395
+ lines.push(` return $this->client->buildUrl(path: ${path}, query: [], options: $options);`);
396
+ }
397
+ } else if (plan.isPaginated) {
398
+ const queryLines = buildQueryArray(op);
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 = [];');
410
+ }
411
+ // Inject parameter group dispatch (instanceof checks)
412
+ lines.push(...generateGroupDispatch(op, ' '));
209
413
  }
210
414
  lines.push(' return $this->client->requestPage(');
211
415
  lines.push(` method: '${httpMethod}',`);
212
416
  lines.push(` path: ${path},`);
213
- if (queryLines.length > 0) {
417
+ if (needsQuery) {
214
418
  lines.push(' query: $query,');
215
419
  }
216
420
  const itemType = op.pagination?.itemType;
@@ -260,6 +464,10 @@ function generateMethod(
260
464
  for (const clientField of getOpInferFromClient(resolvedOp)) {
261
465
  lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
262
466
  }
467
+ // Inject parameter group dispatch into body
468
+ if ((op.parameterGroups?.length ?? 0) > 0) {
469
+ lines.push(...generateGroupDispatch(op, ' ', '$body'));
470
+ }
263
471
  }
264
472
  // Build query params if present
265
473
  const deleteQueryLines = buildQueryArray(op);
@@ -311,6 +519,11 @@ function generateMethod(
311
519
  for (const clientField of getOpInferFromClient(resolvedOp)) {
312
520
  lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
313
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
+ }
314
527
  lines.push(' $response = $this->client->request(');
315
528
  lines.push(` method: '${httpMethod}',`);
316
529
  lines.push(` path: ${path},`);
@@ -332,12 +545,18 @@ function generateMethod(
332
545
  const queryLines = buildQueryArray(op, hiddenParams);
333
546
  const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
334
547
  const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
335
- const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred;
548
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
549
+ const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred || hasGroups;
336
550
 
337
551
  if (needsQuery) {
338
- 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
+ );
339
556
  if (hasOptionalQuery) {
340
557
  lines.push(' $query = array_filter([');
558
+ } else if (queryLines.length > 0) {
559
+ lines.push(' $query = [');
341
560
  } else {
342
561
  lines.push(' $query = [');
343
562
  }
@@ -357,6 +576,8 @@ function generateMethod(
357
576
  for (const clientField of getOpInferFromClient(resolvedOp)) {
358
577
  lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
359
578
  }
579
+ // Inject parameter group dispatch (instanceof checks)
580
+ lines.push(...generateGroupDispatch(op, ' '));
360
581
  }
361
582
  lines.push(' $response = $this->client->request(');
362
583
  lines.push(` method: '${httpMethod}',`);
@@ -395,6 +616,7 @@ function buildMethodParams(
395
616
  const optional: string[] = [];
396
617
  const usedNames = new Set<string>();
397
618
  const hidden = hiddenParams ?? new Set();
619
+ const groupedParams = collectGroupedParamNames(op);
398
620
 
399
621
  // Path params (always required)
400
622
  for (const p of op.pathParams) {
@@ -429,15 +651,41 @@ function buildMethodParams(
429
651
  }
430
652
  }
431
653
 
432
- // 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)
433
670
  for (const q of op.queryParams) {
434
671
  if (hidden.has(q.name)) continue;
672
+ if (groupedParams.has(q.name)) continue;
435
673
  const phpType = mapTypeRef(q.type, { qualified: true });
436
674
  let phpName = fieldName(q.name);
437
675
  if (usedNames.has(phpName)) continue;
438
676
  usedNames.add(phpName);
439
677
  if (q.required) {
440
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
+ }
441
689
  } else {
442
690
  const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
443
691
  optional.push(`${nullableType} $${phpName} = null`);
@@ -504,12 +752,15 @@ function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
504
752
 
505
753
  function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
506
754
  const hidden = hiddenParams ?? new Set();
755
+ const groupedParams = collectGroupedParamNames(op);
507
756
  return op.queryParams
508
- .filter((q) => !hidden.has(q.name))
757
+ .filter((q) => !hidden.has(q.name) && !groupedParams.has(q.name))
509
758
  .map((q) => {
510
759
  const phpName = fieldName(q.name);
511
760
  if (isEnumType(q.type)) {
512
- 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 ? '' : '?';
513
764
  return `'${q.name}' => $${phpName}${nullsafe}->value,`;
514
765
  }
515
766
  return `'${q.name}' => $${phpName},`;
@@ -534,13 +785,18 @@ function clientFieldExpression(field: string): string {
534
785
  }
535
786
  }
536
787
 
537
- function collectImports(service: Service, ctx: EmitterContext): string[] {
788
+ function collectImports(
789
+ service: Service,
790
+ ctx: EmitterContext,
791
+ resolvedLookup?: Map<string, ResolvedOperation>,
792
+ ): string[] {
538
793
  const imports = new Set<string>();
539
794
  const ns = ctx.namespacePascal;
540
795
 
541
796
  for (const op of service.operations) {
542
797
  const plan = planOperation(op);
543
- if (plan.responseModelName && !plan.isPaginated) {
798
+ const resolved = resolvedLookup ? lookupResolved(op, resolvedLookup) : undefined;
799
+ if (plan.responseModelName && !plan.isPaginated && !isRedirectEndpoint(op, resolved)) {
544
800
  imports.add(`${ns}\\Resource\\${className(plan.responseModelName)}`);
545
801
  }
546
802
  if (op.pagination?.itemType.kind === 'model') {
package/src/php/tests.ts CHANGED
@@ -7,12 +7,20 @@ import type {
7
7
  Model,
8
8
  ResolvedOperation,
9
9
  } from '@workos/oagen';
10
- import { planOperation, toCamelCase } from '@workos/oagen';
10
+ import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
11
11
  import { className, enumClassName, resolveMethodName, snakeName, servicePropertyName } from './naming.js';
12
12
  import { isListWrapperModel } from './models.js';
13
13
  import { generateFixtures } from './fixtures.js';
14
- import { getMountTarget, groupByMount, buildHiddenParams } from '../shared/resolved-ops.js';
14
+ import {
15
+ getMountTarget,
16
+ groupByMount,
17
+ buildHiddenParams,
18
+ getOpDefaults,
19
+ getOpInferFromClient,
20
+ collectGroupedParamNames,
21
+ } from '../shared/resolved-ops.js';
15
22
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
23
+ import { isRedirectEndpoint, deriveVariantFieldName } from './resources.js';
16
24
 
17
25
  /**
18
26
  * Generate PHPUnit test files and fixture JSON files.
@@ -20,16 +28,6 @@ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
20
28
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
21
29
  const files: GeneratedFile[] = [];
22
30
 
23
- // Generate fixture JSON files
24
- const fixtures = generateFixtures(spec);
25
- for (const fixture of fixtures) {
26
- files.push({
27
- path: fixture.path,
28
- content: fixture.content,
29
- headerPlacement: 'skip',
30
- });
31
- }
32
-
33
31
  // TestHelper is now hand-maintained in the target SDK (@oagen-ignore-file).
34
32
 
35
33
  // Collect all operations per mount target using resolved per-operation mounts.
@@ -72,6 +70,16 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
72
70
  overwriteExisting: true,
73
71
  });
74
72
 
73
+ // Generate fixture JSON files
74
+ const fixtures = generateFixtures(spec);
75
+ for (const fixture of fixtures) {
76
+ files.push({
77
+ path: fixture.path,
78
+ content: fixture.content,
79
+ headerPlacement: 'skip',
80
+ });
81
+ }
82
+
75
83
  return files;
76
84
  }
77
85
 
@@ -146,6 +154,17 @@ function generateMountGroupTest(
146
154
  lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
147
155
  // Query string serialization assertions
148
156
  emitQueryAssertions(lines, op, ctx, hidden);
157
+ } else if (isRedirectEndpoint(op, resolvedOp)) {
158
+ // Redirect endpoint: URL is built locally, no HTTP request made.
159
+ // Pass all params (including optional) to verify they appear in the URL.
160
+ lines.push(' $client = $this->createMockClient([]);');
161
+ lines.push(
162
+ ` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { includeOptional: true, hidden })});`,
163
+ );
164
+ lines.push(' $this->assertIsString($result);');
165
+ lines.push(` $this->assertStringContainsString('${expectedPath}', $result);`);
166
+ // Query param assertions for the generated URL
167
+ emitRedirectQueryAssertions(lines, op, ctx, hidden, resolvedOp);
149
168
  } else if (plan.responseModelName) {
150
169
  const modelName = className(plan.responseModelName);
151
170
  const fixtureName = `${snakeName(plan.responseModelName)}`;
@@ -329,9 +348,23 @@ function buildTestArgs(
329
348
  }
330
349
  }
331
350
 
332
- // Query params
351
+ // Parameter group args (union-typed) — emit first variant constructor
352
+ const groupedParamNames = collectGroupedParamNames(op);
353
+ for (const group of op.parameterGroups ?? []) {
354
+ if (!group.optional || includeOptional) {
355
+ const variant = group.variants[0];
356
+ const variantClass = `${className(group.name)}${className(variant.name)}`;
357
+ const variantArgs = variant.parameters
358
+ .map((p) => `${deriveVariantFieldName(p.name, group.name)}: 'test_value'`)
359
+ .join(', ');
360
+ args.push(`${toCamelCase(group.name)}: new \\${ctx.namespacePascal}\\Service\\${variantClass}(${variantArgs})`);
361
+ }
362
+ }
363
+
364
+ // Query params (skip grouped params — they're handled above)
333
365
  for (const q of op.queryParams) {
334
366
  if (hidden.has(q.name)) continue;
367
+ if (groupedParamNames.has(q.name)) continue;
335
368
  if (!q.required && !includeOptional) continue;
336
369
  const phpName = toCamelCase(q.name);
337
370
  if (usedNames.has(phpName)) continue;
@@ -363,11 +396,8 @@ function generateTestValue(ref: { kind: string; type?: string; name?: string },
363
396
  const e = ctx.spec.enums.find((en) => en.name === ref.name);
364
397
  if (e && e.values.length > 0) {
365
398
  const enumClass = enumClassName(ref.name);
366
- const caseName = String(e.values[0].name)
367
- .split(/[_\s-]+/)
368
- .filter(Boolean)
369
- .map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
370
- .join('');
399
+ // Must match the case-name logic in enums.ts
400
+ const caseName = toPascalCase(String(e.values[0].name).toLowerCase());
371
401
  return `\\WorkOS\\Resource\\${enumClass}::${caseName}`;
372
402
  }
373
403
  }
@@ -375,6 +405,13 @@ function generateTestValue(ref: { kind: string; type?: string; name?: string },
375
405
  }
376
406
  case 'array':
377
407
  return '[]';
408
+ case 'map':
409
+ return '[]';
410
+ case 'nullable':
411
+ return generateTestValue(
412
+ (ref as unknown as { inner: { kind: string; type?: string; name?: string } }).inner,
413
+ ctx,
414
+ );
378
415
  case 'model': {
379
416
  if (ref.name) {
380
417
  const modelClass = className(ref.name);
@@ -471,9 +508,18 @@ function emitFieldHydrationAssertions(
471
508
  */
472
509
  function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext, hidden?: Set<string>): void {
473
510
  if (op.queryParams.length === 0) return;
511
+ const groupedParams = collectGroupedParamNames(op);
474
512
  lines.push(' parse_str($request->getUri()->getQuery(), $query);');
513
+ // Assert first variant's params from parameter groups
514
+ for (const group of op.parameterGroups ?? []) {
515
+ const variant = group.variants[0];
516
+ for (const param of variant.parameters) {
517
+ lines.push(` $this->assertSame('test_value', $query['${param.name}']);`);
518
+ }
519
+ }
475
520
  for (const q of op.queryParams) {
476
521
  if (hidden?.has(q.name)) continue;
522
+ if (groupedParams.has(q.name)) continue;
477
523
  const innerType =
478
524
  q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
479
525
  if (innerType.kind === 'enum' && innerType.name) {
@@ -499,6 +545,60 @@ function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext
499
545
  }
500
546
  }
501
547
 
548
+ /**
549
+ * Emit query param assertions for redirect endpoint URLs.
550
+ * Parses the query string from the built URL and asserts visible params,
551
+ * hidden defaults (e.g., response_type), and inferred client fields (e.g., client_id).
552
+ */
553
+ function emitRedirectQueryAssertions(
554
+ lines: string[],
555
+ op: Operation,
556
+ ctx: EmitterContext,
557
+ hidden: Set<string>,
558
+ resolvedOp?: ResolvedOperation,
559
+ ): void {
560
+ const hasVisibleQueryParams = op.queryParams.some((q) => !hidden.has(q.name));
561
+ const defaults = getOpDefaults(resolvedOp);
562
+ const inferred = getOpInferFromClient(resolvedOp);
563
+ if (!hasVisibleQueryParams && Object.keys(defaults).length === 0 && inferred.length === 0) return;
564
+
565
+ lines.push(" parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query);");
566
+
567
+ // Assert visible query params (same logic as emitQueryAssertions but reading from $query parsed from URL)
568
+ for (const q of op.queryParams) {
569
+ if (hidden.has(q.name)) continue;
570
+ const innerType =
571
+ q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
572
+ if (innerType.kind === 'enum' && innerType.name) {
573
+ const e = ctx.spec.enums.find((en) => en.name === innerType.name);
574
+ if (e && e.values.length > 0) {
575
+ lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
576
+ }
577
+ } else if (innerType.kind === 'primitive') {
578
+ switch (innerType.type) {
579
+ case 'string':
580
+ lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
581
+ break;
582
+ case 'integer':
583
+ case 'number':
584
+ case 'boolean':
585
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
586
+ break;
587
+ }
588
+ }
589
+ }
590
+
591
+ // Assert hidden defaults (e.g., response_type => 'code')
592
+ for (const [key, value] of Object.entries(defaults)) {
593
+ lines.push(` $this->assertSame('${value}', $query['${key}']);`);
594
+ }
595
+
596
+ // Assert inferred client fields are present (e.g., client_id)
597
+ for (const key of inferred) {
598
+ lines.push(` $this->assertArrayHasKey('${key}', $query);`);
599
+ }
600
+ }
601
+
502
602
  /**
503
603
  * Emit body field assertions for POST/PUT/PATCH operations.
504
604
  * Only asserts primitive required fields (strings, numbers, booleans).
@@ -15,7 +15,14 @@ export function mapTypeRef(ref: TypeRef, opts?: { qualified?: boolean }): string
15
15
  enum: (r) => `${prefix}${enumClassName(r.name)}`,
16
16
  union: (r, variants) => joinUnionVariants(r, variants),
17
17
  nullable: (_ref, inner) => `?${inner}`,
18
- literal: (r) => (typeof r.value === 'number' ? (Number.isInteger(r.value) ? 'int' : 'float') : 'string'),
18
+ literal: (r) =>
19
+ typeof r.value === 'number'
20
+ ? Number.isInteger(r.value)
21
+ ? 'int'
22
+ : 'float'
23
+ : typeof r.value === 'boolean'
24
+ ? 'bool'
25
+ : 'string',
19
26
  map: (_ref, _value) => 'array',
20
27
  });
21
28
  }
@@ -34,7 +41,14 @@ export function mapTypeRefForPHPDoc(ref: TypeRef, opts?: { prefix?: string }): s
34
41
  enum: (r) => `${prefix}${enumClassName(r.name)}`,
35
42
  union: (r, variants) => joinDocUnionVariants(r, variants),
36
43
  nullable: (_ref, inner) => `${inner}|null`,
37
- literal: (r) => (typeof r.value === 'string' ? 'string' : typeof r.value === 'number' ? 'int' : 'string'),
44
+ literal: (r) =>
45
+ typeof r.value === 'string'
46
+ ? 'string'
47
+ : typeof r.value === 'number'
48
+ ? 'int'
49
+ : typeof r.value === 'boolean'
50
+ ? 'bool'
51
+ : 'string',
38
52
  map: (_ref, value) => `array<string, ${value}>`,
39
53
  });
40
54
  }