@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
@@ -5,16 +5,20 @@ import type {
5
5
  EmitterContext,
6
6
  GeneratedFile,
7
7
  ResolvedOperation,
8
+ Model,
9
+ TypeRef,
8
10
  } from '@workos/oagen';
9
11
  import { planOperation } from '@workos/oagen';
10
12
  import { isListWrapperModel } from './models.js';
11
- import { mapTypeRef, isValueTypeRef, isEnumRef, emitJsonPropertyAttributes } from './type-map.js';
13
+ import { mapTypeRef, isValueTypeRef, isEnumRef, emitJsonPropertyAttributes, resolveModelName } from './type-map.js';
12
14
  import {
15
+ appendAsyncSuffix,
13
16
  className,
14
17
  fieldName,
15
18
  methodName,
16
19
  resolveClassName,
17
20
  resolveMethodName,
21
+ resolveMethodStem,
18
22
  serviceTypeName,
19
23
  localName,
20
24
  csLiteral,
@@ -26,6 +30,7 @@ import {
26
30
  deprecationMessage,
27
31
  escapeCsAttributeString,
28
32
  humanize,
33
+ modelClassName,
29
34
  } from './naming.js';
30
35
  import {
31
36
  buildResolvedLookup,
@@ -35,6 +40,8 @@ import {
35
40
  getOpInferFromClient,
36
41
  buildHiddenParams,
37
42
  hasHiddenParams,
43
+ collectGroupedParamNames,
44
+ collectBodyFieldTypes,
38
45
  } from '../shared/resolved-ops.js';
39
46
  import { generateWrapperMethods } from './wrappers.js';
40
47
 
@@ -82,6 +89,193 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
82
89
  return files;
83
90
  }
84
91
 
92
+ // ---------------------------------------------------------------------------
93
+ // Mutually-exclusive parameter group support
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /** Abstract base class name for a parameter group (e.g. UserManagementRole). */
97
+ function groupBaseClassName(mountName: string, groupName: string): string {
98
+ return `${className(mountName)}${className(groupName)}`;
99
+ }
100
+
101
+ /** Concrete variant class name (e.g. UserManagementRoleSingle). */
102
+ function groupVariantClassName(mountName: string, groupName: string, variantName: string): string {
103
+ return `${className(mountName)}${className(groupName)}${className(variantName)}`;
104
+ }
105
+
106
+ /**
107
+ * Generate C# abstract base class + concrete subtypes for all parameter groups
108
+ * on an operation. Each group becomes an abstract class with concrete subclasses
109
+ * for each variant containing the variant's parameters as properties.
110
+ */
111
+ function generateParameterGroupTypes(
112
+ mountName: string,
113
+ op: Operation,
114
+ models: Model[],
115
+ emitted?: Set<string>,
116
+ ): string[] {
117
+ const lines: string[] = [];
118
+ const bodyFieldTypes = collectBodyFieldTypes(op, models);
119
+
120
+ for (const group of op.parameterGroups ?? []) {
121
+ const baseName = groupBaseClassName(mountName, group.name);
122
+ if (emitted?.has(baseName)) continue;
123
+ emitted?.add(baseName);
124
+
125
+ lines.push('');
126
+ lines.push(` public abstract class ${baseName} { }`);
127
+
128
+ for (const variant of group.variants) {
129
+ const variantName = groupVariantClassName(mountName, group.name, variant.name);
130
+ lines.push('');
131
+ lines.push(` public class ${variantName} : ${baseName}`);
132
+ lines.push(' {');
133
+ for (const param of variant.parameters) {
134
+ const csField = fieldName(param.name);
135
+ const effectiveType = bodyFieldTypes.get(param.name) ?? param.type;
136
+ const csType = mapTypeRef(effectiveType);
137
+ lines.push(` public ${csType} ${csField} { get; set; } = default!;`);
138
+ lines.push('');
139
+ }
140
+ lines.push(' }');
141
+ }
142
+ }
143
+
144
+ return lines;
145
+ }
146
+
147
+ /**
148
+ * Emit manual serialization for parameter group variants in the service
149
+ * method body. Each group field on the options class is pattern-matched via
150
+ * `is` checks and its variant parameters are added to the appropriate target
151
+ * (query string or request body).
152
+ */
153
+ function emitGroupSerialization(
154
+ mountName: string,
155
+ op: Operation,
156
+ indent: string,
157
+ models: Model[],
158
+ target: 'query' | 'body',
159
+ ): string[] {
160
+ const lines: string[] = [];
161
+ const bodyFieldTypes = collectBodyFieldTypes(op, models);
162
+
163
+ for (const group of op.parameterGroups ?? []) {
164
+ const groupField = fieldName(group.name);
165
+ let first = true;
166
+
167
+ for (const variant of group.variants) {
168
+ const variantName = groupVariantClassName(mountName, group.name, variant.name);
169
+ // Use a short local variable derived from the variant name
170
+ const localVar = localName(variant.name);
171
+ const keyword = first ? 'if' : 'else if';
172
+ first = false;
173
+
174
+ lines.push(`${indent}${keyword} (options?.${groupField} is ${variantName} ${localVar})`);
175
+ lines.push(`${indent}{`);
176
+ let prevWasBlock = false;
177
+ for (const param of variant.parameters) {
178
+ const csField = fieldName(param.name);
179
+ const effectiveType = bodyFieldTypes.get(param.name) ?? param.type;
180
+ const accessor = `${localVar}.${csField}`;
181
+ const paramLines = emitParamValue(param.name, accessor, effectiveType, indent + ' ', target);
182
+ // SA1513: closing brace must be followed by a blank line before the next statement
183
+ if (prevWasBlock) lines.push('');
184
+ lines.push(...paramLines);
185
+ prevWasBlock = paramLines.length > 1;
186
+ }
187
+ lines.push(`${indent}}`);
188
+ }
189
+ }
190
+
191
+ return lines;
192
+ }
193
+
194
+ /**
195
+ * Emit one or more lines to add a param to the query string or request body,
196
+ * adapting to the parameter's IR type: enums use JsonConvert for wire-value
197
+ * serialization, arrays are comma-joined, and reference types (string, List)
198
+ * get a null guard.
199
+ *
200
+ * Reference types always get a null guard because variant classes are shared
201
+ * across operations whose body models may disagree on nullability.
202
+ */
203
+ function emitParamValue(
204
+ wireName: string,
205
+ accessor: string,
206
+ typeRef: TypeRef,
207
+ indent: string,
208
+ target: 'query' | 'body',
209
+ ): string[] {
210
+ const method = target === 'body' ? 'AddBodyParam' : 'AddQueryParam';
211
+ const isNullable = typeRef.kind === 'nullable';
212
+ const inner: TypeRef = isNullable ? (typeRef as { kind: 'nullable'; inner: TypeRef }).inner : typeRef;
213
+
214
+ // Reference types (arrays, strings, models) are always guarded for null
215
+ // because the variant class property may be nullable even when the current
216
+ // operation's body model says "required".
217
+ const needsNullGuard = isNullable || !isValueTypeRef(inner);
218
+
219
+ if (inner.kind === 'array') {
220
+ // Body params pass the list directly so it serializes as a JSON array;
221
+ // query params comma-join into a single string value.
222
+ const valueExpr = target === 'body' ? accessor : `string.Join(",", ${accessor})`;
223
+ if (needsNullGuard) {
224
+ return [
225
+ `${indent}if (${accessor} != null)`,
226
+ `${indent}{`,
227
+ `${indent} request.${method}("${wireName}", ${valueExpr});`,
228
+ `${indent}}`,
229
+ ];
230
+ }
231
+ return [`${indent}request.${method}("${wireName}", ${valueExpr});`];
232
+ }
233
+
234
+ if (inner.kind === 'enum') {
235
+ const serExpr = `JsonConvert.SerializeObject(${accessor}).Trim('"')`;
236
+ if (isNullable) {
237
+ return [
238
+ `${indent}if (${accessor} != null)`,
239
+ `${indent}{`,
240
+ `${indent} request.${method}("${wireName}", ${serExpr});`,
241
+ `${indent}}`,
242
+ ];
243
+ }
244
+ return [`${indent}request.${method}("${wireName}", ${serExpr});`];
245
+ }
246
+
247
+ if (needsNullGuard) {
248
+ return [
249
+ `${indent}if (${accessor} != null)`,
250
+ `${indent}{`,
251
+ `${indent} request.${method}("${wireName}", ${accessor});`,
252
+ `${indent}}`,
253
+ ];
254
+ }
255
+
256
+ return [`${indent}request.${method}("${wireName}", ${accessor});`];
257
+ }
258
+
259
+ /** Check whether any parameter group variant contains an enum-typed parameter. */
260
+ function groupsNeedJsonConvert(operations: Operation[], models: Model[]): boolean {
261
+ for (const op of operations) {
262
+ const bodyFieldTypes = collectBodyFieldTypes(op, models);
263
+ for (const group of op.parameterGroups ?? []) {
264
+ for (const variant of group.variants) {
265
+ for (const param of variant.parameters) {
266
+ const effectiveType = bodyFieldTypes.get(param.name) ?? param.type;
267
+ const inner: TypeRef =
268
+ effectiveType.kind === 'nullable'
269
+ ? (effectiveType as { kind: 'nullable'; inner: TypeRef }).inner
270
+ : effectiveType;
271
+ if (inner.kind === 'enum') return true;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ return false;
277
+ }
278
+
85
279
  function generateServiceFile(mountName: string, operations: Operation[], ctx: EmitterContext): GeneratedFile | null {
86
280
  const lines: string[] = [];
87
281
  const svcTypeName = serviceTypeName(mountName);
@@ -91,10 +285,14 @@ function generateServiceFile(mountName: string, operations: Operation[], ctx: Em
91
285
 
92
286
  lines.push(`namespace ${ctx.namespacePascal}`);
93
287
  lines.push('{');
288
+ lines.push(' using System;');
94
289
  lines.push(' using System.Collections.Generic;');
95
290
  lines.push(' using System.Net.Http;');
96
291
  lines.push(' using System.Threading;');
97
292
  lines.push(' using System.Threading.Tasks;');
293
+ if (groupsNeedJsonConvert(operations, ctx.spec.models)) {
294
+ lines.push(' using Newtonsoft.Json;');
295
+ }
98
296
  lines.push('');
99
297
  lines.push(
100
298
  ` /// <summary>Service that exposes the ${humanize(mountName)} API operations on <see cref="WorkOSClient"/>.</summary>`,
@@ -119,6 +317,7 @@ function generateServiceFile(mountName: string, operations: Operation[], ctx: Em
119
317
  const emittedMethods = new Set<string>();
120
318
  for (const op of operations) {
121
319
  const plan = planOperation(op);
320
+ const methodStem = resolveCsMethodStem(op, mountName, ctx);
122
321
  const method = resolveCsMethodName(op, mountName, ctx);
123
322
 
124
323
  if (emittedMethods.has(method)) continue;
@@ -132,13 +331,18 @@ function generateServiceFile(mountName: string, operations: Operation[], ctx: Em
132
331
  // get a 422 from the API. Only emit the typed AuthenticateWith* wrappers.
133
332
  if (!isUnionSplit) {
134
333
  lines.push('');
135
- const methodCode = generateMethod(svcTypeName, mountName, method, op, plan, ctx, resolvedOp);
334
+ const methodCode = generateMethod(svcTypeName, mountName, method, methodStem, op, plan, ctx, resolvedOp);
136
335
  lines.push(methodCode);
137
336
 
337
+ if (!(resolvedOp?.urlBuilder ?? false) && method !== methodStem) {
338
+ lines.push('');
339
+ lines.push(generateCompatibilityMethod(mountName, method, methodStem, op, plan, ctx, resolvedOp));
340
+ }
341
+
138
342
  // Generate auto-pagination method for paginated list operations
139
343
  if (plan.isPaginated && op.pagination) {
140
344
  lines.push('');
141
- const autoPagingCode = generateAutoPagingMethod(mountName, method, op, plan, ctx, resolvedOp);
345
+ const autoPagingCode = generateAutoPagingMethod(mountName, method, methodStem, op, plan, ctx, resolvedOp);
142
346
  lines.push(autoPagingCode);
143
347
  }
144
348
  }
@@ -148,7 +352,7 @@ function generateServiceFile(mountName: string, operations: Operation[], ctx: Em
148
352
  const wrapperLines = generateWrapperMethods(svcTypeName, resolvedOp!, ctx);
149
353
  lines.push(...wrapperLines);
150
354
  for (const w of resolvedOp!.wrappers!) {
151
- emittedMethods.add(methodName(w.name));
355
+ emittedMethods.add(appendAsyncSuffix(methodName(w.name)));
152
356
  }
153
357
  }
154
358
  }
@@ -176,6 +380,7 @@ function generateOptionsFile(mountName: string, operations: Operation[], ctx: Em
176
380
  optionsLines.push(' using STJS = System.Text.Json.Serialization;');
177
381
 
178
382
  const emittedOptions = new Set<string>();
383
+ const emittedGroupTypes = new Set<string>();
179
384
  for (const op of operations) {
180
385
  const plan = planOperation(op);
181
386
  const method = resolveCsMethodName(op, mountName, ctx);
@@ -190,7 +395,10 @@ function generateOptionsFile(mountName: string, operations: Operation[], ctx: Em
190
395
  const optionsClass = optionsClassName(mountName, method);
191
396
  if (emittedOptions.has(optionsClass)) continue;
192
397
 
193
- const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
398
+ const groupedParams = collectGroupedParamNames(op);
399
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
400
+ const hasVisibleQueryParams =
401
+ op.queryParams.filter((qp) => !hidden.has(qp.name) && !groupedParams.has(qp.name)).length > 0;
194
402
  const hasBody = plan.hasBody && op.requestBody;
195
403
  let hasVisibleBodyFields = false;
196
404
  if (hasBody && op.requestBody?.kind === 'model') {
@@ -200,7 +408,7 @@ function generateOptionsFile(mountName: string, operations: Operation[], ctx: Em
200
408
  hasVisibleBodyFields = true;
201
409
  }
202
410
 
203
- if (!hasVisibleQueryParams && !hasVisibleBodyFields) continue;
411
+ if (!hasVisibleQueryParams && !hasVisibleBodyFields && !hasGroups) continue;
204
412
 
205
413
  emittedOptions.add(optionsClass);
206
414
  hasOptions = true;
@@ -225,6 +433,7 @@ function generateOptionsFile(mountName: string, operations: Operation[], ctx: Em
225
433
  if (bodyModel) {
226
434
  for (const field of bodyModel.fields) {
227
435
  if (hidden.has(field.name)) continue;
436
+ if (groupedParams.has(field.name)) continue;
228
437
  const csField = fieldName(field.name);
229
438
  if (emittedFields.has(csField)) continue;
230
439
  emittedFields.add(csField);
@@ -263,10 +472,12 @@ function generateOptionsFile(mountName: string, operations: Operation[], ctx: Em
263
472
  }
264
473
  }
265
474
 
266
- // Query params (skip pagination fields for list options — they're in ListOptions base)
475
+ // Query params (skip pagination fields for list options — they're in ListOptions base,
476
+ // and skip grouped params which get their own abstract class hierarchy)
267
477
  const PAGINATION_FIELDS = new Set(['before', 'after', 'limit', 'order']);
268
478
  for (const param of op.queryParams) {
269
479
  if (hidden.has(param.name)) continue;
480
+ if (groupedParams.has(param.name)) continue;
270
481
  if (isPaginated && PAGINATION_FIELDS.has(param.name)) continue;
271
482
  const csField = fieldName(param.name);
272
483
  if (emittedFields.has(csField)) continue;
@@ -311,8 +522,6 @@ function generateOptionsFile(mountName: string, operations: Operation[], ctx: Em
311
522
  const csField = fieldName(key);
312
523
  if (emittedFields.has(csField)) continue;
313
524
  emittedFields.add(csField);
314
- optionsLines.push(` [JsonProperty("${key}")]`);
315
- optionsLines.push(` [STJS.JsonPropertyName("${key}")]`);
316
525
  optionsLines.push(` internal string ${csField} { get; set; } = default!;`);
317
526
  optionsLines.push('');
318
527
  }
@@ -320,13 +529,28 @@ function generateOptionsFile(mountName: string, operations: Operation[], ctx: Em
320
529
  const csField = fieldName(key);
321
530
  if (emittedFields.has(csField)) continue;
322
531
  emittedFields.add(csField);
323
- optionsLines.push(` [JsonProperty("${key}")]`);
324
- optionsLines.push(` [STJS.JsonPropertyName("${key}")]`);
325
532
  optionsLines.push(` internal string ${csField} { get; set; } = default!;`);
326
533
  optionsLines.push('');
327
534
  }
328
535
 
536
+ // Parameter group properties (serialized manually in the service method, not by JSON)
537
+ for (const group of op.parameterGroups ?? []) {
538
+ const baseName = groupBaseClassName(mountName, group.name);
539
+ const csField = fieldName(group.name);
540
+ optionsLines.push(' [JsonIgnore]');
541
+ optionsLines.push(' [STJS.JsonIgnore]');
542
+ const initializer = group.optional ? '' : ' = default!;';
543
+ const csType = group.optional ? `${baseName}?` : baseName;
544
+ optionsLines.push(` public ${csType} ${csField} { get; set; }${initializer}`);
545
+ optionsLines.push('');
546
+ }
547
+
329
548
  optionsLines.push(' }');
549
+
550
+ // Emit parameter group abstract base + concrete variant classes
551
+ if (hasGroups) {
552
+ optionsLines.push(...generateParameterGroupTypes(mountName, op, ctx.spec.models, emittedGroupTypes));
553
+ }
330
554
  }
331
555
 
332
556
  optionsLines.push('}');
@@ -344,6 +568,7 @@ function generateMethod(
344
568
  _serviceType: string,
345
569
  mountName: string,
346
570
  method: string,
571
+ methodStem: string,
347
572
  op: Operation,
348
573
  plan: OperationPlan,
349
574
  ctx: EmitterContext,
@@ -354,7 +579,10 @@ function generateMethod(
354
579
  const isDelete = plan.isDelete;
355
580
  const hasBody = plan.hasBody && op.requestBody;
356
581
  const hidden = buildHiddenParams(resolvedOp);
357
- const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
582
+ const groupedParams = collectGroupedParamNames(op);
583
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
584
+ const hasVisibleQueryParams =
585
+ op.queryParams.filter((qp) => !hidden.has(qp.name) && !groupedParams.has(qp.name)).length > 0;
358
586
 
359
587
  let hasVisibleBodyFields = false;
360
588
  if (hasBody && op.requestBody?.kind === 'model') {
@@ -364,8 +592,8 @@ function generateMethod(
364
592
  hasVisibleBodyFields = true;
365
593
  }
366
594
 
367
- const hasParams = hasVisibleBodyFields || hasVisibleQueryParams;
368
- const optionsClass = hasParams ? optionsClassName(mountName, method) : null;
595
+ const hasParams = hasVisibleBodyFields || hasVisibleQueryParams || hasGroups;
596
+ const optionsClass = hasParams ? optionsClassName(mountName, methodStem) : null;
369
597
  const hasHidden = hasHiddenParams(resolvedOp);
370
598
 
371
599
  // Per-operation Bearer token auth (e.g., SSO GetProfile uses access_token instead of API key)
@@ -388,7 +616,7 @@ function generateMethod(
388
616
  } else if (isDelete) {
389
617
  returnType = 'Task';
390
618
  } else if (plan.responseModelName) {
391
- const respType = className(plan.responseModelName);
619
+ const respType = modelClassName(resolveModelName(plan.responseModelName));
392
620
  if (!isPaginated && op.response?.kind === 'array') {
393
621
  returnType = `Task<List<${respType}>>`;
394
622
  } else {
@@ -420,7 +648,7 @@ function generateMethod(
420
648
  const itemType = resolveListItemType(op.pagination.itemType, ctx);
421
649
  lines.push(` /// <returns>A page of <see cref="${itemType}"/> results.</returns>`);
422
650
  } else if (plan.responseModelName) {
423
- const respType = className(plan.responseModelName);
651
+ const respType = modelClassName(resolveModelName(plan.responseModelName));
424
652
  lines.push(` /// <returns>The <see cref="${respType}"/> result.</returns>`);
425
653
  }
426
654
  if (op.deprecated) {
@@ -474,10 +702,11 @@ function generateMethod(
474
702
  // Build path
475
703
  const pathExpr = buildPathExpr(op);
476
704
 
477
- // URL-builders and bearer-override operations keep the inlined WorkOSRequest
478
- // form because the Service helpers don't expose BuildRequestUri or
479
- // AccessToken configuration. Everything else uses the helper one-liners.
480
- const needsInlineRequest = isUrlBuilder || (hasBearerOverride && !!bearerParamName);
705
+ // URL-builders, bearer-override operations, and operations with parameter
706
+ // groups keep the inlined WorkOSRequest form because the Service helpers
707
+ // don't expose BuildRequestUri, AccessToken configuration, or manual
708
+ // query param injection. Everything else uses the helper one-liners.
709
+ const needsInlineRequest = isUrlBuilder || (hasBearerOverride && !!bearerParamName) || hasGroups;
481
710
  const optionsArg = optionsClass ? 'options' : 'null';
482
711
 
483
712
  if (needsInlineRequest) {
@@ -496,6 +725,17 @@ function generateMethod(
496
725
  }
497
726
  lines.push(' };');
498
727
 
728
+ // Serialize parameter group variants into query params (GET/DELETE)
729
+ // or body params (POST/PUT/PATCH) so sensitive fields like passwords
730
+ // never leak into the URL. DELETE is routed to query because the
731
+ // dotnet HTTP client only sends body content for non-GET/DELETE methods.
732
+ if (hasGroups) {
733
+ const groupTarget = hasBody && !isDelete ? 'body' : 'query';
734
+ lines.push('');
735
+ lines.push(...emitGroupSerialization(mountName, op, ' ', ctx.spec.models, groupTarget));
736
+ lines.push('');
737
+ }
738
+
499
739
  if (isUrlBuilder) {
500
740
  lines.push(' return this.Client.BuildRequestUri(request).ToString();');
501
741
  } else if (returnType.startsWith('Task<')) {
@@ -526,6 +766,7 @@ function generateMethod(
526
766
  function generateAutoPagingMethod(
527
767
  mountName: string,
528
768
  method: string,
769
+ methodStem: string,
529
770
  op: Operation,
530
771
  plan: OperationPlan,
531
772
  ctx: EmitterContext,
@@ -533,7 +774,10 @@ function generateAutoPagingMethod(
533
774
  ): string {
534
775
  const lines: string[] = [];
535
776
  const hidden = buildHiddenParams(resolvedOp);
536
- const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
777
+ const groupedParams = collectGroupedParamNames(op);
778
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
779
+ const hasVisibleQueryParams =
780
+ op.queryParams.filter((qp) => !hidden.has(qp.name) && !groupedParams.has(qp.name)).length > 0;
537
781
 
538
782
  let hasVisibleBodyFields = false;
539
783
  if (plan.hasBody && op.requestBody?.kind === 'model') {
@@ -541,8 +785,8 @@ function generateAutoPagingMethod(
541
785
  if (bodyModel) hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
542
786
  }
543
787
 
544
- const hasParams = hasVisibleBodyFields || hasVisibleQueryParams;
545
- const optionsClass = hasParams ? optionsClassName(mountName, method) : null;
788
+ const hasParams = hasVisibleBodyFields || hasVisibleQueryParams || hasGroups;
789
+ const optionsClass = hasParams ? optionsClassName(mountName, methodStem) : null;
546
790
 
547
791
  const itemType = resolveListItemType(op.pagination!.itemType, ctx);
548
792
 
@@ -572,7 +816,7 @@ function generateAutoPagingMethod(
572
816
  params.push('RequestOptions? requestOptions = null');
573
817
  params.push('CancellationToken cancellationToken = default');
574
818
 
575
- lines.push(` public virtual IAsyncEnumerable<${itemType}> ${method}AutoPagingAsync(${params.join(', ')})`);
819
+ lines.push(` public virtual IAsyncEnumerable<${itemType}> ${methodStem}AutoPagingAsync(${params.join(', ')})`);
576
820
  lines.push(' {');
577
821
 
578
822
  const pathExpr = buildPathExpr(op);
@@ -589,10 +833,15 @@ function resolveCsMethodName(op: Operation, mountName: string, ctx: EmitterConte
589
833
  return resolveMethodName(op, { name: mountName, operations: [op] }, ctx);
590
834
  }
591
835
 
836
+ export function resolveCsMethodStem(op: Operation, mountName: string, ctx: EmitterContext): string {
837
+ return resolveMethodStem(op, { name: mountName, operations: [op] }, ctx);
838
+ }
839
+
592
840
  export function optionsClassName(mountName: string, method: string): string {
841
+ const methodStem = method.endsWith('Async') ? method.slice(0, -5) : method;
593
842
  const prefix = className(mountName);
594
- if (method.startsWith(prefix)) return `${method}Options`;
595
- return `${prefix}${method}Options`;
843
+ if (methodStem.startsWith(prefix)) return `${methodStem}Options`;
844
+ return `${prefix}${methodStem}Options`;
596
845
  }
597
846
 
598
847
  function buildPathExpr(op: Operation): string {
@@ -602,7 +851,7 @@ function buildPathExpr(op: Operation): string {
602
851
  // Build C# string interpolation
603
852
  let interpolated = op.path;
604
853
  for (const p of sortPathParamsByTemplateOrder(op)) {
605
- interpolated = interpolated.replace(`{${p.name}}`, `{${localName(p.name)}}`);
854
+ interpolated = interpolated.replace(`{${p.name}}`, `{Uri.EscapeDataString(${localName(p.name)})}`);
606
855
  }
607
856
  return `$"${interpolated}"`;
608
857
  }
@@ -613,10 +862,82 @@ function resolveListItemType(itemType: import('@workos/oagen').TypeRef, ctx: Emi
613
862
  if (model && isListWrapperModel(model)) {
614
863
  const dataField = model.fields.find((f) => f.name === 'data');
615
864
  if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
616
- return className(dataField.type.items.name);
865
+ return modelClassName(resolveModelName(dataField.type.items.name));
617
866
  }
618
867
  }
619
- return className(itemType.name);
868
+ return modelClassName(resolveModelName(itemType.name));
620
869
  }
621
870
  return mapTypeRef(itemType);
622
871
  }
872
+
873
+ function generateCompatibilityMethod(
874
+ mountName: string,
875
+ asyncMethod: string,
876
+ methodStem: string,
877
+ op: Operation,
878
+ plan: OperationPlan,
879
+ ctx: EmitterContext,
880
+ resolvedOp?: ResolvedOperation,
881
+ ): string {
882
+ const lines: string[] = [];
883
+ const hidden = buildHiddenParams(resolvedOp);
884
+ const groupedParams = collectGroupedParamNames(op);
885
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
886
+ const hasVisibleQueryParams =
887
+ op.queryParams.filter((qp) => !hidden.has(qp.name) && !groupedParams.has(qp.name)).length > 0;
888
+
889
+ let hasVisibleBodyFields = false;
890
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
891
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
892
+ if (bodyModel) hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
893
+ } else if (plan.hasBody && op.requestBody) {
894
+ hasVisibleBodyFields = true;
895
+ }
896
+
897
+ const hasParams = hasVisibleBodyFields || hasVisibleQueryParams || hasGroups;
898
+ const optionsClass = hasParams ? optionsClassName(mountName, methodStem) : null;
899
+
900
+ let returnType = 'Task';
901
+ if (plan.isPaginated && op.pagination) {
902
+ const itemType = resolveListItemType(op.pagination.itemType, ctx);
903
+ returnType = `Task<WorkOSList<${itemType}>>`;
904
+ } else if (plan.responseModelName) {
905
+ const respType = modelClassName(resolveModelName(plan.responseModelName));
906
+ returnType = !plan.isPaginated && op.response?.kind === 'array' ? `Task<List<${respType}>>` : `Task<${respType}>`;
907
+ }
908
+
909
+ const params: string[] = [];
910
+ const args: string[] = [];
911
+ for (const p of sortPathParamsByTemplateOrder(op)) {
912
+ const name = localName(p.name);
913
+ params.push(`string ${name}`);
914
+ args.push(name);
915
+ }
916
+
917
+ const hasBearerOverride = op.security?.some((s: any) => s.schemeName !== 'bearerAuth') ?? false;
918
+ if (hasBearerOverride) {
919
+ const bearerParamName = op.security!.find((s: any) => s.schemeName !== 'bearerAuth')!.schemeName;
920
+ const bearerLocal = localName(bearerParamName);
921
+ params.push(`string ${bearerLocal}`);
922
+ args.push(bearerLocal);
923
+ }
924
+
925
+ if (optionsClass) {
926
+ const isRequired = hasVisibleBodyFields && !plan.isPaginated;
927
+ params.push(isRequired ? `${optionsClass} options` : `${optionsClass}? options = null`);
928
+ args.push('options');
929
+ }
930
+
931
+ params.push('RequestOptions? requestOptions = null');
932
+ params.push('CancellationToken cancellationToken = default');
933
+ args.push('requestOptions');
934
+ args.push('cancellationToken');
935
+
936
+ lines.push(` /// <summary>Compatibility wrapper for <see cref="${asyncMethod}"/>.</summary>`);
937
+ lines.push(` public virtual ${returnType} ${methodStem}(${params.join(', ')})`);
938
+ lines.push(' {');
939
+ lines.push(` return this.${asyncMethod}(${args.join(', ')});`);
940
+ lines.push(' }');
941
+
942
+ return lines.join('\n');
943
+ }