@workos/oagen-emitters 0.4.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 (105) 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 +8 -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-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 +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +10 -34
  21. package/src/dotnet/index.ts +6 -4
  22. package/src/dotnet/models.ts +58 -82
  23. package/src/dotnet/naming.ts +44 -6
  24. package/src/dotnet/resources.ts +350 -29
  25. package/src/dotnet/tests.ts +44 -24
  26. package/src/dotnet/type-map.ts +44 -17
  27. package/src/dotnet/wrappers.ts +21 -10
  28. package/src/go/client.ts +35 -3
  29. package/src/go/enums.ts +4 -0
  30. package/src/go/index.ts +10 -5
  31. package/src/go/models.ts +6 -1
  32. package/src/go/resources.ts +534 -73
  33. package/src/go/tests.ts +39 -3
  34. package/src/go/type-map.ts +8 -3
  35. package/src/go/wrappers.ts +79 -21
  36. package/src/index.ts +14 -0
  37. package/src/kotlin/client.ts +7 -2
  38. package/src/kotlin/enums.ts +30 -3
  39. package/src/kotlin/models.ts +97 -6
  40. package/src/kotlin/naming.ts +7 -1
  41. package/src/kotlin/resources.ts +370 -39
  42. package/src/kotlin/tests.ts +120 -6
  43. package/src/node/client.ts +38 -11
  44. package/src/node/field-plan.ts +12 -14
  45. package/src/node/fixtures.ts +39 -3
  46. package/src/node/models.ts +281 -37
  47. package/src/node/resources.ts +156 -52
  48. package/src/node/tests.ts +76 -27
  49. package/src/node/type-map.ts +1 -31
  50. package/src/node/utils.ts +96 -6
  51. package/src/node/wrappers.ts +31 -1
  52. package/src/php/models.ts +0 -33
  53. package/src/php/resources.ts +199 -18
  54. package/src/php/tests.ts +26 -2
  55. package/src/php/type-map.ts +16 -2
  56. package/src/php/wrappers.ts +6 -2
  57. package/src/plugin.ts +50 -0
  58. package/src/python/client.ts +13 -3
  59. package/src/python/enums.ts +28 -3
  60. package/src/python/index.ts +35 -27
  61. package/src/python/models.ts +138 -1
  62. package/src/python/resources.ts +234 -17
  63. package/src/python/tests.ts +260 -16
  64. package/src/python/type-map.ts +16 -2
  65. package/src/ruby/client.ts +238 -0
  66. package/src/ruby/enums.ts +149 -0
  67. package/src/ruby/index.ts +93 -0
  68. package/src/ruby/manifest.ts +35 -0
  69. package/src/ruby/models.ts +360 -0
  70. package/src/ruby/naming.ts +187 -0
  71. package/src/ruby/rbi.ts +313 -0
  72. package/src/ruby/resources.ts +799 -0
  73. package/src/ruby/tests.ts +459 -0
  74. package/src/ruby/type-map.ts +97 -0
  75. package/src/ruby/wrappers.ts +161 -0
  76. package/src/shared/model-utils.ts +131 -7
  77. package/src/shared/naming-utils.ts +36 -0
  78. package/src/shared/non-spec-services.ts +13 -0
  79. package/src/shared/resolved-ops.ts +75 -1
  80. package/test/dotnet/client.test.ts +2 -2
  81. package/test/dotnet/models.test.ts +7 -9
  82. package/test/dotnet/resources.test.ts +135 -3
  83. package/test/dotnet/tests.test.ts +5 -5
  84. package/test/entrypoint.test.ts +89 -0
  85. package/test/go/client.test.ts +6 -6
  86. package/test/go/resources.test.ts +156 -7
  87. package/test/kotlin/models.test.ts +1 -1
  88. package/test/kotlin/resources.test.ts +210 -0
  89. package/test/node/models.test.ts +134 -1
  90. package/test/node/resources.test.ts +134 -26
  91. package/test/node/utils.test.ts +140 -0
  92. package/test/php/models.test.ts +5 -4
  93. package/test/php/resources.test.ts +66 -1
  94. package/test/plugin.test.ts +50 -0
  95. package/test/python/client.test.ts +56 -0
  96. package/test/python/models.test.ts +99 -0
  97. package/test/python/resources.test.ts +294 -0
  98. package/test/python/tests.test.ts +91 -0
  99. package/test/ruby/client.test.ts +81 -0
  100. package/test/ruby/resources.test.ts +386 -0
  101. package/test/shared/resolved-ops.test.ts +122 -0
  102. package/tsdown.config.ts +1 -1
  103. package/dist/index.mjs.map +0 -1
  104. package/scripts/generate-php.js +0 -13
  105. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -18,6 +18,8 @@ import {
18
18
  getOpInferFromClient,
19
19
  buildHiddenParams,
20
20
  hasHiddenParams,
21
+ collectGroupedParamNames,
22
+ collectBodyFieldTypes,
21
23
  } from '../shared/resolved-ops.js';
22
24
  import { lowerFirstForDoc, fieldDocComment } from '../shared/naming-utils.js';
23
25
  import { generateWrapperMethods } from './wrappers.js';
