@workos/oagen-emitters 0.12.0 → 0.12.1

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.
@@ -4,11 +4,13 @@ import type {
4
4
  EmitterContext,
5
5
  GeneratedFile,
6
6
  Parameter,
7
+ ParameterGroup,
7
8
  ResolvedOperation,
8
9
  ResolvedWrapper,
10
+ TypeRef,
9
11
  } from '@workos/oagen';
10
12
  import { planOperation } from '@workos/oagen';
11
- import { fieldName, methodName, typeName, moduleName } from './naming.js';
13
+ import { fieldName, methodName, typeName, moduleName, variantName } from './naming.js';
12
14
  import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
13
15
  import { applySecretRedaction } from './secret.js';
14
16
  import { parsePathTemplate } from '../shared/path-template.js';
@@ -75,6 +77,10 @@ function renderMountGroup(
75
77
  lines.push('}');
76
78
  lines.push('');
77
79
 
80
+ // Parameter-group enums and synthetic body types are emitted once per
81
+ // distinct shape per file (a single mount). Collect them up front so
82
+ // duplicates collapse and per-method emit can reference stable names.
83
+ const groupEmitter = new GroupEmitter();
78
84
  const paramsStructs: string[] = [];
79
85
  const methods: string[] = [];
80
86
  const seenMethods = new Set<string>();
@@ -88,7 +94,7 @@ function renderMountGroup(
88
94
  seenMethods.add(wrapperMethodName);
89
95
  const paramsType = `${typeName(wrapper.name)}Params`;
90
96
  const params = resolveWrapperParams(wrapper, ctx);
91
- paramsStructs.push(renderWrapperParamsStruct(paramsType, op, wrapper, params, registry));
97
+ paramsStructs.push(renderWrapperParamsStruct(paramsType, op, wrapper, params, registry, ctx));
92
98
  methods.push(renderWrapperMethod(op, wrapper, params, paramsType, wrapperMethodName));
93
99
  }
94
100
  continue;
@@ -97,21 +103,40 @@ function renderMountGroup(
97
103
  const m = methodName(resolved.methodName);
98
104
  if (seenMethods.has(m)) continue;
99
105
  seenMethods.add(m);
106
+
107
+ // URL-builder ops short-circuit: no params struct (just optional one with
108
+ // the parameters as fields) but no HTTP-issuing methods.
109
+ if (resolved.urlBuilder) {
110
+ const paramsType = `${typeName(resolved.methodName)}Params`;
111
+ const emptyParams = isEmptyParams(op, resolved);
112
+ if (!emptyParams) {
113
+ paramsStructs.push(renderParamsStruct(paramsType, op, resolved, registry, ctx, groupEmitter));
114
+ }
115
+ methods.push(renderUrlBuilderMethod(op, resolved, paramsType, m, emptyParams));
116
+ continue;
117
+ }
118
+
100
119
  const paramsType = `${typeName(resolved.methodName)}Params`;
101
120
  const emptyParams = isEmptyParams(op, resolved);
102
121
  if (!emptyParams) {
103
- paramsStructs.push(renderParamsStruct(paramsType, op, resolved, registry));
122
+ paramsStructs.push(renderParamsStruct(paramsType, op, resolved, registry, ctx, groupEmitter));
104
123
  }
105
124
  methods.push(renderMethod(op, resolved, paramsType, m, emptyParams));
106
125
 
107
- // For paginated list endpoints, also emit `<method>_auto_paging` returning
108
- // `impl Stream<Item = Result<T, Error>>`. Detected by:
109
- // - response is a model with both `data: Vec<T>` and `list_metadata`
110
- // - the params struct has an `after` cursor field
126
+ // Auto-paging variant driven by `op.pagination` IR metadata. URL-builder
127
+ // and HTTP-less ops never qualify.
111
128
  const autoPaging = renderAutoPagingMethod(op, resolved, paramsType, m, ctx);
112
129
  if (autoPaging) methods.push(autoPaging);
113
130
  }
114
131
 
132
+ // Group-related type definitions go between the file header and the params
133
+ // structs so a single file's params can reference them by short name.
134
+ const groupBlock = groupEmitter.render();
135
+ if (groupBlock.length > 0) {
136
+ lines.push(groupBlock);
137
+ lines.push('');
138
+ }
139
+
115
140
  for (const s of paramsStructs) {
116
141
  lines.push(s);
117
142
  lines.push('');
@@ -127,47 +152,340 @@ function renderMountGroup(
127
152
  return lines.join('\n').replace(/\n+$/g, '\n');
128
153
  }
129
154
 
130
- function renderParamsStruct(name: string, op: Operation, resolved: ResolvedOperation, registry: UnionRegistry): string {
155
+ // ─── Parameter-group emitter ────────────────────────────────────────────────
156
+ //
157
+ // A per-file collector that records each unique enum and synthetic body type
158
+ // requested across the file's operations, deduplicates them by structural key,
159
+ // and renders them in dependency order. The emitter is otherwise stateless so
160
+ // individual `renderParamsStruct` calls just stamp their request and reference
161
+ // the resulting Rust names.
162
+
163
+ interface GroupEnumSpec {
164
+ name: string;
165
+ variants: Array<{
166
+ name: string;
167
+ fields: Array<{ rustName: string; wireName: string; rustType: string }>;
168
+ }>;
169
+ }
170
+
171
+ interface SyntheticBodySpec {
172
+ /** PascalCase type name (e.g. `CheckBody`). */
173
+ name: string;
174
+ /** Body model fields that survive after grouped-field removal. */
175
+ flatFields: Array<{
176
+ rustName: string;
177
+ wireName: string;
178
+ rustType: string;
179
+ required: boolean;
180
+ doc?: string;
181
+ }>;
182
+ /** `#[serde(flatten)]` fields, one per body parameter group. */
183
+ flattenEnums: Array<{
184
+ rustName: string;
185
+ rustType: string;
186
+ required: boolean;
187
+ doc?: string;
188
+ }>;
189
+ }
190
+
191
+ class GroupEmitter {
192
+ private enums: GroupEnumSpec[] = [];
193
+ private bodies: SyntheticBodySpec[] = [];
194
+ private seenEnums = new Set<string>();
195
+ private seenBodies = new Set<string>();
196
+
197
+ /** Register a parameter-group enum, returning the PascalCase Rust name. */
198
+ registerEnum(group: ParameterGroup): string {
199
+ const name = typeName(group.name);
200
+ const variants = group.variants.map((v) => ({
201
+ name: typeName(v.name),
202
+ fields: v.parameters.map((p) => ({
203
+ rustName: fieldName(p.name),
204
+ wireName: p.name,
205
+ // Group variant params are always treated as required within their
206
+ // variant — picking the variant is the caller's commitment to supply
207
+ // the variant's full payload.
208
+ rustType: rustTypeForGroupParam(p.type),
209
+ })),
210
+ }));
211
+ if (!this.seenEnums.has(name)) {
212
+ this.seenEnums.add(name);
213
+ this.enums.push({ name, variants });
214
+ }
215
+ return name;
216
+ }
217
+
218
+ /** Register a synthetic body struct, returning its PascalCase Rust name. */
219
+ registerBody(spec: SyntheticBodySpec): string {
220
+ if (!this.seenBodies.has(spec.name)) {
221
+ this.seenBodies.add(spec.name);
222
+ this.bodies.push(spec);
223
+ }
224
+ return spec.name;
225
+ }
226
+
227
+ render(): string {
228
+ const blocks: string[] = [];
229
+ for (const e of this.enums) {
230
+ const lines: string[] = [];
231
+ lines.push('#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]');
232
+ lines.push('#[serde(untagged)]');
233
+ lines.push(`pub enum ${e.name} {`);
234
+ for (const v of e.variants) {
235
+ if (v.fields.length === 0) {
236
+ lines.push(` ${v.name},`);
237
+ continue;
238
+ }
239
+ lines.push(` ${v.name} {`);
240
+ for (const f of v.fields) {
241
+ if (f.rustName !== f.wireName) {
242
+ lines.push(` #[serde(rename = ${JSON.stringify(f.wireName)})]`);
243
+ }
244
+ lines.push(` ${f.rustName}: ${f.rustType},`);
245
+ }
246
+ lines.push(' },');
247
+ }
248
+ lines.push('}');
249
+ blocks.push(lines.join('\n'));
250
+ }
251
+ for (const b of this.bodies) {
252
+ const lines: string[] = [];
253
+ // Bodies need `Deserialize` too: the generated test harness builds
254
+ // request stubs via `serde_json::from_str("{}")` and otherwise can't
255
+ // refer to the synthetic type without breaking the fixture pipeline.
256
+ const everythingOptional = b.flatFields.every((f) => !f.required) && b.flattenEnums.every((f) => !f.required);
257
+ const baseDerives = 'Debug, Clone, serde::Serialize, serde::Deserialize';
258
+ const derives = everythingOptional ? `${baseDerives}, Default` : baseDerives;
259
+ lines.push(`#[derive(${derives})]`);
260
+ lines.push(`pub struct ${b.name} {`);
261
+ for (const f of b.flatFields) {
262
+ if (f.doc) {
263
+ for (const c of paramDocComment(f.doc)) lines.push(` ${c}`);
264
+ }
265
+ if (f.required) {
266
+ if (f.doc) lines.push(' ///');
267
+ lines.push(' /// Required.');
268
+ }
269
+ if (!f.required) {
270
+ lines.push(' #[serde(skip_serializing_if = "Option::is_none")]');
271
+ }
272
+ if (f.rustName !== f.wireName) {
273
+ lines.push(` #[serde(rename = ${JSON.stringify(f.wireName)})]`);
274
+ }
275
+ lines.push(` pub ${f.rustName}: ${f.rustType},`);
276
+ }
277
+ for (const f of b.flattenEnums) {
278
+ if (f.doc) {
279
+ for (const c of paramDocComment(f.doc)) lines.push(` ${c}`);
280
+ }
281
+ lines.push(' #[serde(flatten)]');
282
+ if (!f.required) {
283
+ lines.push(' #[serde(skip_serializing_if = "Option::is_none")]');
284
+ }
285
+ lines.push(` pub ${f.rustName}: ${f.rustType},`);
286
+ }
287
+ lines.push('}');
288
+
289
+ // Constructor for the synthetic body type. Mirrors the params struct's
290
+ // ergonomic: required fields land as positional args, optional ones
291
+ // default via `Default::default`.
292
+ const required = [...b.flatFields.filter((f) => f.required), ...b.flattenEnums.filter((f) => f.required)];
293
+ if (required.length > 0 || b.flatFields.length + b.flattenEnums.length > 0) {
294
+ const ctorArgs = required.map((f) => `${f.rustName}: ${ctorParamType(f.rustType)}`).join(', ');
295
+ const init: string[] = [];
296
+ for (const f of b.flatFields) {
297
+ if (f.required) {
298
+ const value = ctorParamConvert(f.rustType, f.rustName);
299
+ init.push(value === f.rustName ? ` ${f.rustName},` : ` ${f.rustName}: ${value},`);
300
+ } else {
301
+ init.push(` ${f.rustName}: Default::default(),`);
302
+ }
303
+ }
304
+ for (const f of b.flattenEnums) {
305
+ if (f.required) {
306
+ const value = ctorParamConvert(f.rustType, f.rustName);
307
+ init.push(value === f.rustName ? ` ${f.rustName},` : ` ${f.rustName}: ${value},`);
308
+ } else {
309
+ init.push(` ${f.rustName}: Default::default(),`);
310
+ }
311
+ }
312
+ lines.push('');
313
+ lines.push(`impl ${b.name} {`);
314
+ lines.push(` /// Construct a new \`${b.name}\` with the required fields set.`);
315
+ lines.push(` pub fn new(${ctorArgs}) -> Self {`);
316
+ lines.push(' Self {');
317
+ for (const l of init) lines.push(l);
318
+ lines.push(' }');
319
+ lines.push(' }');
320
+ lines.push('}');
321
+ }
322
+ blocks.push(lines.join('\n'));
323
+ }
324
+ return blocks.join('\n\n');
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Render the Rust type for a parameter-group variant field. Variants commit
330
+ * the caller to supplying their full payload, so optional individual params
331
+ * still flow as `String` (not `Option<String>`); the enum-level choice is the
332
+ * one source of truth for "did the caller pick this variant or not."
333
+ */
334
+ function rustTypeForGroupParam(type: TypeRef): string {
335
+ const rust = mapTypeRef(type);
336
+ // Variant fields are non-optional regardless of the IR's per-param flag.
337
+ if (rust.startsWith('Option<')) return rust.slice('Option<'.length, -1);
338
+ return rust;
339
+ }
340
+
341
+ /** Classify each parameter group on an op as "query" or "body". */
342
+ function classifyGroup(group: ParameterGroup, op: Operation): 'query' | 'body' {
343
+ const queryNames = new Set(op.queryParams.map((qp) => qp.name));
344
+ const allInQuery = group.variants.every((v) => v.parameters.every((p) => queryNames.has(p.name)));
345
+ return allInQuery ? 'query' : 'body';
346
+ }
347
+
348
+ function renderParamsStruct(
349
+ name: string,
350
+ op: Operation,
351
+ resolved: ResolvedOperation,
352
+ registry: UnionRegistry,
353
+ ctx: EmitterContext,
354
+ groupEmitter: GroupEmitter,
355
+ ): string {
131
356
  const bodyRequired = isBodyRequired(op);
132
357
  const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
133
358
 
134
- type FieldInfo = { fname: string; rust: string; required: boolean; doc?: string };
359
+ // Names of params that belong to a parameter group; these are folded into
360
+ // the enum field and must be omitted from the flat params struct.
361
+ const queryGroupParamNames = new Set<string>();
362
+ const bodyGroupParamNames = new Set<string>();
363
+ const queryGroupFields: Array<{
364
+ name: string;
365
+ rustType: string;
366
+ required: boolean;
367
+ doc?: string;
368
+ }> = [];
369
+ const bodyGroupFields: Array<{
370
+ name: string;
371
+ rustType: string;
372
+ required: boolean;
373
+ doc?: string;
374
+ }> = [];
375
+ for (const group of op.parameterGroups ?? []) {
376
+ const enumName = groupEmitter.registerEnum(group);
377
+ const rustType = group.optional ? `Option<${enumName}>` : enumName;
378
+ const groupField = {
379
+ name: fieldName(group.name),
380
+ rustType,
381
+ required: !group.optional,
382
+ doc: undefined as string | undefined,
383
+ };
384
+ if (classifyGroup(group, op) === 'query') {
385
+ for (const v of group.variants) for (const p of v.parameters) queryGroupParamNames.add(p.name);
386
+ queryGroupFields.push(groupField);
387
+ } else {
388
+ for (const v of group.variants) for (const p of v.parameters) bodyGroupParamNames.add(p.name);
389
+ bodyGroupFields.push(groupField);
390
+ }
391
+ }
392
+
393
+ type FieldInfo = {
394
+ fname: string;
395
+ rust: string;
396
+ required: boolean;
397
+ defaultExpr: string | null;
398
+ doc?: string;
399
+ };
135
400
  const fields: FieldInfo[] = [];
136
401
  const fieldLines: string[] = [];
137
402
  const seen = new Set<string>();
138
- const emitField = (p: Parameter) => {
403
+ const emitField = (p: Parameter, opts: { isQuery?: boolean } = {}) => {
139
404
  if (hidden.has(p.name)) return;
405
+ if (queryGroupParamNames.has(p.name)) return;
140
406
  const fname = fieldName(p.name);
141
407
  if (seen.has(fname)) return;
142
408
  seen.add(fname);
143
- let rust = mapTypeRef(p.type, { hint: `${name}${typeName(p.name)}`, registry });
409
+ let rust = mapTypeRef(p.type, {
410
+ hint: `${name}${typeName(p.name)}`,
411
+ registry,
412
+ });
144
413
  if (!p.required && !rust.startsWith('Option<')) rust = makeOptional(rust);
145
414
  rust = applySecretRedaction(rust, p.name);
415
+ // Spec-level default → materialise it as a Rust expression so
416
+ // `Default::default()` and `new(…)` actually produce the documented value.
417
+ const defaultExpr = p.default != null ? rustDefaultExpr(p.default, p.type, rust.startsWith('Option<'), ctx) : null;
146
418
  // Field-level documentation derived from the spec.
147
419
  const desc = p.description?.trim();
148
420
  if (desc) {
149
421
  for (const c of paramDocComment(desc)) fieldLines.push(` ${c}`);
150
422
  }
151
- if (p.required && !rust.startsWith('Option<')) {
423
+ if (p.default != null) {
152
424
  if (desc) fieldLines.push(' ///');
425
+ fieldLines.push(` /// Defaults to \`${formatDefault(p.default)}\`.`);
426
+ }
427
+ if (p.required && !rust.startsWith('Option<')) {
428
+ if (desc || p.default != null) fieldLines.push(' ///');
153
429
  fieldLines.push(' /// Required.');
154
430
  }
155
431
  if (rust.startsWith('Option<')) {
156
432
  fieldLines.push(' #[serde(skip_serializing_if = "Option::is_none")]');
157
433
  }
434
+ // Vec query params with `style: form, explode: false` (the comma-joined
435
+ // wire format) need a custom serializer — serde alone serialises Vec as
436
+ // an array, which our query encoder unrolls into repeated keys.
437
+ if (opts.isQuery && p.explode === false && isVecType(rust)) {
438
+ fieldLines.push(
439
+ rust.startsWith('Option<')
440
+ ? ' #[serde(serialize_with = "crate::query::serialize_comma_separated_opt")]'
441
+ : ' #[serde(serialize_with = "crate::query::serialize_comma_separated")]',
442
+ );
443
+ }
158
444
  if (fname !== p.name) {
159
445
  fieldLines.push(` #[serde(rename = ${JSON.stringify(p.name)})]`);
160
446
  }
161
447
  if (p.deprecated) fieldLines.push(' #[deprecated]');
162
448
  fieldLines.push(` pub ${fname}: ${rust},`);
163
- fields.push({ fname, rust, required: !!p.required && !rust.startsWith('Option<') });
449
+ fields.push({
450
+ fname,
451
+ rust,
452
+ required: !!p.required && !rust.startsWith('Option<'),
453
+ defaultExpr,
454
+ });
164
455
  };
165
456
 
166
- for (const p of op.queryParams) emitField(p);
457
+ for (const p of op.queryParams) emitField(p, { isQuery: true });
167
458
  for (const p of op.headerParams) emitField(p);
459
+ for (const p of op.cookieParams ?? []) emitField(p);
460
+
461
+ // Query-side parameter group fields. Flattened so the encoder sees the
462
+ // variant's own fields at the params struct's top level.
463
+ for (const g of queryGroupFields) {
464
+ fieldLines.push(' #[serde(flatten)]');
465
+ if (!g.required) {
466
+ fieldLines.push(' #[serde(skip_serializing_if = "Option::is_none")]');
467
+ }
468
+ fieldLines.push(` pub ${g.name}: ${g.rustType},`);
469
+ fields.push({
470
+ fname: g.name,
471
+ rust: g.rustType,
472
+ required: g.required,
473
+ defaultExpr: null,
474
+ });
475
+ }
168
476
 
169
477
  if (op.requestBody) {
170
- let bodyType = mapTypeRef(op.requestBody, { hint: `${name}Body`, registry });
478
+ let bodyType: string;
479
+ if (bodyGroupFields.length > 0) {
480
+ // Synthesise a body struct that replaces the grouped fields with a
481
+ // flattened enum. The original body model keeps the flat optional
482
+ // fields (it's shared with other consumers); the synthetic type is
483
+ // op-local and serialises with the parameter group's variant in
484
+ // strongly-typed form.
485
+ bodyType = registerSyntheticBody(op, name, bodyGroupParamNames, bodyGroupFields, ctx, registry, groupEmitter);
486
+ } else {
487
+ bodyType = mapTypeRef(op.requestBody, { hint: `${name}Body`, registry });
488
+ }
171
489
  if (!bodyRequired && !bodyType.startsWith('Option<')) {
172
490
  bodyType = makeOptional(bodyType);
173
491
  }
@@ -176,14 +494,22 @@ function renderParamsStruct(name: string, op: Operation, resolved: ResolvedOpera
176
494
  if (bodyRequired) fieldLines.push(' /// Required.');
177
495
  fieldLines.push(' #[serde(skip)]');
178
496
  fieldLines.push(` pub body: ${bodyType},`);
179
- fields.push({ fname: 'body', rust: bodyType, required: bodyRequired });
497
+ fields.push({
498
+ fname: 'body',
499
+ rust: bodyType,
500
+ required: bodyRequired,
501
+ defaultExpr: null,
502
+ });
180
503
  }
181
504
 
182
- // Default-derive only when every field is optional (so Default can't
183
- // construct a "valid" value with empty strings for required fields).
505
+ // Default-derive only when every field is optional and no field has a
506
+ // spec-level default. Spec defaults need a manual `impl Default` since
507
+ // `Option<T>::default()` is `None`, not `Some(<spec default>)`.
184
508
  const requiredFields = fields.filter((f) => f.required);
185
509
  const allOptional = fields.length === 0 || requiredFields.length === 0;
186
- const derives = allOptional ? 'Debug, Clone, Default, Serialize' : 'Debug, Clone, Serialize';
510
+ const hasSpecDefault = fields.some((f) => f.defaultExpr !== null);
511
+ const canDeriveDefault = allOptional && !hasSpecDefault;
512
+ const derives = canDeriveDefault ? 'Debug, Clone, Default, Serialize' : 'Debug, Clone, Serialize';
187
513
 
188
514
  const out: string[] = [];
189
515
  if (fieldLines.length === 0) {
@@ -192,6 +518,22 @@ function renderParamsStruct(name: string, op: Operation, resolved: ResolvedOpera
192
518
  out.push(`#[derive(${derives})]`, `pub struct ${name} {`, ...fieldLines, '}');
193
519
  }
194
520
 
521
+ // Manual `Default` impl when every field is optional but some have
522
+ // spec-level defaults — `Default::default()` now returns those values
523
+ // instead of `None`.
524
+ if (allOptional && hasSpecDefault) {
525
+ const defaultInitLines = fields.map((f) => ` ${f.fname}: ${f.defaultExpr ?? 'Default::default()'},`);
526
+ out.push('');
527
+ out.push(`impl Default for ${name} {`);
528
+ out.push(' #[allow(deprecated)]');
529
+ out.push(' fn default() -> Self {');
530
+ out.push(' Self {');
531
+ out.push(...defaultInitLines);
532
+ out.push(' }');
533
+ out.push(' }');
534
+ out.push('}');
535
+ }
536
+
195
537
  // Generate `new(...)` constructor when there is at least one required field
196
538
  // but at least one optional field — gives callers a clear ergonomic entry
197
539
  // point without forcing them to spell out optional fields.
@@ -204,7 +546,7 @@ function renderParamsStruct(name: string, op: Operation, resolved: ResolvedOpera
204
546
  // Use field-init shorthand when the parameter and field names match.
205
547
  initLines.push(value === f.fname ? ` ${f.fname},` : ` ${f.fname}: ${value},`);
206
548
  } else {
207
- initLines.push(` ${f.fname}: Default::default(),`);
549
+ initLines.push(` ${f.fname}: ${f.defaultExpr ?? 'Default::default()'},`);
208
550
  }
209
551
  }
210
552
  out.push('');
@@ -222,6 +564,69 @@ function renderParamsStruct(name: string, op: Operation, resolved: ResolvedOpera
222
564
  return out.join('\n');
223
565
  }
224
566
 
567
+ /** True when a Rust type expression is a `Vec<…>` (or `Option<Vec<…>>`). */
568
+ function isVecType(rust: string): boolean {
569
+ const inner = rust.startsWith('Option<') ? rust.slice('Option<'.length, -1) : rust;
570
+ return inner.startsWith('Vec<');
571
+ }
572
+
573
+ /**
574
+ * Build a synthetic body struct for an op that has body-side parameter
575
+ * groups. The original body model can't be reused as-is because its grouped
576
+ * fields are still flat optionals; the synthetic type swaps them for a
577
+ * flattened enum so callers commit to one variant at construction time.
578
+ */
579
+ function registerSyntheticBody(
580
+ op: Operation,
581
+ paramsName: string,
582
+ bodyGroupParamNames: Set<string>,
583
+ bodyGroupFields: Array<{
584
+ name: string;
585
+ rustType: string;
586
+ required: boolean;
587
+ doc?: string;
588
+ }>,
589
+ ctx: EmitterContext,
590
+ registry: UnionRegistry,
591
+ groupEmitter: GroupEmitter,
592
+ ): string {
593
+ // Resolve the original body model (only model-kind bodies can have body
594
+ // groups in the spec; other shapes fall back to the model name directly).
595
+ const bodyRef = op.requestBody;
596
+ if (!bodyRef || bodyRef.kind !== 'model') {
597
+ return mapTypeRef(bodyRef!, { hint: `${paramsName}Body`, registry });
598
+ }
599
+ const model = ctx.spec.models.find((m) => m.name === bodyRef.name);
600
+ if (!model) {
601
+ return typeName(bodyRef.name);
602
+ }
603
+ const name = `${paramsName}Body`;
604
+ const flatFields = model.fields
605
+ .filter((f) => !bodyGroupParamNames.has(f.name))
606
+ .map((f) => {
607
+ let rust = mapTypeRef(f.type, {
608
+ hint: `${name}${typeName(f.name)}`,
609
+ registry,
610
+ });
611
+ if (!f.required && !rust.startsWith('Option<')) rust = makeOptional(rust);
612
+ rust = applySecretRedaction(rust, f.name);
613
+ return {
614
+ rustName: fieldName(f.name),
615
+ wireName: f.name,
616
+ rustType: rust,
617
+ required: !!f.required && !rust.startsWith('Option<'),
618
+ doc: f.description,
619
+ };
620
+ });
621
+ const flattenEnums = bodyGroupFields.map((g) => ({
622
+ rustName: g.name,
623
+ rustType: g.rustType,
624
+ required: g.required,
625
+ doc: g.doc,
626
+ }));
627
+ return groupEmitter.registerBody({ name, flatFields, flattenEnums });
628
+ }
629
+
225
630
  /** Constructor parameter type — accept `impl Into<String>` for ergonomic strings. */
226
631
  function ctorParamType(rust: string): string {
227
632
  if (rust === 'String') return 'impl Into<String>';
@@ -235,6 +640,17 @@ function ctorParamConvert(rust: string, name: string): string {
235
640
  return name;
236
641
  }
237
642
 
643
+ /**
644
+ * Detect a non-default per-operation security requirement (e.g. SSO's
645
+ * `get_profile` requires an OAuth access token rather than the WorkOS API
646
+ * key). Returns the snake_case parameter name to use for the override.
647
+ */
648
+ function bearerOverrideToken(op: Operation): string | null {
649
+ const override = op.security?.find((s) => s.schemeName !== 'bearerAuth');
650
+ if (!override) return null;
651
+ return fieldName(override.schemeName);
652
+ }
653
+
238
654
  function renderMethod(
239
655
  op: Operation,
240
656
  resolved: ResolvedOperation,
@@ -249,13 +665,19 @@ function renderMethod(
249
665
 
250
666
  const returnType = renderResponseType(op);
251
667
  const bodyRequired = isBodyRequired(op);
668
+ const tokenParam = bearerOverrideToken(op);
252
669
 
253
670
  const sig: string[] = [];
254
671
 
255
672
  // Convenience method — no per-request options. Delegates to `_with_options`.
256
673
  for (const line of methodDocLines(op)) sig.push(` ${line}`);
257
674
  if (op.deprecated) sig.push(' #[deprecated]');
258
- const argsConvenience = ['&self', ...pathArgList, ...(emptyParams ? [] : [`params: ${paramsType}`])];
675
+ const argsConvenience = [
676
+ '&self',
677
+ ...pathArgList,
678
+ ...(emptyParams ? [] : [`params: ${paramsType}`]),
679
+ ...(tokenParam ? [`${tokenParam}: impl Into<String>`] : []),
680
+ ];
259
681
  const convenienceSig = ` pub async fn ${method}(${argsConvenience.join(', ')}) -> Result<${returnType}, Error> {`;
260
682
  if (convenienceSig.length <= 100) {
261
683
  sig.push(convenienceSig);
@@ -264,7 +686,12 @@ function renderMethod(
264
686
  for (const arg of argsConvenience) sig.push(` ${arg},`);
265
687
  sig.push(` ) -> Result<${returnType}, Error> {`);
266
688
  }
267
- const delegateArgs = [...pathArgNames, ...(emptyParams ? [] : ['params']), 'None'].join(', ');
689
+ const delegateArgs = [
690
+ ...pathArgNames,
691
+ ...(emptyParams ? [] : ['params']),
692
+ ...(tokenParam ? [tokenParam] : []),
693
+ 'None',
694
+ ].join(', ');
268
695
  sig.push(` self.${method}_with_options(${delegateArgs}).await`);
269
696
  sig.push(' }');
270
697
  sig.push('');
@@ -276,6 +703,7 @@ function renderMethod(
276
703
  '&self',
277
704
  ...pathArgList,
278
705
  ...(emptyParams ? [] : [`params: ${paramsType}`]),
706
+ ...(tokenParam ? [`${tokenParam}: impl Into<String>`] : []),
279
707
  'options: Option<&crate::RequestOptions>',
280
708
  ];
281
709
  const optsSig = ` pub async fn ${method}_with_options(${argsOpts.join(', ')}) -> Result<${returnType}, Error> {`;
@@ -304,6 +732,18 @@ function renderMethod(
304
732
 
305
733
  sig.push(` let method = http::Method::${op.httpMethod.toUpperCase()};`);
306
734
 
735
+ // Bearer override: build a one-off `RequestOptions` that re-merges any
736
+ // caller-supplied options with the override Authorization header. Done
737
+ // inline so the call site can keep using the standard request helpers.
738
+ if (tokenParam) {
739
+ sig.push(` let ${tokenParam}: String = ${tokenParam}.into();`);
740
+ sig.push(` let auth = http::HeaderValue::from_str(&format!("Bearer {${tokenParam}}"))`);
741
+ sig.push(` .map_err(|e| Error::Builder(format!("invalid bearer token: {e}")))?;`);
742
+ sig.push(' let mut merged = options.cloned().unwrap_or_default();');
743
+ sig.push(' merged.extra_headers.push((http::header::AUTHORIZATION, auth));');
744
+ sig.push(' let options = Some(&merged);');
745
+ }
746
+
307
747
  // For empty-params endpoints, pass `&()` as the (empty) query — `()`
308
748
  // serialises to nothing under serde, matching the previous empty-struct
309
749
  // behaviour without surfacing the struct in the public API.
@@ -334,43 +774,148 @@ function renderMethod(
334
774
  }
335
775
 
336
776
  /**
337
- * Generate a `<method>_auto_paging` helper for paginated list endpoints.
338
- * Returns null when the operation isn't a recognised list endpoint (response
339
- * model lacks both `data: Vec<T>` and `list_metadata`, or the params struct
340
- * has no `after` cursor).
777
+ * Render a URL-builder method. URL-builder ops (e.g. `GET /sso/authorize`,
778
+ * `GET /sso/logout`) issue no HTTP request — they format a redirect URL the
779
+ * application sends the user to. Generated methods return `Result<String,
780
+ * Error>` because percent-encoding the query string can still fail.
781
+ */
782
+ function renderUrlBuilderMethod(
783
+ op: Operation,
784
+ resolved: ResolvedOperation,
785
+ paramsType: string,
786
+ method: string,
787
+ emptyParams: boolean,
788
+ ): string {
789
+ const segments = parsePathTemplate(op.path);
790
+ const pathArgList = op.pathParams.map((p) => `${methodName(p.name)}: &str`);
791
+ const pathArgNames = op.pathParams.map((p) => methodName(p.name));
792
+
793
+ const sig: string[] = [];
794
+ for (const line of methodDocLines(op)) sig.push(` ${line}`);
795
+ if (op.deprecated) sig.push(' #[deprecated]');
796
+ const args = ['&self', ...pathArgList, ...(emptyParams ? [] : [`params: ${paramsType}`])];
797
+ const headSig = ` pub fn ${method}(${args.join(', ')}) -> Result<String, Error> {`;
798
+ if (headSig.length <= 100) {
799
+ sig.push(headSig);
800
+ } else {
801
+ sig.push(` pub fn ${method}(`);
802
+ for (const arg of args) sig.push(` ${arg},`);
803
+ sig.push(' ) -> Result<String, Error> {');
804
+ }
805
+
806
+ const pathFormat = segments
807
+ .map((s) => (s.kind === 'literal' ? s.value : `{${methodName(s.name as string)}}`))
808
+ .join('');
809
+ const pathHasParams = segments.some((s) => s.kind === 'param');
810
+
811
+ if (pathHasParams) {
812
+ for (const p of op.pathParams) {
813
+ const n = methodName(p.name);
814
+ sig.push(` let ${n} = crate::client::path_segment(${n});`);
815
+ }
816
+ sig.push(` let path = format!(${JSON.stringify(pathFormat)});`);
817
+ } else {
818
+ sig.push(` let path = ${JSON.stringify(pathFormat)}.to_string();`);
819
+ }
820
+
821
+ // Bake constant defaults + inferred client fields directly into the query.
822
+ // The runtime helper handles encoding, including arrays and flatten-enum
823
+ // groups, so we just hand it whatever serde produces from `params`.
824
+ const defaults = (resolved.defaults ?? {}) as Record<string, string | number | boolean>;
825
+ const inferred = resolved.inferFromClient ?? [];
826
+ const hasDefaults = Object.keys(defaults).length > 0 || inferred.length > 0;
827
+ if (hasDefaults) {
828
+ sig.push(' let mut overlay = serde_json::Map::new();');
829
+ for (const [k, v] of Object.entries(defaults)) {
830
+ sig.push(` overlay.insert(${JSON.stringify(k)}.to_string(), serde_json::json!(${JSON.stringify(v)}));`);
831
+ }
832
+ for (const k of inferred) {
833
+ sig.push(
834
+ ` overlay.insert(${JSON.stringify(k)}.to_string(), serde_json::Value::String(${clientFieldExpression(k)}.to_string()));`,
835
+ );
836
+ }
837
+ if (!emptyParams) {
838
+ sig.push(' let params_value = serde_json::to_value(&params)');
839
+ sig.push(' .map_err(|e| Error::Builder(format!("query encode failed: {e}")))?;');
840
+ sig.push(' if let serde_json::Value::Object(map) = params_value {');
841
+ sig.push(' for (k, v) in map { overlay.insert(k, v); }');
842
+ sig.push(' }');
843
+ }
844
+ sig.push(' let merged = serde_json::Value::Object(overlay);');
845
+ sig.push(' let qs = crate::query::encode_query(&merged)?;');
846
+ } else if (!emptyParams) {
847
+ sig.push(' let qs = crate::query::encode_query(&params)?;');
848
+ } else {
849
+ sig.push(' let qs = String::new();');
850
+ }
851
+
852
+ sig.push(' let url = if qs.is_empty() {');
853
+ sig.push(' format!("{}{}", self.client.base_url(), path)');
854
+ sig.push(' } else {');
855
+ sig.push(' format!("{}{}?{}", self.client.base_url(), path, qs)');
856
+ sig.push(' };');
857
+ sig.push(' Ok(url)');
858
+ sig.push(' }');
859
+
860
+ // Unused path params would otherwise warn; keep them in scope.
861
+ void pathArgNames;
862
+ return sig.join('\n');
863
+ }
864
+
865
+ /**
866
+ * Generate a `<method>_auto_paging` helper from `op.pagination`. Returns null
867
+ * when the operation isn't paginated, when the strategy isn't `cursor`, or
868
+ * when the response model lacks the expected `data` / pagination-cursor
869
+ * fields (defensive — the IR shouldn't produce that combination today).
341
870
  */
342
871
  function renderAutoPagingMethod(
343
872
  op: Operation,
344
- _resolved: ResolvedOperation,
873
+ resolved: ResolvedOperation,
345
874
  paramsType: string,
346
875
  method: string,
347
876
  ctx: EmitterContext,
348
877
  ): string | null {
349
- if (!op.response || op.response.kind !== 'model') return null;
350
- const responseRef = op.response;
351
- const responseModel = ctx.spec.models.find((m) => m.name === responseRef.name);
878
+ if (!op.pagination) return null;
879
+ // Only cursor pagination is implemented today; offset / link-header would
880
+ // need a different stream wrapper.
881
+ if (op.pagination.strategy !== 'cursor') return null;
882
+ if (resolved.urlBuilder) return null;
883
+ if (op.response.kind !== 'model') return null;
884
+
885
+ const responseModel = ctx.spec.models.find((m) => m.name === (op.response as { name: string }).name);
352
886
  if (!responseModel) return null;
353
887
 
354
- const dataField = responseModel.fields.find((f) => f.name === 'data');
355
- const hasListMetadata = responseModel.fields.some((f) => f.name === 'list_metadata');
356
- if (!dataField || !hasListMetadata) return null;
357
- if (dataField.type.kind !== 'array') return null;
358
-
888
+ const cursorParam = op.pagination.param;
889
+ const dataPath = op.pagination.dataPath ?? 'data';
890
+ const dataField = responseModel.fields.find((f) => f.name === dataPath);
891
+ if (!dataField || dataField.type.kind !== 'array') return null;
892
+ const listMetadataField = responseModel.fields.find((f) => f.name === 'list_metadata');
893
+ if (!listMetadataField || listMetadataField.type.kind !== 'model') return null;
894
+
895
+ // The response cursor lives on the list-metadata model under the same name
896
+ // as the request param. Bail if it doesn't — that would mean a spec/IR
897
+ // mismatch and a hand-written wrapper is safer than a broken generated one.
898
+ const metadataModel = ctx.spec.models.find((m) => m.name === (listMetadataField.type as { name: string }).name);
899
+ if (!metadataModel) return null;
900
+ if (!metadataModel.fields.some((f) => f.name === cursorParam)) return null;
901
+
902
+ // The IR's `pagination.itemType` is the response wrapper model (e.g.
903
+ // `OrganizationList`), so reach into the model's `data: Vec<T>` field to
904
+ // pull out the actual element type.
359
905
  const itemType = mapTypeRef(dataField.type.items);
360
- // Require an `after` cursor in the params (query params).
361
- const hasAfter = op.queryParams.some((p) => p.name === 'after');
362
- if (!hasAfter) return null;
906
+
907
+ const cursorField = fieldName(cursorParam);
908
+ const dataAccessor = fieldName(dataPath);
363
909
 
364
910
  // Path args are taken by owned `String` so the returned stream borrows
365
- // nothing but `&self`. This keeps the lifetime story simple — only `'_`
366
- // (the `&self` lifetime) is needed on the returned `impl Stream`.
911
+ // nothing but `&self`.
367
912
  const pathArgList = op.pathParams.map((p) => `${methodName(p.name)}: impl Into<String>`);
368
913
  const pathArgNames = op.pathParams.map((p) => methodName(p.name));
369
914
 
370
915
  const sig: string[] = [];
371
916
  sig.push('');
372
917
  sig.push(` /// Returns an async [\`futures_util::Stream\`] that yields every \`${itemType}\``);
373
- sig.push(` /// across all pages, advancing the \`after\` cursor under the hood.`);
918
+ sig.push(` /// across all pages, advancing the \`${cursorParam}\` cursor under the hood.`);
374
919
  sig.push(' ///');
375
920
  sig.push(' /// ```ignore');
376
921
  sig.push(' /// use futures_util::TryStreamExt;');
@@ -391,22 +936,17 @@ function renderAutoPagingMethod(
391
936
  sig.push(` ) -> impl futures_util::Stream<Item = Result<${itemType}, Error>> + '_ {`);
392
937
  }
393
938
 
394
- // Coerce path args to owned `String` so the closure passed to the shared
395
- // pagination helper doesn't need to borrow them across `.await`s.
396
939
  for (const n of pathArgNames) {
397
940
  sig.push(` let ${n}: String = ${n}.into();`);
398
941
  }
399
- // Delegate cursor management to the shared `auto_paginate_pages` helper:
400
- // generated code only has to fetch the next page and return its
401
- // `(data, after)` pair.
402
942
  sig.push(' crate::pagination::auto_paginate_pages(move |after| {');
403
943
  for (const n of pathArgNames) sig.push(` let ${n} = ${n}.clone();`);
404
944
  sig.push(' let mut params = params.clone();');
405
- sig.push(' params.after = after;');
945
+ sig.push(` params.${cursorField} = after;`);
406
946
  sig.push(' async move {');
407
947
  const callArgs = [...pathArgNames.map((n) => `&${n}`), 'params'].join(', ');
408
948
  sig.push(` let page = self.${method}(${callArgs}).await?;`);
409
- sig.push(' Ok((page.data, page.list_metadata.after))');
949
+ sig.push(` Ok((page.${dataAccessor}, page.list_metadata.${cursorField}))`);
410
950
  sig.push(' }');
411
951
  sig.push(' })');
412
952
  sig.push(' }');
@@ -420,8 +960,14 @@ function renderWrapperParamsStruct(
420
960
  _wrapper: ResolvedWrapper,
421
961
  params: ResolvedWrapperParam[],
422
962
  registry: UnionRegistry,
963
+ ctx: EmitterContext,
423
964
  ): string {
424
- type FieldInfo = { fname: string; rust: string; required: boolean };
965
+ type FieldInfo = {
966
+ fname: string;
967
+ rust: string;
968
+ required: boolean;
969
+ defaultExpr: string | null;
970
+ };
425
971
  const fields: FieldInfo[] = [];
426
972
  const fieldLines: string[] = [];
427
973
  const seen = new Set<string>();
@@ -431,7 +977,10 @@ function renderWrapperParamsStruct(
431
977
  seen.add(fname);
432
978
  let rust: string;
433
979
  if (rp.field) {
434
- rust = mapTypeRef(rp.field.type, { hint: `${name}${typeName(rp.paramName)}`, registry });
980
+ rust = mapTypeRef(rp.field.type, {
981
+ hint: `${name}${typeName(rp.paramName)}`,
982
+ registry,
983
+ });
435
984
  } else {
436
985
  rust = 'String';
437
986
  }
@@ -441,9 +990,14 @@ function renderWrapperParamsStruct(
441
990
  if (desc) {
442
991
  for (const c of paramDocComment(desc)) fieldLines.push(` ${c}`);
443
992
  }
993
+ const fieldDefault = rp.field?.default;
994
+ if (fieldDefault != null) {
995
+ if (desc) fieldLines.push(' ///');
996
+ fieldLines.push(` /// Defaults to \`${formatDefault(fieldDefault)}\`.`);
997
+ }
444
998
  const required = !rp.isOptional && !rust.startsWith('Option<');
445
999
  if (required) {
446
- if (desc) fieldLines.push(' ///');
1000
+ if (desc || fieldDefault != null) fieldLines.push(' ///');
447
1001
  fieldLines.push(' /// Required.');
448
1002
  }
449
1003
  if (rust.startsWith('Option<')) {
@@ -453,16 +1007,21 @@ function renderWrapperParamsStruct(
453
1007
  fieldLines.push(` #[serde(rename = ${JSON.stringify(rp.paramName)})]`);
454
1008
  }
455
1009
  fieldLines.push(` pub ${fname}: ${rust},`);
456
- fields.push({ fname, rust, required });
1010
+ const defaultExpr =
1011
+ fieldDefault != null && rp.field
1012
+ ? rustDefaultExpr(fieldDefault, rp.field.type, rust.startsWith('Option<'), ctx)
1013
+ : null;
1014
+ fields.push({ fname, rust, required, defaultExpr });
457
1015
  }
458
1016
 
459
1017
  // Mirror the regular params struct: derive `Default` only when every field
460
- // is optional, otherwise emit a `new(...)` constructor for the required
461
- // ones. This prevents callers from cheaply constructing invalid request
462
- // states.
1018
+ // is optional and no field carries a spec-level default; spec defaults need
1019
+ // a manual `impl Default` (Option<T>::default() is None).
463
1020
  const requiredFields = fields.filter((f) => f.required);
464
1021
  const allOptional = fields.length === 0 || requiredFields.length === 0;
465
- const derives = allOptional ? 'Debug, Clone, Default, Serialize' : 'Debug, Clone, Serialize';
1022
+ const hasSpecDefault = fields.some((f) => f.defaultExpr !== null);
1023
+ const canDeriveDefault = allOptional && !hasSpecDefault;
1024
+ const derives = canDeriveDefault ? 'Debug, Clone, Default, Serialize' : 'Debug, Clone, Serialize';
466
1025
 
467
1026
  const out: string[] = [];
468
1027
  if (fieldLines.length === 0) {
@@ -471,6 +1030,18 @@ function renderWrapperParamsStruct(
471
1030
  out.push(`#[derive(${derives})]`, `pub struct ${name} {`, ...fieldLines, '}');
472
1031
  }
473
1032
 
1033
+ if (allOptional && hasSpecDefault) {
1034
+ const defaultInitLines = fields.map((f) => ` ${f.fname}: ${f.defaultExpr ?? 'Default::default()'},`);
1035
+ out.push('');
1036
+ out.push(`impl Default for ${name} {`);
1037
+ out.push(' fn default() -> Self {');
1038
+ out.push(' Self {');
1039
+ out.push(...defaultInitLines);
1040
+ out.push(' }');
1041
+ out.push(' }');
1042
+ out.push('}');
1043
+ }
1044
+
474
1045
  if (requiredFields.length > 0) {
475
1046
  const ctorArgs = requiredFields.map((f) => `${f.fname}: ${ctorParamType(f.rust)}`).join(', ');
476
1047
  const initLines: string[] = [];
@@ -479,7 +1050,7 @@ function renderWrapperParamsStruct(
479
1050
  const value = ctorParamConvert(f.rust, f.fname);
480
1051
  initLines.push(value === f.fname ? ` ${f.fname},` : ` ${f.fname}: ${value},`);
481
1052
  } else {
482
- initLines.push(` ${f.fname}: Default::default(),`);
1053
+ initLines.push(` ${f.fname}: ${f.defaultExpr ?? 'Default::default()'},`);
483
1054
  }
484
1055
  }
485
1056
  out.push('');
@@ -616,6 +1187,60 @@ function paramDocComment(text: string): string[] {
616
1187
  .map((l) => `/// ${l}`);
617
1188
  }
618
1189
 
1190
+ /**
1191
+ * Render a spec-level default value for inclusion in a doc comment. Strings
1192
+ * render bare (e.g. `desc`) so they nest naturally inside the surrounding
1193
+ * backticks; numbers/booleans use JSON encoding.
1194
+ */
1195
+ function formatDefault(value: unknown): string {
1196
+ if (typeof value === 'string') return value;
1197
+ return JSON.stringify(value);
1198
+ }
1199
+
1200
+ /**
1201
+ * Render a spec-level default value as a Rust expression suitable for an
1202
+ * `impl Default` body or a `new(…)` initialiser. When `isOptional` is true the
1203
+ * result is wrapped in `Some(…)` so it matches an `Option<T>` field.
1204
+ *
1205
+ * Returns `null` for types/values the emitter doesn't know how to materialise
1206
+ * — caller falls back to `Default::default()`.
1207
+ */
1208
+ function rustDefaultExpr(value: unknown, ref: TypeRef, isOptional: boolean, ctx: EmitterContext): string | null {
1209
+ if (ref.kind === 'nullable') {
1210
+ return rustDefaultExpr(value, ref.inner, isOptional, ctx);
1211
+ }
1212
+
1213
+ let expr: string | null = null;
1214
+ switch (ref.kind) {
1215
+ case 'primitive':
1216
+ if (ref.type === 'integer' && typeof value === 'number' && Number.isFinite(value)) {
1217
+ expr = String(Math.trunc(value));
1218
+ } else if (ref.type === 'number' && typeof value === 'number' && Number.isFinite(value)) {
1219
+ // Floats need a decimal point so the literal parses as `f64`, not `i32`.
1220
+ expr = Number.isInteger(value) ? `${value}.0` : String(value);
1221
+ } else if (ref.type === 'boolean' && typeof value === 'boolean') {
1222
+ expr = String(value);
1223
+ } else if (ref.type === 'string' && typeof value === 'string') {
1224
+ expr = `${JSON.stringify(value)}.to_string()`;
1225
+ }
1226
+ break;
1227
+ case 'enum': {
1228
+ if (typeof value !== 'string' && typeof value !== 'number') break;
1229
+ const enumDef = ctx.spec.enums.find((e) => e.name === ref.name);
1230
+ if (!enumDef) break;
1231
+ const ev = enumDef.values.find((v) => v.value === value);
1232
+ if (!ev) break;
1233
+ expr = `${typeName(ref.name)}::${variantName(ev.value)}`;
1234
+ break;
1235
+ }
1236
+ default:
1237
+ break;
1238
+ }
1239
+
1240
+ if (expr === null) return null;
1241
+ return isOptional ? `Some(${expr})` : expr;
1242
+ }
1243
+
619
1244
  function methodDocLines(op: Operation): string[] {
620
1245
  const lines: string[] = [];
621
1246
  if (op.description && op.description.trim().length > 0) {
@@ -659,16 +1284,26 @@ function isBodyRequired(op: Operation): boolean {
659
1284
 
660
1285
  /**
661
1286
  * `true` when the resolved operation contributes nothing to a params struct:
662
- * no request body, and every exposed query/header parameter is inferred from
663
- * the client or supplied as a default. Such methods take no `params:` arg in
664
- * the public API and skip the empty struct entirely.
1287
+ * no request body, and every exposed query/header/cookie parameter is
1288
+ * inferred from the client or supplied as a default. Such methods take no
1289
+ * `params:` arg in the public API and skip the empty struct entirely.
665
1290
  */
666
1291
  function isEmptyParams(op: Operation, resolved: ResolvedOperation): boolean {
667
1292
  if (op.requestBody) return false;
668
1293
  const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
669
- const visibleQuery = op.queryParams.filter((p) => !hidden.has(p.name));
1294
+ const grouped = new Set<string>();
1295
+ for (const g of op.parameterGroups ?? []) {
1296
+ for (const v of g.variants) for (const p of v.parameters) grouped.add(p.name);
1297
+ }
1298
+ const visibleQuery = op.queryParams.filter((p) => !hidden.has(p.name) && !grouped.has(p.name));
670
1299
  const visibleHeader = op.headerParams.filter((p) => !hidden.has(p.name));
671
- return visibleQuery.length === 0 && visibleHeader.length === 0;
1300
+ const visibleCookie = (op.cookieParams ?? []).filter((p) => !hidden.has(p.name));
1301
+ return (
1302
+ visibleQuery.length === 0 &&
1303
+ visibleHeader.length === 0 &&
1304
+ visibleCookie.length === 0 &&
1305
+ (op.parameterGroups?.length ?? 0) === 0
1306
+ );
672
1307
  }
673
1308
 
674
1309
  function renderResourcesBarrel(exports: { module: string; struct: string }[]): string {