@workos/oagen-emitters 0.11.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.
@@ -0,0 +1,1324 @@
1
+ import type {
2
+ Service,
3
+ Operation,
4
+ EmitterContext,
5
+ GeneratedFile,
6
+ Parameter,
7
+ ParameterGroup,
8
+ ResolvedOperation,
9
+ ResolvedWrapper,
10
+ TypeRef,
11
+ } from '@workos/oagen';
12
+ import { planOperation } from '@workos/oagen';
13
+ import { fieldName, methodName, typeName, moduleName, variantName } from './naming.js';
14
+ import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
15
+ import { applySecretRedaction } from './secret.js';
16
+ import { parsePathTemplate } from '../shared/path-template.js';
17
+ import { groupByMount, buildResolvedLookup } from '../shared/resolved-ops.js';
18
+ import { resolveWrapperParams, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
19
+
20
+ /**
21
+ * Generate one resource file per mount target under `src/resources/`, plus a
22
+ * `src/resources/mod.rs` barrel. Each file collapses every IR service that
23
+ * mounts on the same target into a single `Api` struct.
24
+ */
25
+ export function generateResources(_services: Service[], ctx: EmitterContext, registry: UnionRegistry): GeneratedFile[] {
26
+ const groups = groupByMount(ctx);
27
+ const lookup = buildResolvedLookup(ctx);
28
+ const files: GeneratedFile[] = [];
29
+ const exports: { module: string; struct: string }[] = [];
30
+
31
+ for (const [mountName, group] of groups) {
32
+ if (group.operations.length === 0) continue;
33
+ const basename = moduleName(mountName);
34
+ const struct = mountStructName(mountName);
35
+ exports.push({ module: basename, struct });
36
+ files.push({
37
+ path: `src/resources/${basename}.rs`,
38
+ content: renderMountGroup(mountName, group.resolvedOps, ctx, registry, lookup),
39
+ overwriteExisting: true,
40
+ });
41
+ }
42
+
43
+ files.push({
44
+ path: 'src/resources/mod.rs',
45
+ content: renderResourcesBarrel(exports),
46
+ overwriteExisting: true,
47
+ });
48
+
49
+ return files;
50
+ }
51
+
52
+ /** PascalCase struct name for a mount target (e.g., `UserManagementApi`). */
53
+ export function mountStructName(mountName: string): string {
54
+ const base = typeName(mountName);
55
+ return base.endsWith('Api') ? base : `${base}Api`;
56
+ }
57
+
58
+ function renderMountGroup(
59
+ mountName: string,
60
+ resolvedOps: ResolvedOperation[],
61
+ ctx: EmitterContext,
62
+ registry: UnionRegistry,
63
+ _lookup: Map<string, ResolvedOperation>,
64
+ ): string {
65
+ const struct = mountStructName(mountName);
66
+ const lines: string[] = [];
67
+ lines.push('use crate::client::Client;');
68
+ lines.push('#[allow(unused_imports)]');
69
+ lines.push('use crate::enums::*;');
70
+ lines.push('use crate::error::Error;');
71
+ lines.push('#[allow(unused_imports)]');
72
+ lines.push('use crate::models::*;');
73
+ lines.push('use serde::Serialize;');
74
+ lines.push('');
75
+ lines.push(`pub struct ${struct}<'a> {`);
76
+ lines.push(" pub(crate) client: &'a Client,");
77
+ lines.push('}');
78
+ lines.push('');
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();
84
+ const paramsStructs: string[] = [];
85
+ const methods: string[] = [];
86
+ const seenMethods = new Set<string>();
87
+
88
+ for (const resolved of resolvedOps) {
89
+ const op = resolved.operation;
90
+ if ((resolved.wrappers?.length ?? 0) > 0) {
91
+ for (const wrapper of resolved.wrappers!) {
92
+ const wrapperMethodName = methodName(wrapper.name);
93
+ if (seenMethods.has(wrapperMethodName)) continue;
94
+ seenMethods.add(wrapperMethodName);
95
+ const paramsType = `${typeName(wrapper.name)}Params`;
96
+ const params = resolveWrapperParams(wrapper, ctx);
97
+ paramsStructs.push(renderWrapperParamsStruct(paramsType, op, wrapper, params, registry, ctx));
98
+ methods.push(renderWrapperMethod(op, wrapper, params, paramsType, wrapperMethodName));
99
+ }
100
+ continue;
101
+ }
102
+
103
+ const m = methodName(resolved.methodName);
104
+ if (seenMethods.has(m)) continue;
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
+
119
+ const paramsType = `${typeName(resolved.methodName)}Params`;
120
+ const emptyParams = isEmptyParams(op, resolved);
121
+ if (!emptyParams) {
122
+ paramsStructs.push(renderParamsStruct(paramsType, op, resolved, registry, ctx, groupEmitter));
123
+ }
124
+ methods.push(renderMethod(op, resolved, paramsType, m, emptyParams));
125
+
126
+ // Auto-paging variant driven by `op.pagination` IR metadata. URL-builder
127
+ // and HTTP-less ops never qualify.
128
+ const autoPaging = renderAutoPagingMethod(op, resolved, paramsType, m, ctx);
129
+ if (autoPaging) methods.push(autoPaging);
130
+ }
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
+
140
+ for (const s of paramsStructs) {
141
+ lines.push(s);
142
+ lines.push('');
143
+ }
144
+
145
+ lines.push(`impl<'a> ${struct}<'a> {`);
146
+ methods.forEach((mm, i) => {
147
+ lines.push(mm);
148
+ if (i < methods.length - 1) lines.push('');
149
+ });
150
+ lines.push('}');
151
+
152
+ return lines.join('\n').replace(/\n+$/g, '\n');
153
+ }
154
+
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 {
356
+ const bodyRequired = isBodyRequired(op);
357
+ const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
358
+
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
+ };
400
+ const fields: FieldInfo[] = [];
401
+ const fieldLines: string[] = [];
402
+ const seen = new Set<string>();
403
+ const emitField = (p: Parameter, opts: { isQuery?: boolean } = {}) => {
404
+ if (hidden.has(p.name)) return;
405
+ if (queryGroupParamNames.has(p.name)) return;
406
+ const fname = fieldName(p.name);
407
+ if (seen.has(fname)) return;
408
+ seen.add(fname);
409
+ let rust = mapTypeRef(p.type, {
410
+ hint: `${name}${typeName(p.name)}`,
411
+ registry,
412
+ });
413
+ if (!p.required && !rust.startsWith('Option<')) rust = makeOptional(rust);
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;
418
+ // Field-level documentation derived from the spec.
419
+ const desc = p.description?.trim();
420
+ if (desc) {
421
+ for (const c of paramDocComment(desc)) fieldLines.push(` ${c}`);
422
+ }
423
+ if (p.default != null) {
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(' ///');
429
+ fieldLines.push(' /// Required.');
430
+ }
431
+ if (rust.startsWith('Option<')) {
432
+ fieldLines.push(' #[serde(skip_serializing_if = "Option::is_none")]');
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
+ }
444
+ if (fname !== p.name) {
445
+ fieldLines.push(` #[serde(rename = ${JSON.stringify(p.name)})]`);
446
+ }
447
+ if (p.deprecated) fieldLines.push(' #[deprecated]');
448
+ fieldLines.push(` pub ${fname}: ${rust},`);
449
+ fields.push({
450
+ fname,
451
+ rust,
452
+ required: !!p.required && !rust.startsWith('Option<'),
453
+ defaultExpr,
454
+ });
455
+ };
456
+
457
+ for (const p of op.queryParams) emitField(p, { isQuery: true });
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
+ }
476
+
477
+ if (op.requestBody) {
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
+ }
489
+ if (!bodyRequired && !bodyType.startsWith('Option<')) {
490
+ bodyType = makeOptional(bodyType);
491
+ }
492
+ fieldLines.push(' /// Request body sent with this call.');
493
+ if (bodyRequired) fieldLines.push(' ///');
494
+ if (bodyRequired) fieldLines.push(' /// Required.');
495
+ fieldLines.push(' #[serde(skip)]');
496
+ fieldLines.push(` pub body: ${bodyType},`);
497
+ fields.push({
498
+ fname: 'body',
499
+ rust: bodyType,
500
+ required: bodyRequired,
501
+ defaultExpr: null,
502
+ });
503
+ }
504
+
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>)`.
508
+ const requiredFields = fields.filter((f) => f.required);
509
+ const allOptional = fields.length === 0 || requiredFields.length === 0;
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';
513
+
514
+ const out: string[] = [];
515
+ if (fieldLines.length === 0) {
516
+ out.push(`#[derive(${derives})]`, `pub struct ${name} {}`);
517
+ } else {
518
+ out.push(`#[derive(${derives})]`, `pub struct ${name} {`, ...fieldLines, '}');
519
+ }
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
+
537
+ // Generate `new(...)` constructor when there is at least one required field
538
+ // but at least one optional field — gives callers a clear ergonomic entry
539
+ // point without forcing them to spell out optional fields.
540
+ if (requiredFields.length > 0) {
541
+ const ctorArgs = requiredFields.map((f) => `${f.fname}: ${ctorParamType(f.rust)}`).join(', ');
542
+ const initLines: string[] = [];
543
+ for (const f of fields) {
544
+ if (f.required) {
545
+ const value = ctorParamConvert(f.rust, f.fname);
546
+ // Use field-init shorthand when the parameter and field names match.
547
+ initLines.push(value === f.fname ? ` ${f.fname},` : ` ${f.fname}: ${value},`);
548
+ } else {
549
+ initLines.push(` ${f.fname}: ${f.defaultExpr ?? 'Default::default()'},`);
550
+ }
551
+ }
552
+ out.push('');
553
+ out.push(`impl ${name} {`);
554
+ out.push(` /// Construct a new \`${name}\` with the required fields set.`);
555
+ out.push(' #[allow(deprecated)]');
556
+ out.push(` pub fn new(${ctorArgs}) -> Self {`);
557
+ out.push(' Self {');
558
+ out.push(...initLines);
559
+ out.push(' }');
560
+ out.push(' }');
561
+ out.push('}');
562
+ }
563
+
564
+ return out.join('\n');
565
+ }
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
+
630
+ /** Constructor parameter type — accept `impl Into<String>` for ergonomic strings. */
631
+ function ctorParamType(rust: string): string {
632
+ if (rust === 'String') return 'impl Into<String>';
633
+ if (rust === 'crate::SecretString') return 'impl Into<crate::SecretString>';
634
+ return rust;
635
+ }
636
+
637
+ function ctorParamConvert(rust: string, name: string): string {
638
+ if (rust === 'String') return `${name}.into()`;
639
+ if (rust === 'crate::SecretString') return `${name}.into()`;
640
+ return name;
641
+ }
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
+
654
+ function renderMethod(
655
+ op: Operation,
656
+ resolved: ResolvedOperation,
657
+ paramsType: string,
658
+ method: string,
659
+ emptyParams: boolean,
660
+ ): string {
661
+ const plan = planOperation(op);
662
+ const segments = parsePathTemplate(op.path);
663
+ const pathArgList = op.pathParams.map((p) => `${methodName(p.name)}: &str`);
664
+ const pathArgNames = op.pathParams.map((p) => methodName(p.name));
665
+
666
+ const returnType = renderResponseType(op);
667
+ const bodyRequired = isBodyRequired(op);
668
+ const tokenParam = bearerOverrideToken(op);
669
+
670
+ const sig: string[] = [];
671
+
672
+ // Convenience method — no per-request options. Delegates to `_with_options`.
673
+ for (const line of methodDocLines(op)) sig.push(` ${line}`);
674
+ if (op.deprecated) sig.push(' #[deprecated]');
675
+ const argsConvenience = [
676
+ '&self',
677
+ ...pathArgList,
678
+ ...(emptyParams ? [] : [`params: ${paramsType}`]),
679
+ ...(tokenParam ? [`${tokenParam}: impl Into<String>`] : []),
680
+ ];
681
+ const convenienceSig = ` pub async fn ${method}(${argsConvenience.join(', ')}) -> Result<${returnType}, Error> {`;
682
+ if (convenienceSig.length <= 100) {
683
+ sig.push(convenienceSig);
684
+ } else {
685
+ sig.push(` pub async fn ${method}(`);
686
+ for (const arg of argsConvenience) sig.push(` ${arg},`);
687
+ sig.push(` ) -> Result<${returnType}, Error> {`);
688
+ }
689
+ const delegateArgs = [
690
+ ...pathArgNames,
691
+ ...(emptyParams ? [] : ['params']),
692
+ ...(tokenParam ? [tokenParam] : []),
693
+ 'None',
694
+ ].join(', ');
695
+ sig.push(` self.${method}_with_options(${delegateArgs}).await`);
696
+ sig.push(' }');
697
+ sig.push('');
698
+
699
+ // `_with_options` variant — per-request idempotency keys, custom headers, etc.
700
+ sig.push(` /// Variant of [\`Self::${method}\`] that accepts per-request [\`crate::RequestOptions\`].`);
701
+ if (op.deprecated) sig.push(' #[deprecated]');
702
+ const argsOpts = [
703
+ '&self',
704
+ ...pathArgList,
705
+ ...(emptyParams ? [] : [`params: ${paramsType}`]),
706
+ ...(tokenParam ? [`${tokenParam}: impl Into<String>`] : []),
707
+ 'options: Option<&crate::RequestOptions>',
708
+ ];
709
+ const optsSig = ` pub async fn ${method}_with_options(${argsOpts.join(', ')}) -> Result<${returnType}, Error> {`;
710
+ if (optsSig.length <= 100) {
711
+ sig.push(optsSig);
712
+ } else {
713
+ sig.push(` pub async fn ${method}_with_options(`);
714
+ for (const arg of argsOpts) sig.push(` ${arg},`);
715
+ sig.push(` ) -> Result<${returnType}, Error> {`);
716
+ }
717
+
718
+ const pathFormat = segments
719
+ .map((s) => (s.kind === 'literal' ? s.value : `{${methodName(s.name as string)}}`))
720
+ .join('');
721
+ const pathHasParams = segments.some((s) => s.kind === 'param');
722
+
723
+ if (pathHasParams) {
724
+ for (const p of op.pathParams) {
725
+ const n = methodName(p.name);
726
+ sig.push(` let ${n} = crate::client::path_segment(${n});`);
727
+ }
728
+ sig.push(` let path = format!(${JSON.stringify(pathFormat)});`);
729
+ } else {
730
+ sig.push(` let path = ${JSON.stringify(pathFormat)}.to_string();`);
731
+ }
732
+
733
+ sig.push(` let method = http::Method::${op.httpMethod.toUpperCase()};`);
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
+
747
+ // For empty-params endpoints, pass `&()` as the (empty) query — `()`
748
+ // serialises to nothing under serde, matching the previous empty-struct
749
+ // behaviour without surfacing the struct in the public API.
750
+ const queryRef = emptyParams ? '&()' : '&params';
751
+ const emptyResp = isEmptyResponse(op);
752
+ const bodyMethod = emptyResp ? 'request_with_body_opts_empty' : 'request_with_body_opts';
753
+ const queryMethod = emptyResp ? 'request_with_query_opts_empty' : 'request_with_query_opts';
754
+
755
+ if (op.requestBody) {
756
+ sig.push(' self.client');
757
+ if (bodyRequired) {
758
+ sig.push(` .${bodyMethod}(method, &path, ${queryRef}, Some(&params.body), options)`);
759
+ } else {
760
+ sig.push(` .${bodyMethod}(method, &path, ${queryRef}, params.body.as_ref(), options)`);
761
+ }
762
+ sig.push(' .await');
763
+ } else {
764
+ sig.push(' self.client');
765
+ sig.push(` .${queryMethod}(method, &path, ${queryRef}, options)`);
766
+ sig.push(' .await');
767
+ }
768
+
769
+ sig.push(' }');
770
+
771
+ void plan;
772
+ void resolved;
773
+ return sig.join('\n');
774
+ }
775
+
776
+ /**
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).
870
+ */
871
+ function renderAutoPagingMethod(
872
+ op: Operation,
873
+ resolved: ResolvedOperation,
874
+ paramsType: string,
875
+ method: string,
876
+ ctx: EmitterContext,
877
+ ): string | null {
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);
886
+ if (!responseModel) return null;
887
+
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.
905
+ const itemType = mapTypeRef(dataField.type.items);
906
+
907
+ const cursorField = fieldName(cursorParam);
908
+ const dataAccessor = fieldName(dataPath);
909
+
910
+ // Path args are taken by owned `String` so the returned stream borrows
911
+ // nothing but `&self`.
912
+ const pathArgList = op.pathParams.map((p) => `${methodName(p.name)}: impl Into<String>`);
913
+ const pathArgNames = op.pathParams.map((p) => methodName(p.name));
914
+
915
+ const sig: string[] = [];
916
+ sig.push('');
917
+ sig.push(` /// Returns an async [\`futures_util::Stream\`] that yields every \`${itemType}\``);
918
+ sig.push(` /// across all pages, advancing the \`${cursorParam}\` cursor under the hood.`);
919
+ sig.push(' ///');
920
+ sig.push(' /// ```ignore');
921
+ sig.push(' /// use futures_util::TryStreamExt;');
922
+ sig.push(` /// let all: Vec<${itemType}> = self`);
923
+ sig.push(` /// .${method}_auto_paging(${[...pathArgNames, 'params'].join(', ')})`);
924
+ sig.push(' /// .try_collect()');
925
+ sig.push(' /// .await?;');
926
+ sig.push(' /// ```');
927
+ if (op.deprecated) sig.push(' #[deprecated]');
928
+
929
+ const argsAll = ['&self', ...pathArgList, `params: ${paramsType}`];
930
+ const optsSig = ` pub fn ${method}_auto_paging(${argsAll.join(', ')}) -> impl futures_util::Stream<Item = Result<${itemType}, Error>> + '_ {`;
931
+ if (optsSig.length <= 110) {
932
+ sig.push(optsSig);
933
+ } else {
934
+ sig.push(` pub fn ${method}_auto_paging(`);
935
+ for (const arg of argsAll) sig.push(` ${arg},`);
936
+ sig.push(` ) -> impl futures_util::Stream<Item = Result<${itemType}, Error>> + '_ {`);
937
+ }
938
+
939
+ for (const n of pathArgNames) {
940
+ sig.push(` let ${n}: String = ${n}.into();`);
941
+ }
942
+ sig.push(' crate::pagination::auto_paginate_pages(move |after| {');
943
+ for (const n of pathArgNames) sig.push(` let ${n} = ${n}.clone();`);
944
+ sig.push(' let mut params = params.clone();');
945
+ sig.push(` params.${cursorField} = after;`);
946
+ sig.push(' async move {');
947
+ const callArgs = [...pathArgNames.map((n) => `&${n}`), 'params'].join(', ');
948
+ sig.push(` let page = self.${method}(${callArgs}).await?;`);
949
+ sig.push(` Ok((page.${dataAccessor}, page.list_metadata.${cursorField}))`);
950
+ sig.push(' }');
951
+ sig.push(' })');
952
+ sig.push(' }');
953
+
954
+ return sig.join('\n');
955
+ }
956
+
957
+ function renderWrapperParamsStruct(
958
+ name: string,
959
+ _op: Operation,
960
+ _wrapper: ResolvedWrapper,
961
+ params: ResolvedWrapperParam[],
962
+ registry: UnionRegistry,
963
+ ctx: EmitterContext,
964
+ ): string {
965
+ type FieldInfo = {
966
+ fname: string;
967
+ rust: string;
968
+ required: boolean;
969
+ defaultExpr: string | null;
970
+ };
971
+ const fields: FieldInfo[] = [];
972
+ const fieldLines: string[] = [];
973
+ const seen = new Set<string>();
974
+ for (const rp of params) {
975
+ const fname = fieldName(rp.paramName);
976
+ if (seen.has(fname)) continue;
977
+ seen.add(fname);
978
+ let rust: string;
979
+ if (rp.field) {
980
+ rust = mapTypeRef(rp.field.type, {
981
+ hint: `${name}${typeName(rp.paramName)}`,
982
+ registry,
983
+ });
984
+ } else {
985
+ rust = 'String';
986
+ }
987
+ if (rp.isOptional && !rust.startsWith('Option<')) rust = makeOptional(rust);
988
+ rust = applySecretRedaction(rust, rp.paramName);
989
+ const desc = rp.field?.description?.trim();
990
+ if (desc) {
991
+ for (const c of paramDocComment(desc)) fieldLines.push(` ${c}`);
992
+ }
993
+ const fieldDefault = rp.field?.default;
994
+ if (fieldDefault != null) {
995
+ if (desc) fieldLines.push(' ///');
996
+ fieldLines.push(` /// Defaults to \`${formatDefault(fieldDefault)}\`.`);
997
+ }
998
+ const required = !rp.isOptional && !rust.startsWith('Option<');
999
+ if (required) {
1000
+ if (desc || fieldDefault != null) fieldLines.push(' ///');
1001
+ fieldLines.push(' /// Required.');
1002
+ }
1003
+ if (rust.startsWith('Option<')) {
1004
+ fieldLines.push(' #[serde(skip_serializing_if = "Option::is_none")]');
1005
+ }
1006
+ if (fname !== rp.paramName) {
1007
+ fieldLines.push(` #[serde(rename = ${JSON.stringify(rp.paramName)})]`);
1008
+ }
1009
+ fieldLines.push(` pub ${fname}: ${rust},`);
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 });
1015
+ }
1016
+
1017
+ // Mirror the regular params struct: derive `Default` only when every field
1018
+ // is optional and no field carries a spec-level default; spec defaults need
1019
+ // a manual `impl Default` (Option<T>::default() is None).
1020
+ const requiredFields = fields.filter((f) => f.required);
1021
+ const allOptional = fields.length === 0 || requiredFields.length === 0;
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';
1025
+
1026
+ const out: string[] = [];
1027
+ if (fieldLines.length === 0) {
1028
+ out.push(`#[derive(${derives})]`, `pub struct ${name} {}`);
1029
+ } else {
1030
+ out.push(`#[derive(${derives})]`, `pub struct ${name} {`, ...fieldLines, '}');
1031
+ }
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
+
1045
+ if (requiredFields.length > 0) {
1046
+ const ctorArgs = requiredFields.map((f) => `${f.fname}: ${ctorParamType(f.rust)}`).join(', ');
1047
+ const initLines: string[] = [];
1048
+ for (const f of fields) {
1049
+ if (f.required) {
1050
+ const value = ctorParamConvert(f.rust, f.fname);
1051
+ initLines.push(value === f.fname ? ` ${f.fname},` : ` ${f.fname}: ${value},`);
1052
+ } else {
1053
+ initLines.push(` ${f.fname}: ${f.defaultExpr ?? 'Default::default()'},`);
1054
+ }
1055
+ }
1056
+ out.push('');
1057
+ out.push(`impl ${name} {`);
1058
+ out.push(` /// Construct a new \`${name}\` with the required fields set.`);
1059
+ out.push(` pub fn new(${ctorArgs}) -> Self {`);
1060
+ out.push(' Self {');
1061
+ out.push(...initLines);
1062
+ out.push(' }');
1063
+ out.push(' }');
1064
+ out.push('}');
1065
+ }
1066
+
1067
+ return out.join('\n');
1068
+ }
1069
+
1070
+ function renderWrapperMethod(
1071
+ op: Operation,
1072
+ wrapper: ResolvedWrapper,
1073
+ params: ResolvedWrapperParam[],
1074
+ paramsType: string,
1075
+ method: string,
1076
+ ): string {
1077
+ const segments = parsePathTemplate(op.path);
1078
+ const pathArgList = op.pathParams.map((p) => `${methodName(p.name)}: &str`);
1079
+ const pathArgNames = op.pathParams.map((p) => methodName(p.name));
1080
+ const returnType = wrapper.responseModelName ? typeName(wrapper.responseModelName) : renderResponseType(op);
1081
+
1082
+ const sig: string[] = [];
1083
+ const docLines: string[] = [];
1084
+ const desc = (op.description ?? '').trim();
1085
+ if (desc) {
1086
+ for (const raw of desc.split('\n')) {
1087
+ const t = raw.trim();
1088
+ docLines.push(t.length === 0 ? ' ///' : ` /// ${t}`);
1089
+ }
1090
+ } else {
1091
+ docLines.push(` /// ${op.httpMethod.toUpperCase()} ${op.path} (${wrapper.name})`);
1092
+ }
1093
+
1094
+ // Convenience method — delegates to `_with_options`.
1095
+ sig.push(...docLines);
1096
+ if (op.deprecated) sig.push(' #[deprecated]');
1097
+ const argsConvenience = ['&self', ...pathArgList, `params: ${paramsType}`];
1098
+ const convenienceSig = ` pub async fn ${method}(${argsConvenience.join(', ')}) -> Result<${returnType}, Error> {`;
1099
+ if (convenienceSig.length <= 100) {
1100
+ sig.push(convenienceSig);
1101
+ } else {
1102
+ sig.push(` pub async fn ${method}(`);
1103
+ for (const arg of argsConvenience) sig.push(` ${arg},`);
1104
+ sig.push(` ) -> Result<${returnType}, Error> {`);
1105
+ }
1106
+ const delegateArgs = [...pathArgNames, 'params', 'None'].join(', ');
1107
+ sig.push(` self.${method}_with_options(${delegateArgs}).await`);
1108
+ sig.push(' }');
1109
+ sig.push('');
1110
+
1111
+ // `_with_options` variant.
1112
+ sig.push(` /// Variant of [\`Self::${method}\`] that accepts per-request [\`crate::RequestOptions\`].`);
1113
+ if (op.deprecated) sig.push(' #[deprecated]');
1114
+ const argsOpts = ['&self', ...pathArgList, `params: ${paramsType}`, 'options: Option<&crate::RequestOptions>'];
1115
+ const optsSig = ` pub async fn ${method}_with_options(${argsOpts.join(', ')}) -> Result<${returnType}, Error> {`;
1116
+ if (optsSig.length <= 100) {
1117
+ sig.push(optsSig);
1118
+ } else {
1119
+ sig.push(` pub async fn ${method}_with_options(`);
1120
+ for (const arg of argsOpts) sig.push(` ${arg},`);
1121
+ sig.push(` ) -> Result<${returnType}, Error> {`);
1122
+ }
1123
+
1124
+ const pathFormat = segments
1125
+ .map((s) => (s.kind === 'literal' ? s.value : `{${methodName(s.name as string)}}`))
1126
+ .join('');
1127
+ const pathHasParams = segments.some((s) => s.kind === 'param');
1128
+
1129
+ if (pathHasParams) {
1130
+ for (const p of op.pathParams) {
1131
+ const n = methodName(p.name);
1132
+ sig.push(` let ${n} = crate::client::path_segment(${n});`);
1133
+ }
1134
+ sig.push(` let path = format!(${JSON.stringify(pathFormat)});`);
1135
+ } else {
1136
+ sig.push(` let path = ${JSON.stringify(pathFormat)}.to_string();`);
1137
+ }
1138
+
1139
+ sig.push(` let method = http::Method::${op.httpMethod.toUpperCase()};`);
1140
+
1141
+ // Build the JSON body inline: defaults + inferFromClient (read from the
1142
+ // client at request time) + each exposed param.
1143
+ sig.push(' let body = serde_json::json!({');
1144
+ for (const [k, v] of Object.entries(wrapper.defaults ?? {})) {
1145
+ sig.push(` ${JSON.stringify(k)}: ${JSON.stringify(v)},`);
1146
+ }
1147
+ for (const k of wrapper.inferFromClient ?? []) {
1148
+ sig.push(` ${JSON.stringify(k)}: ${clientFieldExpression(k)},`);
1149
+ }
1150
+ for (const rp of params) {
1151
+ sig.push(` ${JSON.stringify(rp.paramName)}: params.${fieldName(rp.paramName)},`);
1152
+ }
1153
+ sig.push(' });');
1154
+
1155
+ sig.push(' #[derive(Serialize)]');
1156
+ sig.push(' struct EmptyQuery {}');
1157
+ sig.push(' self.client');
1158
+ sig.push(' .request_with_body_opts(method, &path, &EmptyQuery {}, Some(&body), options)');
1159
+ sig.push(' .await');
1160
+ sig.push(' }');
1161
+
1162
+ return sig.join('\n');
1163
+ }
1164
+
1165
+ /**
1166
+ * Rust expression for reading a client-config field at request time. Mirrors
1167
+ * the Go emitter's `clientFieldExpression`. Falls back to an empty literal
1168
+ * for unknown fields so the body still compiles.
1169
+ */
1170
+ function clientFieldExpression(field: string): string {
1171
+ switch (field) {
1172
+ case 'client_id':
1173
+ return 'self.client.client_id()';
1174
+ case 'client_secret':
1175
+ return 'self.client.api_key()';
1176
+ default:
1177
+ return '""';
1178
+ }
1179
+ }
1180
+
1181
+ /** Multi-line `///` doc comment from a free-form description. */
1182
+ function paramDocComment(text: string): string[] {
1183
+ return text
1184
+ .split('\n')
1185
+ .map((l) => l.trim())
1186
+ .filter((l) => l.length > 0)
1187
+ .map((l) => `/// ${l}`);
1188
+ }
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
+
1244
+ function methodDocLines(op: Operation): string[] {
1245
+ const lines: string[] = [];
1246
+ if (op.description && op.description.trim().length > 0) {
1247
+ for (const raw of op.description.split('\n')) {
1248
+ const trimmed = raw.trim();
1249
+ if (trimmed.length === 0) {
1250
+ lines.push('///');
1251
+ } else {
1252
+ lines.push(`/// ${trimmed}`);
1253
+ }
1254
+ }
1255
+ } else {
1256
+ lines.push(`/// ${op.httpMethod.toUpperCase()} ${op.path}`);
1257
+ }
1258
+ return lines;
1259
+ }
1260
+
1261
+ function renderResponseType(op: Operation): string {
1262
+ if (isEmptyResponse(op)) return '()';
1263
+ return mapTypeRef(op.response!);
1264
+ }
1265
+
1266
+ /**
1267
+ * True when the operation has no usable response schema. We treat the IR's
1268
+ * `primitive: unknown` and missing-response cases the same way: the spec
1269
+ * declared no JSON body, so the SDK promises nothing about the contents.
1270
+ * Returning `Result<(), Error>` is more honest than handing back a
1271
+ * `serde_json::Value` that's almost always `Object({})` — and it lets the
1272
+ * caller `?` the result without an unused-variable warning.
1273
+ */
1274
+ function isEmptyResponse(op: Operation): boolean {
1275
+ if (!op.response) return true;
1276
+ if (op.response.kind === 'primitive' && op.response.type === 'unknown') return true;
1277
+ return false;
1278
+ }
1279
+
1280
+ /** Treat a body as optional when the IR wraps it in `nullable`. */
1281
+ function isBodyRequired(op: Operation): boolean {
1282
+ return op.requestBody !== undefined && op.requestBody.kind !== 'nullable';
1283
+ }
1284
+
1285
+ /**
1286
+ * `true` when the resolved operation contributes nothing to a params struct:
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.
1290
+ */
1291
+ function isEmptyParams(op: Operation, resolved: ResolvedOperation): boolean {
1292
+ if (op.requestBody) return false;
1293
+ const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
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));
1299
+ const visibleHeader = op.headerParams.filter((p) => !hidden.has(p.name));
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
+ );
1307
+ }
1308
+
1309
+ function renderResourcesBarrel(exports: { module: string; struct: string }[]): string {
1310
+ const seen = new Set<string>();
1311
+ const unique: { module: string; struct: string }[] = [];
1312
+ for (const e of exports) {
1313
+ if (seen.has(e.module)) continue;
1314
+ seen.add(e.module);
1315
+ unique.push(e);
1316
+ }
1317
+ unique.sort((a, b) => a.module.localeCompare(b.module));
1318
+
1319
+ const lines: string[] = [];
1320
+ for (const { module } of unique) lines.push(`pub mod ${module};`);
1321
+ lines.push('');
1322
+ for (const { module, struct } of unique) lines.push(`pub use ${module}::${struct};`);
1323
+ return lines.join('\n') + '\n';
1324
+ }