@@ -78,15 +80,32 @@ function generateServiceFile(mountName: string, operations: Operation[], ctx: Em
78
80
  // Determine which imports are needed
79
81
  const needsFmt = operations.some((op) => op.pathParams.length > 0);
80
82
  const needsNetUrl = operations.some((op) => {
83
+ if (op.pathParams.length > 0) return true;
81
84
  const resolved = lookupResolved(op, resolvedLookup);
82
- return resolved && hasHiddenParams(resolved) && op.httpMethod.toLowerCase() === 'get';
85
+ if (resolved?.urlBuilder) return true;
86
+ if (resolved && hasHiddenParams(resolved) && op.httpMethod.toLowerCase() === 'get') return true;
87
+ if ((op.parameterGroups?.length ?? 0) > 0) return true;
88
+ return false;
83
89
  });
84
90
  const needsStrings = needsStringsImport(operations, resolvedLookup);
91
+ const needsJson = operations.some((op) => hasBodyGroups(op));
92
+ // context is needed only for methods that make HTTP calls. URL-builder ops
93
+ // don't take ctx, so a file that contains *only* URL builders would have
94
+ // an unused import.
95
+ const needsContext = operations.some((op) => {
96
+ const resolved = lookupResolved(op, resolvedLookup);
97
+ return !resolved?.urlBuilder;
98
+ });
85
99
 
86
100
  lines.push(`package ${ctx.namespace}`);
87
101
  lines.push('');
88
102
  lines.push('import (');
89
- lines.push('\t"context"');
103
+ if (needsContext) {
104
+ lines.push('\t"context"');
105
+ }
106
+ if (needsJson) {
107
+ lines.push('\t"encoding/json"');
108
+ }
90
109
  if (needsFmt) {
91
110
  lines.push('\t"fmt"');
92
111
  }
@@ -106,6 +125,23 @@ function generateServiceFile(mountName: string, operations: Operation[], ctx: Em
106
125
  lines.push('}');
107
126
  lines.push('');
108
127
 
128
+ // Pre-collect all parameter groups across operations and emit deduplicated
129
+ // type definitions. A group with the same name may appear in both query-param
130
+ // and body-param contexts; the interface then carries both applyToQuery and
131
+ // applyToBody methods.
132
+ const groupTypes = collectFileGroups(mountName, operations);
133
+ if (groupTypes.length > 0) {
134
+ // Collect body field types from all operations so variant structs use the
135
+ // correct IR types (the parser's group-level types can fall back to string).
136
+ const mergedBodyFieldTypes = new Map<string, import('@workos/oagen').TypeRef>();
137
+ for (const op of operations) {
138
+ for (const [k, v] of collectBodyFieldTypes(op, ctx.spec.models)) {
139
+ mergedBodyFieldTypes.set(k, v);
140
+ }
141
+ }
142
+ lines.push(emitCollectedGroupTypes(mountName, groupTypes, mergedBodyFieldTypes));
143
+ }
144
+
109
145
  // Generate params structs and methods for each operation.
110
146
  // Deduplicate by method name -- multiple IR operations can resolve to the same
111
147
  // Go method name when mounted from different IR services.
@@ -118,24 +154,28 @@ function generateServiceFile(mountName: string, operations: Operation[], ctx: Em
118
154
  emittedMethods.add(method);
119
155
 
120
156
  const resolvedOp = lookupResolved(op, resolvedLookup);
157
+ const hasWrappers = (resolvedOp?.wrappers?.length ?? 0) > 0;
158
+
159
+ // When wrappers exist (union-body operations like Authenticate), only
160
+ // the typed per-variant wrapper methods are emitted. The parent method
161
+ // would otherwise carry `Body interface{}` and defeat type safety.
162
+ if (!hasWrappers) {
163
+ const paramsStruct = generateParamsStruct(mountName, method, op, plan, ctx, resolvedOp);
164
+ if (paramsStruct) {
165
+ lines.push(paramsStruct);
166
+ lines.push('');
167
+ }
121
168
 
122
- // Generate params struct if needed
123
- const paramsStruct = generateParamsStruct(mountName, method, op, plan, ctx, resolvedOp);
124
- if (paramsStruct) {
125
- lines.push(paramsStruct);
169
+ const methodCode = generateMethod(serviceType, mountName, method, op, plan, ctx, resolvedOp);
170
+ lines.push(methodCode);
126
171
  lines.push('');
127
172
  }
128
173
 
129
- // Generate method
130
- const methodCode = generateMethod(serviceType, mountName, method, op, plan, ctx, resolvedOp);
131
- lines.push(methodCode);
132
- lines.push('');
133
-
134
174
  // Generate union split wrapper methods (e.g., AuthenticateWithPassword)
135
- if (resolvedOp?.wrappers && resolvedOp.wrappers.length > 0) {
136
- const wrapperLines = generateWrapperMethods(serviceType, resolvedOp, ctx);
175
+ if (hasWrappers && resolvedOp) {
176
+ const wrapperLines = generateWrapperMethods(serviceType, mountName, resolvedOp, ctx);
137
177
  lines.push(...wrapperLines);
138
- for (const w of resolvedOp.wrappers) {
178
+ for (const w of resolvedOp.wrappers!) {
139
179
  emittedMethods.add(methodName(w.name));
140
180
  }
141
181
  }
@@ -160,6 +200,165 @@ export function paramsStructName(mountName: string, method: string): string {
160
200
  return `${prefix}${method}Params`;
161
201
  }
162
202
 
203
+ /**
204
+ * Unexported struct name used as the typed JSON body for a non-wrapper
205
+ * operation that has hidden params (defaults / inferFromClient). Mirrors
206
+ * the wrapper convention (`<methodLowerCamel>Body`).
207
+ */
208
+ function hiddenParamsBodyStructName(method: string): string {
209
+ return `${unexportedName(method)}Body`;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Mutually-exclusive parameter group support
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /** Check whether an operation has any body-level parameter groups. */
217
+ function hasBodyGroups(op: Operation): boolean {
218
+ return (op.parameterGroups ?? []).some((g) => isBodyGroup(g, op));
219
+ }
220
+
221
+ /**
222
+ * Check whether a parameter group targets the request body (rather than query params).
223
+ * Body groups' parameter names don't appear in op.queryParams — they come from
224
+ * the body model's oneOf variants.
225
+ */
226
+ function isBodyGroup(group: import('@workos/oagen').ParameterGroup, op: Operation): boolean {
227
+ const queryNames = new Set(op.queryParams.map((qp) => qp.name));
228
+ // If none of the group's variant params appear in queryParams, it's a body group
229
+ return group.variants.every((v) => v.parameters.every((p) => !queryNames.has(p.name)));
230
+ }
231
+
232
+ /** Interface type name for a parameter group (e.g. AuthorizationParentResource). */
233
+ function groupInterfaceName(mountName: string, groupName: string): string {
234
+ return `${className(mountName)}${fieldName(groupName)}`;
235
+ }
236
+
237
+ /** Variant struct type name (e.g. AuthorizationParentResourceRefByID). */
238
+ function groupVariantTypeName(mountName: string, groupName: string, variantName: string): string {
239
+ return `${groupInterfaceName(mountName, groupName)}${fieldName(variantName)}`;
240
+ }
241
+
242
+ /**
243
+ * Derive a short field name for a parameter within a variant struct.
244
+ * Strips the group name prefix when present to avoid stuttering
245
+ * (e.g. parent_resource_id in group parent_resource -> ID).
246
+ */
247
+ function deriveVariantFieldName(paramName: string, groupName: string): string {
248
+ const prefix = groupName + '_';
249
+ const stripped = paramName.startsWith(prefix) ? paramName.slice(prefix.length) : paramName;
250
+ return fieldName(stripped);
251
+ }
252
+
253
+ /** Collected group metadata, merged across all operations in a file. */
254
+ interface CollectedGroup {
255
+ name: string;
256
+ needsQuery: boolean;
257
+ needsBody: boolean;
258
+ /** Use the first variant set encountered (they should be identical). */
259
+ variants: import('@workos/oagen').ParameterGroupVariant[];
260
+ }
261
+
262
+ /**
263
+ * Pre-collect all parameter groups across operations in a mount group,
264
+ * deduplicating by group name and merging query/body usage flags.
265
+ */
266
+ function collectFileGroups(mountName: string, operations: Operation[]): CollectedGroup[] {
267
+ const byName = new Map<string, CollectedGroup>();
268
+
269
+ for (const op of operations) {
270
+ for (const group of op.parameterGroups ?? []) {
271
+ const existing = byName.get(group.name);
272
+ const isBody = isBodyGroup(group, op);
273
+ if (existing) {
274
+ if (isBody) existing.needsBody = true;
275
+ else existing.needsQuery = true;
276
+ } else {
277
+ byName.set(group.name, {
278
+ name: group.name,
279
+ needsQuery: !isBody,
280
+ needsBody: isBody,
281
+ variants: group.variants,
282
+ });
283
+ }
284
+ }
285
+ }
286
+
287
+ return [...byName.values()];
288
+ }
289
+
290
+ /**
291
+ * Emit deduplicated Go interface and variant struct definitions for all
292
+ * collected parameter groups in a file. Groups used in query contexts get
293
+ * applyToQuery; body contexts get applyToBody; groups used in both get both.
294
+ */
295
+ function emitCollectedGroupTypes(
296
+ mountName: string,
297
+ groups: CollectedGroup[],
298
+ bodyFieldTypes: Map<string, import('@workos/oagen').TypeRef>,
299
+ ): string {
300
+ const lines: string[] = [];
301
+
302
+ for (const group of groups) {
303
+ const ifaceName = groupInterfaceName(mountName, group.name);
304
+ const markerMethod = `is${ifaceName}`;
305
+
306
+ const variantNames = group.variants.map((v) => groupVariantTypeName(mountName, group.name, v.name));
307
+ lines.push(`// ${ifaceName} is one of:`);
308
+ for (const vn of variantNames) {
309
+ lines.push(`// - ${vn}`);
310
+ }
311
+ lines.push(`type ${ifaceName} interface {`);
312
+ lines.push(`\t${markerMethod}()`);
313
+ if (group.needsQuery) {
314
+ lines.push('\tapplyToQuery(url.Values)');
315
+ }
316
+ if (group.needsBody) {
317
+ lines.push('\tapplyToBody(map[string]any)');
318
+ }
319
+ lines.push('}');
320
+ lines.push('');
321
+
322
+ for (const variant of group.variants) {
323
+ const typeName = groupVariantTypeName(mountName, group.name, variant.name);
324
+
325
+ lines.push(`type ${typeName} struct {`);
326
+ for (const param of variant.parameters) {
327
+ const goField = deriveVariantFieldName(param.name, group.name);
328
+ const effectiveType = bodyFieldTypes.get(param.name) ?? param.type;
329
+ const goType = mapTypeRefValue(effectiveType);
330
+ lines.push(`\t${goField} ${goType}`);
331
+ }
332
+ lines.push('}');
333
+ lines.push('');
334
+
335
+ lines.push(`func (p ${typeName}) ${markerMethod}() {}`);
336
+
337
+ if (group.needsQuery) {
338
+ lines.push(`func (p ${typeName}) applyToQuery(v url.Values) {`);
339
+ for (const param of variant.parameters) {
340
+ const goField = deriveVariantFieldName(param.name, group.name);
341
+ lines.push(`\tv.Set("${param.name}", ${formatQueryValue(`p.${goField}`, param.type)})`);
342
+ }
343
+ lines.push('}');
344
+ }
345
+
346
+ if (group.needsBody) {
347
+ lines.push(`func (p ${typeName}) applyToBody(m map[string]any) {`);
348
+ for (const param of variant.parameters) {
349
+ const goField = deriveVariantFieldName(param.name, group.name);
350
+ lines.push(`\tm["${param.name}"] = p.${goField}`);
351
+ }
352
+ lines.push('}');
353
+ }
354
+
355
+ lines.push('');
356
+ }
357
+ }
358
+
359
+ return lines.join('\n');
360
+ }
361
+
163
362
  function generateParamsStruct(
164
363
  mountName: string,
165
364
  method: string,
@@ -170,22 +369,24 @@ function generateParamsStruct(
170
369
  ): string | null {
171
370
  // Build set of hidden param names (defaults + inferFromClient)
172
371
  const hidden = buildHiddenParams(resolvedOp);
372
+ const groupedParams = collectGroupedParamNames(op);
373
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
173
374
 
174
- const hasQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
375
+ const hasQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name) && !groupedParams.has(qp.name)).length > 0;
175
376
  const hasBody = plan.hasBody && op.requestBody;
176
377
 
177
- // Check if body has any visible fields after filtering
378
+ // Check if body has any visible fields after filtering (excluding grouped fields)
178
379
  let hasVisibleBodyFields = false;
179
380
  if (hasBody && op.requestBody?.kind === 'model') {
180
381
  const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
181
382
  if (bodyModel) {
182
- hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
383
+ hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name) && !groupedParams.has(f.name));
183
384
  }
184
385
  } else if (hasBody) {
185
386
  hasVisibleBodyFields = true; // non-model body always visible
186
387
  }
187
388
 
188
- if (!hasQueryParams && !hasVisibleBodyFields) return null;
389
+ if (!hasQueryParams && !hasVisibleBodyFields && !hasGroups) return null;
189
390
 
190
391
  const lines: string[] = [];
191
392
  const structName = paramsStructName(mountName, method);
@@ -202,6 +403,7 @@ function generateParamsStruct(
202
403
  if (bodyModel) {
203
404
  for (const field of bodyModel.fields) {
204
405
  if (hidden.has(field.name)) continue;
406
+ if (groupedParams.has(field.name)) continue;
205
407
  const goField = fieldName(field.name);
206
408
  if (emittedFields.has(goField)) continue;
207
409
  emittedFields.add(goField);
@@ -233,7 +435,7 @@ function generateParamsStruct(
233
435
  // Check if this is a list operation with standard pagination fields.
234
436
  // If so, embed PaginationParams and skip those fields individually.
235
437
  const PAGINATION_FIELDS = new Set(['before', 'after', 'limit', 'order']);
236
- const visibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name));
438
+ const visibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name) && !groupedParams.has(qp.name));
237
439
  const hasPaginationFields = ['before', 'after', 'limit'].every((name) =>
238
440
  visibleQueryParams.some((qp) => qp.name === name),
239
441
  );
@@ -241,9 +443,10 @@ function generateParamsStruct(
241
443
  lines.push('\tPaginationParams');
242
444
  }
243
445
 
244
- // Query params (skip any already emitted from body fields, hidden params, and pagination fields)
446
+ // Query params (skip any already emitted from body fields, hidden params, grouped params, and pagination fields)
245
447
  for (const param of op.queryParams) {
246
448
  if (hidden.has(param.name)) continue;
449
+ if (groupedParams.has(param.name)) continue;
247
450
  if (hasPaginationFields && PAGINATION_FIELDS.has(param.name)) continue;
248
451
  const goField = fieldName(param.name);
249
452
  if (emittedFields.has(goField)) continue;
@@ -272,7 +475,52 @@ function generateParamsStruct(
272
475
  lines.push(`\t${goField} ${goType} \`${urlTag} ${jsonTag}\``);
273
476
  }
274
477
 
478
+ // Parameter group fields (sum-type interfaces, serialized via applyToQuery)
479
+ for (const group of op.parameterGroups ?? []) {
480
+ const goField = fieldName(group.name);
481
+ const goType = groupInterfaceName(mountName, group.name);
482
+ if (group.optional) {
483
+ lines.push(`\t// ${goField} optionally identifies the ${group.name.replace(/_/g, ' ')}.`);
484
+ } else {
485
+ lines.push(`\t// ${goField} identifies the ${group.name.replace(/_/g, ' ')} (required).`);
486
+ }
487
+ lines.push(`\t${goField} ${goType} \`url:"-" json:"-"\``);
488
+ }
489
+
275
490
  lines.push('}');
491
+
492
+ // Generate MarshalJSON for params structs that have body-level groups.
493
+ // The method uses a type alias to marshal non-group fields, then merges
494
+ // the active group variant's fields into the JSON map.
495
+ const bodyGroupList = (op.parameterGroups ?? []).filter((g) => isBodyGroup(g, op));
496
+ if (bodyGroupList.length > 0) {
497
+ lines.push('');
498
+ lines.push(`// MarshalJSON implements json.Marshaler for ${structName}.`);
499
+ lines.push(`func (p ${structName}) MarshalJSON() ([]byte, error) {`);
500
+ lines.push(`\ttype Alias ${structName}`);
501
+ lines.push('\tdata, err := json.Marshal(Alias(p))');
502
+ lines.push('\tif err != nil {');
503
+ lines.push('\t\treturn nil, err');
504
+ lines.push('\t}');
505
+ // Check if any group is non-nil; if not, return early
506
+ const allNilCheck = bodyGroupList.map((g) => `p.${fieldName(g.name)} == nil`).join(' && ');
507
+ lines.push(`\tif ${allNilCheck} {`);
508
+ lines.push('\t\treturn data, nil');
509
+ lines.push('\t}');
510
+ lines.push('\tvar m map[string]any');
511
+ lines.push('\tif err := json.Unmarshal(data, &m); err != nil {');
512
+ lines.push('\t\treturn nil, err');
513
+ lines.push('\t}');
514
+ for (const group of bodyGroupList) {
515
+ const goField = fieldName(group.name);
516
+ lines.push(`\tif p.${goField} != nil {`);
517
+ lines.push(`\t\tp.${goField}.applyToBody(m)`);
518
+ lines.push('\t}');
519
+ }
520
+ lines.push('\treturn json.Marshal(m)');
521
+ lines.push('}');
522
+ }
523
+
276
524
  return lines.join('\n');
277
525
  }
278
526
 
@@ -290,31 +538,44 @@ function generateMethod(
290
538
  const isDelete = plan.isDelete;
291
539
  const hasBody = plan.hasBody && op.requestBody;
292
540
  const hidden = buildHiddenParams(resolvedOp);
541
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
293
542
  const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
294
543
 
295
- // Check if body has visible fields after filtering hidden params
544
+ // Check if body has visible fields after filtering hidden params and grouped params
545
+ const groupedParams = collectGroupedParamNames(op);
296
546
  let hasVisibleBodyFields = false;
297
547
  if (hasBody && op.requestBody?.kind === 'model') {
298
548
  const bodyModel = _ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
299
549
  if (bodyModel) {
300
- hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
550
+ hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name) && !groupedParams.has(f.name));
301
551
  }
302
552
  } else if (hasBody) {
303
553
  hasVisibleBodyFields = true;
304
554
  }
305
555
 
306
- const hasParams = hasVisibleBodyFields || hasVisibleQueryParams;
556
+ const hasParams = hasVisibleBodyFields || hasVisibleQueryParams || hasGroups;
307
557
  const paramsType = hasParams ? `*${paramsStructName(mountName, method)}` : null;
308
558
  const bodyArg = hasBody && hasParams ? bodyArgument(op) : 'nil';
309
559
  const hasHidden = hasHiddenParams(resolvedOp);
310
560
  const isGet = op.httpMethod.toLowerCase() === 'get';
561
+ const isUrlBuilder = resolvedOp?.urlBuilder ?? false;
311
562
 
312
563
  // Detect if response is a raw array (not paginated)
313
564
  const isArrayResponse = !isPaginated && op.response?.kind === 'array';
314
565
 
566
+ // Emit a typed body struct *before* the method for non-GET ops with hidden
567
+ // params (defaults / inferFromClient). The struct gives consumers a
568
+ // deterministic wire format and avoids `map[string]interface{}` literals.
569
+ if (hasHidden && !isGet && hasBody) {
570
+ emitHiddenParamsBodyStruct(lines, method, op, _ctx, resolvedOp!);
571
+ lines.push('');
572
+ }
573
+
315
574
  // Return type
316
575
  let returnType: string;
317
- if (isPaginated && op.pagination) {
576
+ if (isUrlBuilder) {
577
+ returnType = 'string';
578
+ } else if (isPaginated && op.pagination) {
318
579
  const itemType = resolveIteratorItemType(op.pagination.itemType, _ctx);
319
580
  returnType = `*Iterator[${itemType}]`;
320
581
  } else if (isDelete) {
@@ -333,7 +594,7 @@ function generateMethod(
333
594
  // Build godoc -- wrap multi-line descriptions in // comments
334
595
  if (op.description) {
335
596
  const descLines = op.description.split('\n').filter((l) => l.trim());
336
- lines.push(`// ${method} ${lowerFirstDesc(descLines[0])}`);
597
+ lines.push(`// ${godocSummary(method, descLines[0])}`);
337
598
  for (let i = 1; i < descLines.length; i++) {
338
599
  lines.push(`// ${descLines[i].trim()}`);
339
600
  }
@@ -349,8 +610,8 @@ function generateMethod(
349
610
  lines.push(`// Deprecated: this operation is deprecated.`);
350
611
  }
351
612
 
352
- // Method signature
353
- const params: string[] = ['ctx context.Context'];
613
+ // Method signature — URL builders don't take ctx (no I/O) and return a string.
614
+ const params: string[] = isUrlBuilder ? [] : ['ctx context.Context'];
354
615
  // Path params as positional args (sorted by template order)
355
616
  for (const p of sortPathParamsByTemplateOrder(op)) {
356
617
  params.push(`${lowerFirst(fieldName(p.name))} ${mapTypeRefValue(p.type)}`);
@@ -360,20 +621,23 @@ function generateMethod(
360
621
  }
361
622
  params.push('opts ...RequestOption');
362
623
 
363
- if (isPaginated) {
364
- lines.push(`func (s *${serviceType}) ${method}(${params.join(', ')}) ${returnType} {`);
365
- } else if (isDelete || !plan.responseModelName) {
366
- lines.push(`func (s *${serviceType}) ${method}(${params.join(', ')}) ${returnType} {`);
367
- } else {
368
- lines.push(`func (s *${serviceType}) ${method}(${params.join(', ')}) ${returnType} {`);
369
- }
624
+ lines.push(`func (s *${serviceType}) ${method}(${params.join(', ')}) ${returnType} {`);
370
625
 
371
626
  // Build path
372
627
  const pathExpr = buildPathExpr(op);
373
628
 
374
- // For GET operations with hidden params, build query via url.Values
375
- // so we can inject defaults + inferred values alongside user-provided params.
376
- if (hasHidden && isGet) {
629
+ // URL-builder ops construct the URL client-side and return it without
630
+ // performing any HTTP I/O.
631
+ if (isUrlBuilder) {
632
+ emitUrlBuilderMethod(lines, op, pathExpr, resolvedOp!, paramsType);
633
+ lines.push('}');
634
+ return lines.join('\n');
635
+ }
636
+
637
+ // For GET operations with hidden params or parameter groups, build query
638
+ // via url.Values so we can inject defaults, inferred values, and/or call
639
+ // applyToQuery on grouped parameter variants.
640
+ if ((hasHidden || hasGroups) && isGet) {
377
641
  emitGetWithHiddenParams(
378
642
  lines,
379
643
  op,
@@ -387,7 +651,7 @@ function generateMethod(
387
651
  isArrayResponse,
388
652
  );
389
653
  } else if (hasHidden && !isGet && hasBody) {
390
- // For non-GET operations with hidden params, build a body map
654
+ // For non-GET operations with hidden params, build a typed body struct
391
655
  emitBodyWithHiddenParams(
392
656
  lines,
393
657
  op,
@@ -399,13 +663,14 @@ function generateMethod(
399
663
  isPaginated,
400
664
  isDelete,
401
665
  isArrayResponse,
666
+ method,
402
667
  );
403
668
  } else if (isPaginated && op.pagination) {
404
669
  const itemType = resolveIteratorItemType(op.pagination.itemType, _ctx);
405
670
  const dataPath = op.pagination.dataPath ? `"${op.pagination.dataPath}"` : `"data"`;
406
671
  const cursorParam = '"after"';
407
672
  lines.push(
408
- `\treturn newIterator[${itemType}](ctx, s.client, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${hasVisibleQueryParams ? 'params' : 'nil'}, ${cursorParam}, ${dataPath}, opts)`,
673
+ `\treturn newIterator[${itemType}](ctx, s.client, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${hasVisibleQueryParams ? 'params' : 'nil'}, ${cursorParam}, ${dataPath}, opts, ${paginationDefaultsLiteral(op)})`,
409
674
  );
410
675
  } else if (isDelete) {
411
676
  lines.push(
@@ -499,9 +764,11 @@ function emitGetWithHiddenParams(
499
764
  lines.push('\t}');
500
765
  }
501
766
 
502
- // Add user-provided query params from the struct
767
+ // Add user-provided query params from the struct (excluding grouped params
768
+ // which are serialized via their variant's applyToQuery method)
503
769
  if (paramsType) {
504
- const visibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name));
770
+ const groupedParamNames = collectGroupedParamNames(op);
771
+ const visibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name) && !groupedParamNames.has(qp.name));
505
772
  for (const param of visibleQueryParams) {
506
773
  const goField = fieldName(param.name);
507
774
  const isMap = param.type.kind === 'map';
@@ -529,6 +796,14 @@ function emitGetWithHiddenParams(
529
796
  lines.push('\t}');
530
797
  }
531
798
  }
799
+
800
+ // Apply parameter group variants to the query via applyToQuery
801
+ for (const group of op.parameterGroups ?? []) {
802
+ const goField = fieldName(group.name);
803
+ lines.push(`\tif params.${goField} != nil {`);
804
+ lines.push(`\t\tparams.${goField}.applyToQuery(query)`);
805
+ lines.push('\t}');
806
+ }
532
807
  }
533
808
 
534
809
  // Make the request with query as the 4th arg
@@ -537,7 +812,7 @@ function emitGetWithHiddenParams(
537
812
  const dataPath = op.pagination.dataPath ? `"${op.pagination.dataPath}"` : `"data"`;
538
813
  const cursorParam = '"after"';
539
814
  lines.push(
540
- `\treturn newIterator[${itemType}](ctx, s.client, "GET", ${pathExpr}, query, ${cursorParam}, ${dataPath}, opts)`,
815
+ `\treturn newIterator[${itemType}](ctx, s.client, "GET", ${pathExpr}, query, ${cursorParam}, ${dataPath}, opts, ${paginationDefaultsLiteral(op)})`,
541
816
  );
542
817
  } else if (isDelete) {
543
818
  lines.push(`\t_, err := s.client.request(ctx, "GET", ${pathExpr}, query, nil, nil, opts)`);
@@ -566,62 +841,193 @@ function emitGetWithHiddenParams(
566
841
  }
567
842
 
568
843
  /**
569
- * Emit method body for non-GET operations that have hidden params (defaults/inferFromClient).
570
- * Builds a body map so we can inject hidden values alongside user-provided fields.
844
+ * Emit method body for URL-builder operations (OAuth redirect endpoints).
845
+ * Builds a url.Values from defaults + inferred + user-provided params, then
846
+ * returns `s.client.buildURL(path, query, opts)` without performing any I/O.
571
847
  */
572
- function emitBodyWithHiddenParams(
848
+ function emitUrlBuilderMethod(
573
849
  lines: string[],
574
850
  op: Operation,
575
851
  pathExpr: string,
576
- plan: OperationPlan,
577
- ctx: EmitterContext,
578
852
  resolvedOp: ResolvedOperation,
579
853
  paramsType: string | null,
580
- _isPaginated: boolean,
581
- isDelete: boolean,
582
- isArrayResponse: boolean,
583
854
  ): void {
584
855
  const hidden = buildHiddenParams(resolvedOp);
585
856
 
586
- // Build body map
587
- lines.push('\tbody := map[string]interface{}{');
857
+ lines.push('\tquery := url.Values{}');
588
858
 
589
- // Inject constant defaults
859
+ // Inject constant defaults (e.g., response_type=code)
590
860
  for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
591
- lines.push(`\t\t"${key}": ${goLiteral(value as string | number | boolean)},`);
861
+ lines.push(`\tquery.Set("${key}", ${goLiteralForQuery(value as string | number | boolean)})`);
592
862
  }
593
863
 
594
- lines.push('\t}');
595
-
596
- // Inject inferred fields from client config
864
+ // Inject inferred fields from client config (e.g., client_id)
597
865
  for (const field of getOpInferFromClient(resolvedOp)) {
598
866
  const expr = clientFieldExpression(field);
599
867
  lines.push(`\tif ${expr} != "" {`);
600
- lines.push(`\t\tbody["${field}"] = ${expr}`);
868
+ lines.push(`\t\tquery.Set("${field}", ${expr})`);
601
869
  lines.push('\t}');
602
870
  }
603
871
 
604
- // Add user-provided body fields from the struct
605
- if (paramsType && op.requestBody?.kind === 'model') {
606
- const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
607
- if (bodyModel) {
608
- for (const field of bodyModel.fields) {
609
- if (hidden.has(field.name)) continue;
610
- const goField = fieldName(field.name);
611
- if (field.required) {
612
- lines.push(`\tbody["${field.name}"] = params.${goField}`);
872
+ // Add user-provided query params from the struct
873
+ if (paramsType) {
874
+ const visibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name));
875
+ for (const param of visibleQueryParams) {
876
+ const goField = fieldName(param.name);
877
+ const isMap = param.type.kind === 'map';
878
+ if (isMap) {
879
+ if (param.required) {
880
+ lines.push(`\tfor k, v := range params.${goField} {`);
881
+ lines.push(`\t\tquery.Set(fmt.Sprintf("${param.name}[%s]", k), fmt.Sprintf("%v", v))`);
882
+ lines.push('\t}');
613
883
  } else {
614
- // Slices and maps are reference types in Go — nil-able without pointer wrapping
615
- const isRefType = field.type.kind === 'array' || field.type.kind === 'map';
616
- const valueExpr = isRefType ? `params.${goField}` : `*params.${goField}`;
617
884
  lines.push(`\tif params.${goField} != nil {`);
618
- lines.push(`\t\tbody["${field.name}"] = ${valueExpr}`);
885
+ lines.push(`\t\tfor k, v := range params.${goField} {`);
886
+ lines.push(`\t\t\tquery.Set(fmt.Sprintf("${param.name}[%s]", k), fmt.Sprintf("%v", v))`);
887
+ lines.push('\t\t}');
619
888
  lines.push('\t}');
620
889
  }
890
+ } else if (param.required) {
891
+ lines.push(`\tquery.Set("${param.name}", ${formatQueryValue(`params.${goField}`, param.type)})`);
892
+ } else {
893
+ const isRefType = param.type.kind === 'array';
894
+ const valueExpr = isRefType ? `params.${goField}` : `*params.${goField}`;
895
+ lines.push(`\tif params.${goField} != nil {`);
896
+ lines.push(`\t\tquery.Set("${param.name}", ${formatQueryValue(valueExpr, param.type)})`);
897
+ lines.push('\t}');
621
898
  }
622
899
  }
623
900
  }
624
901
 
902
+ lines.push(`\treturn s.client.buildURL(${pathExpr}, query, opts)`);
903
+ }
904
+
905
+ /**
906
+ * Emit a private typed body struct for a non-GET operation that has hidden
907
+ * params. Field order matches how `emitBodyWithHiddenParams` constructs the
908
+ * body at the call site: defaults → required exposed → inferred → optional
909
+ * exposed. Mirrors `emitWrapperBodyStruct` in `wrappers.ts`.
910
+ */
911
+ function emitHiddenParamsBodyStruct(
912
+ lines: string[],
913
+ method: string,
914
+ op: Operation,
915
+ ctx: EmitterContext,
916
+ resolvedOp: ResolvedOperation,
917
+ ): void {
918
+ const hidden = buildHiddenParams(resolvedOp);
919
+ const structName = hiddenParamsBodyStructName(method);
920
+
921
+ const bodyModel =
922
+ op.requestBody?.kind === 'model'
923
+ ? ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name)
924
+ : undefined;
925
+
926
+ lines.push(`// ${structName} is the JSON request body for ${method}.`);
927
+ lines.push(`type ${structName} struct {`);
928
+
929
+ // Constant defaults (always sent — no omitempty so the wire format is deterministic)
930
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
931
+ const goField = fieldName(key);
932
+ const goType = typeof value === 'boolean' ? 'bool' : typeof value === 'number' ? 'int' : 'string';
933
+ lines.push(`\t${goField} ${goType} \`json:"${key}"\``);
934
+ }
935
+
936
+ // Required exposed body fields
937
+ const groupedParamNames = collectGroupedParamNames(op);
938
+ if (bodyModel) {
939
+ for (const field of bodyModel.fields) {
940
+ if (hidden.has(field.name)) continue;
941
+ if (groupedParamNames.has(field.name)) continue;
942
+ if (!field.required) continue;
943
+ const goField = fieldName(field.name);
944
+ const goType = mapTypeRef(field.type);
945
+ lines.push(`\t${goField} ${goType} \`json:"${field.name}"\``);
946
+ }
947
+ }
948
+
949
+ // Inferred fields from client config (omitempty drops empty strings)
950
+ for (const inferred of getOpInferFromClient(resolvedOp)) {
951
+ const goField = fieldName(inferred);
952
+ lines.push(`\t${goField} string \`json:"${inferred},omitempty"\``);
953
+ }
954
+
955
+ // Optional exposed body fields (pointer/slice/map + omitempty)
956
+ if (bodyModel) {
957
+ for (const field of bodyModel.fields) {
958
+ if (hidden.has(field.name)) continue;
959
+ if (groupedParamNames.has(field.name)) continue;
960
+ if (field.required) continue;
961
+ const goField = fieldName(field.name);
962
+ const goType = makeOptional(mapTypeRef(field.type));
963
+ lines.push(`\t${goField} ${goType} \`json:"${field.name},omitempty"\``);
964
+ }
965
+ }
966
+
967
+ lines.push('}');
968
+ }
969
+
970
+ /**
971
+ * Emit method body for non-GET operations that have hidden params (defaults
972
+ * / inferFromClient). Builds a typed body struct (declared by
973
+ * `emitHiddenParamsBodyStruct`) so the wire format is deterministic and
974
+ * statically typed.
975
+ */
976
+ function emitBodyWithHiddenParams(
977
+ lines: string[],
978
+ op: Operation,
979
+ pathExpr: string,
980
+ plan: OperationPlan,
981
+ ctx: EmitterContext,
982
+ resolvedOp: ResolvedOperation,
983
+ paramsType: string | null,
984
+ _isPaginated: boolean,
985
+ isDelete: boolean,
986
+ isArrayResponse: boolean,
987
+ method: string,
988
+ ): void {
989
+ const hidden = buildHiddenParams(resolvedOp);
990
+ const bodyType = hiddenParamsBodyStructName(method);
991
+
992
+ const bodyModel =
993
+ op.requestBody?.kind === 'model'
994
+ ? ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name)
995
+ : undefined;
996
+
997
+ // Build typed body struct literal — defaults + required user fields first
998
+ lines.push(`\tbody := ${bodyType}{`);
999
+
1000
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
1001
+ lines.push(`\t\t${fieldName(key)}: ${goLiteral(value as string | number | boolean)},`);
1002
+ }
1003
+
1004
+ if (paramsType && bodyModel) {
1005
+ for (const field of bodyModel.fields) {
1006
+ if (hidden.has(field.name)) continue;
1007
+ if (!field.required) continue;
1008
+ const goField = fieldName(field.name);
1009
+ lines.push(`\t\t${goField}: params.${goField},`);
1010
+ }
1011
+ }
1012
+
1013
+ lines.push('\t}');
1014
+
1015
+ // Inferred fields from client config — omitempty drops empty values
1016
+ for (const inferred of getOpInferFromClient(resolvedOp)) {
1017
+ const goField = fieldName(inferred);
1018
+ lines.push(`\tbody.${goField} = ${clientFieldExpression(inferred)}`);
1019
+ }
1020
+
1021
+ // Optional exposed body fields — copy through; omitempty drops nil/empty
1022
+ if (paramsType && bodyModel) {
1023
+ for (const field of bodyModel.fields) {
1024
+ if (hidden.has(field.name)) continue;
1025
+ if (field.required) continue;
1026
+ const goField = fieldName(field.name);
1027
+ lines.push(`\tbody.${goField} = params.${goField}`);
1028
+ }
1029
+ }
1030
+
625
1031
  // Determine query arg (visible query params from struct)
626
1032
  const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
627
1033
  const queryArg = hasVisibleQueryParams ? 'params' : 'nil';
@@ -725,7 +1131,9 @@ function buildPathExpr(op: Operation): string {
725
1131
  const args: string[] = [];
726
1132
  for (const p of sortPathParamsByTemplateOrder(op)) {
727
1133
  fmtStr = fmtStr.replace(`{${p.name}}`, '%s');
728
- args.push(lowerFirst(fieldName(p.name)));
1134
+ const varName = lowerFirst(fieldName(p.name));
1135
+ const needsCast = p.type.kind !== 'primitive' || p.type.type !== 'string';
1136
+ args.push(needsCast ? `url.PathEscape(string(${varName}))` : `url.PathEscape(${varName})`);
729
1137
  }
730
1138
  return `fmt.Sprintf("${fmtStr}", ${args.join(', ')})`;
731
1139
  }
@@ -812,6 +1220,59 @@ function lowerFirstDesc(s: string): string {
812
1220
  return lowerFirstForDoc(s);
813
1221
  }
814
1222
 
1223
+ /**
1224
+ * Build the Go literal for the pagination defaults map passed as the last
1225
+ * argument to newIterator. Collects `default` values from pagination-related
1226
+ * query params (limit, order) and returns either `nil` or
1227
+ * `map[string]string{"limit": "10", "order": "desc"}`.
1228
+ */
1229
+ function paginationDefaultsLiteral(op: Operation): string {
1230
+ const PAGINATION_DEFAULTS = ['limit', 'order'];
1231
+ const entries: string[] = [];
1232
+ for (const name of PAGINATION_DEFAULTS) {
1233
+ const param = op.queryParams.find((qp) => qp.name === name);
1234
+ if (param?.default != null) {
1235
+ entries.push(`"${name}": "${param.default}"`);
1236
+ }
1237
+ }
1238
+ if (entries.length === 0) return 'nil';
1239
+ return `map[string]string{${entries.join(', ')}}`;
1240
+ }
1241
+
1242
+ /**
1243
+ * Build the godoc summary line for a method. When every PascalCase word
1244
+ * in the method name matches the leading words of the summary
1245
+ * (case-insensitive), those words are stripped to avoid stutter:
1246
+ *
1247
+ * godocSummary("Check", "Check authorization") → "Check authorization"
1248
+ * godocSummary("Delete", "Delete an API key") → "Delete an API key"
1249
+ * godocSummary("VerifyEmail", "Verify email") → "VerifyEmail"
1250
+ * godocSummary("GetJWKS", "Get JWKS") → "GetJWKS"
1251
+ *
1252
+ * When the summary words diverge from the method name, nothing is stripped:
1253
+ *
1254
+ * godocSummary("AssignRole", "Assign a role")
1255
+ * → "AssignRole assign a role"
1256
+ * godocSummary("ListOrganizationMembershipResources", "List resources for organization membership")
1257
+ * → "ListOrganizationMembershipResources list resources for organization membership"
1258
+ */
1259
+ function godocSummary(method: string, summary: string): string {
1260
+ const methodWords = method.match(/[A-Z]+(?:[a-z]+|(?=[A-Z]|$))|[A-Z]?[a-z]+|[0-9]+/g) ?? [method];
1261
+ const summaryWords = summary.split(/\s+/);
1262
+
1263
+ // Check whether all method words match the leading summary words.
1264
+ if (methodWords.length <= summaryWords.length) {
1265
+ const allMatch = methodWords.every((mw, i) => mw.toLowerCase() === summaryWords[i].toLowerCase());
1266
+ if (allMatch) {
1267
+ const rest = summaryWords.slice(methodWords.length).join(' ');
1268
+ if (rest) return `${method} ${lowerFirstDesc(rest)}`;
1269
+ return method;
1270
+ }
1271
+ }
1272
+
1273
+ return `${method} ${lowerFirstDesc(summary)}`;
1274
+ }
1275
+
815
1276
  function singularizePascal(name: string): string {
816
1277
  if (name.endsWith('ies')) {
817
1278
  return `${name.slice(0, -3)}y`;
@@ -823,5 +1284,5 @@ function singularizePascal(name: string): string {
823
1284
  }
824
1285
 
825
1286
  function serviceTypeName(name: string): string {
826
- return `${unexportedName(singularizePascal(name))}Service`;
1287
+ return `${className(singularizePascal(name))}Service`;
827
1288
  }