@workos/oagen-emitters 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,689 @@
1
+ import type {
2
+ Service,
3
+ Operation,
4
+ EmitterContext,
5
+ GeneratedFile,
6
+ Parameter,
7
+ ResolvedOperation,
8
+ ResolvedWrapper,
9
+ } from '@workos/oagen';
10
+ import { planOperation } from '@workos/oagen';
11
+ import { fieldName, methodName, typeName, moduleName } from './naming.js';
12
+ import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
13
+ import { applySecretRedaction } from './secret.js';
14
+ import { parsePathTemplate } from '../shared/path-template.js';
15
+ import { groupByMount, buildResolvedLookup } from '../shared/resolved-ops.js';
16
+ import { resolveWrapperParams, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
17
+
18
+ /**
19
+ * Generate one resource file per mount target under `src/resources/`, plus a
20
+ * `src/resources/mod.rs` barrel. Each file collapses every IR service that
21
+ * mounts on the same target into a single `Api` struct.
22
+ */
23
+ export function generateResources(_services: Service[], ctx: EmitterContext, registry: UnionRegistry): GeneratedFile[] {
24
+ const groups = groupByMount(ctx);
25
+ const lookup = buildResolvedLookup(ctx);
26
+ const files: GeneratedFile[] = [];
27
+ const exports: { module: string; struct: string }[] = [];
28
+
29
+ for (const [mountName, group] of groups) {
30
+ if (group.operations.length === 0) continue;
31
+ const basename = moduleName(mountName);
32
+ const struct = mountStructName(mountName);
33
+ exports.push({ module: basename, struct });
34
+ files.push({
35
+ path: `src/resources/${basename}.rs`,
36
+ content: renderMountGroup(mountName, group.resolvedOps, ctx, registry, lookup),
37
+ overwriteExisting: true,
38
+ });
39
+ }
40
+
41
+ files.push({
42
+ path: 'src/resources/mod.rs',
43
+ content: renderResourcesBarrel(exports),
44
+ overwriteExisting: true,
45
+ });
46
+
47
+ return files;
48
+ }
49
+
50
+ /** PascalCase struct name for a mount target (e.g., `UserManagementApi`). */
51
+ export function mountStructName(mountName: string): string {
52
+ const base = typeName(mountName);
53
+ return base.endsWith('Api') ? base : `${base}Api`;
54
+ }
55
+
56
+ function renderMountGroup(
57
+ mountName: string,
58
+ resolvedOps: ResolvedOperation[],
59
+ ctx: EmitterContext,
60
+ registry: UnionRegistry,
61
+ _lookup: Map<string, ResolvedOperation>,
62
+ ): string {
63
+ const struct = mountStructName(mountName);
64
+ const lines: string[] = [];
65
+ lines.push('use crate::client::Client;');
66
+ lines.push('#[allow(unused_imports)]');
67
+ lines.push('use crate::enums::*;');
68
+ lines.push('use crate::error::Error;');
69
+ lines.push('#[allow(unused_imports)]');
70
+ lines.push('use crate::models::*;');
71
+ lines.push('use serde::Serialize;');
72
+ lines.push('');
73
+ lines.push(`pub struct ${struct}<'a> {`);
74
+ lines.push(" pub(crate) client: &'a Client,");
75
+ lines.push('}');
76
+ lines.push('');
77
+
78
+ const paramsStructs: string[] = [];
79
+ const methods: string[] = [];
80
+ const seenMethods = new Set<string>();
81
+
82
+ for (const resolved of resolvedOps) {
83
+ const op = resolved.operation;
84
+ if ((resolved.wrappers?.length ?? 0) > 0) {
85
+ for (const wrapper of resolved.wrappers!) {
86
+ const wrapperMethodName = methodName(wrapper.name);
87
+ if (seenMethods.has(wrapperMethodName)) continue;
88
+ seenMethods.add(wrapperMethodName);
89
+ const paramsType = `${typeName(wrapper.name)}Params`;
90
+ const params = resolveWrapperParams(wrapper, ctx);
91
+ paramsStructs.push(renderWrapperParamsStruct(paramsType, op, wrapper, params, registry));
92
+ methods.push(renderWrapperMethod(op, wrapper, params, paramsType, wrapperMethodName));
93
+ }
94
+ continue;
95
+ }
96
+
97
+ const m = methodName(resolved.methodName);
98
+ if (seenMethods.has(m)) continue;
99
+ seenMethods.add(m);
100
+ const paramsType = `${typeName(resolved.methodName)}Params`;
101
+ const emptyParams = isEmptyParams(op, resolved);
102
+ if (!emptyParams) {
103
+ paramsStructs.push(renderParamsStruct(paramsType, op, resolved, registry));
104
+ }
105
+ methods.push(renderMethod(op, resolved, paramsType, m, emptyParams));
106
+
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
111
+ const autoPaging = renderAutoPagingMethod(op, resolved, paramsType, m, ctx);
112
+ if (autoPaging) methods.push(autoPaging);
113
+ }
114
+
115
+ for (const s of paramsStructs) {
116
+ lines.push(s);
117
+ lines.push('');
118
+ }
119
+
120
+ lines.push(`impl<'a> ${struct}<'a> {`);
121
+ methods.forEach((mm, i) => {
122
+ lines.push(mm);
123
+ if (i < methods.length - 1) lines.push('');
124
+ });
125
+ lines.push('}');
126
+
127
+ return lines.join('\n').replace(/\n+$/g, '\n');
128
+ }
129
+
130
+ function renderParamsStruct(name: string, op: Operation, resolved: ResolvedOperation, registry: UnionRegistry): string {
131
+ const bodyRequired = isBodyRequired(op);
132
+ const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
133
+
134
+ type FieldInfo = { fname: string; rust: string; required: boolean; doc?: string };
135
+ const fields: FieldInfo[] = [];
136
+ const fieldLines: string[] = [];
137
+ const seen = new Set<string>();
138
+ const emitField = (p: Parameter) => {
139
+ if (hidden.has(p.name)) return;
140
+ const fname = fieldName(p.name);
141
+ if (seen.has(fname)) return;
142
+ seen.add(fname);
143
+ let rust = mapTypeRef(p.type, { hint: `${name}${typeName(p.name)}`, registry });
144
+ if (!p.required && !rust.startsWith('Option<')) rust = makeOptional(rust);
145
+ rust = applySecretRedaction(rust, p.name);
146
+ // Field-level documentation derived from the spec.
147
+ const desc = p.description?.trim();
148
+ if (desc) {
149
+ for (const c of paramDocComment(desc)) fieldLines.push(` ${c}`);
150
+ }
151
+ if (p.required && !rust.startsWith('Option<')) {
152
+ if (desc) fieldLines.push(' ///');
153
+ fieldLines.push(' /// Required.');
154
+ }
155
+ if (rust.startsWith('Option<')) {
156
+ fieldLines.push(' #[serde(skip_serializing_if = "Option::is_none")]');
157
+ }
158
+ if (fname !== p.name) {
159
+ fieldLines.push(` #[serde(rename = ${JSON.stringify(p.name)})]`);
160
+ }
161
+ if (p.deprecated) fieldLines.push(' #[deprecated]');
162
+ fieldLines.push(` pub ${fname}: ${rust},`);
163
+ fields.push({ fname, rust, required: !!p.required && !rust.startsWith('Option<') });
164
+ };
165
+
166
+ for (const p of op.queryParams) emitField(p);
167
+ for (const p of op.headerParams) emitField(p);
168
+
169
+ if (op.requestBody) {
170
+ let bodyType = mapTypeRef(op.requestBody, { hint: `${name}Body`, registry });
171
+ if (!bodyRequired && !bodyType.startsWith('Option<')) {
172
+ bodyType = makeOptional(bodyType);
173
+ }
174
+ fieldLines.push(' /// Request body sent with this call.');
175
+ if (bodyRequired) fieldLines.push(' ///');
176
+ if (bodyRequired) fieldLines.push(' /// Required.');
177
+ fieldLines.push(' #[serde(skip)]');
178
+ fieldLines.push(` pub body: ${bodyType},`);
179
+ fields.push({ fname: 'body', rust: bodyType, required: bodyRequired });
180
+ }
181
+
182
+ // Default-derive only when every field is optional (so Default can't
183
+ // construct a "valid" value with empty strings for required fields).
184
+ const requiredFields = fields.filter((f) => f.required);
185
+ const allOptional = fields.length === 0 || requiredFields.length === 0;
186
+ const derives = allOptional ? 'Debug, Clone, Default, Serialize' : 'Debug, Clone, Serialize';
187
+
188
+ const out: string[] = [];
189
+ if (fieldLines.length === 0) {
190
+ out.push(`#[derive(${derives})]`, `pub struct ${name} {}`);
191
+ } else {
192
+ out.push(`#[derive(${derives})]`, `pub struct ${name} {`, ...fieldLines, '}');
193
+ }
194
+
195
+ // Generate `new(...)` constructor when there is at least one required field
196
+ // but at least one optional field — gives callers a clear ergonomic entry
197
+ // point without forcing them to spell out optional fields.
198
+ if (requiredFields.length > 0) {
199
+ const ctorArgs = requiredFields.map((f) => `${f.fname}: ${ctorParamType(f.rust)}`).join(', ');
200
+ const initLines: string[] = [];
201
+ for (const f of fields) {
202
+ if (f.required) {
203
+ const value = ctorParamConvert(f.rust, f.fname);
204
+ // Use field-init shorthand when the parameter and field names match.
205
+ initLines.push(value === f.fname ? ` ${f.fname},` : ` ${f.fname}: ${value},`);
206
+ } else {
207
+ initLines.push(` ${f.fname}: Default::default(),`);
208
+ }
209
+ }
210
+ out.push('');
211
+ out.push(`impl ${name} {`);
212
+ out.push(` /// Construct a new \`${name}\` with the required fields set.`);
213
+ out.push(' #[allow(deprecated)]');
214
+ out.push(` pub fn new(${ctorArgs}) -> Self {`);
215
+ out.push(' Self {');
216
+ out.push(...initLines);
217
+ out.push(' }');
218
+ out.push(' }');
219
+ out.push('}');
220
+ }
221
+
222
+ return out.join('\n');
223
+ }
224
+
225
+ /** Constructor parameter type — accept `impl Into<String>` for ergonomic strings. */
226
+ function ctorParamType(rust: string): string {
227
+ if (rust === 'String') return 'impl Into<String>';
228
+ if (rust === 'crate::SecretString') return 'impl Into<crate::SecretString>';
229
+ return rust;
230
+ }
231
+
232
+ function ctorParamConvert(rust: string, name: string): string {
233
+ if (rust === 'String') return `${name}.into()`;
234
+ if (rust === 'crate::SecretString') return `${name}.into()`;
235
+ return name;
236
+ }
237
+
238
+ function renderMethod(
239
+ op: Operation,
240
+ resolved: ResolvedOperation,
241
+ paramsType: string,
242
+ method: string,
243
+ emptyParams: boolean,
244
+ ): string {
245
+ const plan = planOperation(op);
246
+ const segments = parsePathTemplate(op.path);
247
+ const pathArgList = op.pathParams.map((p) => `${methodName(p.name)}: &str`);
248
+ const pathArgNames = op.pathParams.map((p) => methodName(p.name));
249
+
250
+ const returnType = renderResponseType(op);
251
+ const bodyRequired = isBodyRequired(op);
252
+
253
+ const sig: string[] = [];
254
+
255
+ // Convenience method — no per-request options. Delegates to `_with_options`.
256
+ for (const line of methodDocLines(op)) sig.push(` ${line}`);
257
+ if (op.deprecated) sig.push(' #[deprecated]');
258
+ const argsConvenience = ['&self', ...pathArgList, ...(emptyParams ? [] : [`params: ${paramsType}`])];
259
+ const convenienceSig = ` pub async fn ${method}(${argsConvenience.join(', ')}) -> Result<${returnType}, Error> {`;
260
+ if (convenienceSig.length <= 100) {
261
+ sig.push(convenienceSig);
262
+ } else {
263
+ sig.push(` pub async fn ${method}(`);
264
+ for (const arg of argsConvenience) sig.push(` ${arg},`);
265
+ sig.push(` ) -> Result<${returnType}, Error> {`);
266
+ }
267
+ const delegateArgs = [...pathArgNames, ...(emptyParams ? [] : ['params']), 'None'].join(', ');
268
+ sig.push(` self.${method}_with_options(${delegateArgs}).await`);
269
+ sig.push(' }');
270
+ sig.push('');
271
+
272
+ // `_with_options` variant — per-request idempotency keys, custom headers, etc.
273
+ sig.push(` /// Variant of [\`Self::${method}\`] that accepts per-request [\`crate::RequestOptions\`].`);
274
+ if (op.deprecated) sig.push(' #[deprecated]');
275
+ const argsOpts = [
276
+ '&self',
277
+ ...pathArgList,
278
+ ...(emptyParams ? [] : [`params: ${paramsType}`]),
279
+ 'options: Option<&crate::RequestOptions>',
280
+ ];
281
+ const optsSig = ` pub async fn ${method}_with_options(${argsOpts.join(', ')}) -> Result<${returnType}, Error> {`;
282
+ if (optsSig.length <= 100) {
283
+ sig.push(optsSig);
284
+ } else {
285
+ sig.push(` pub async fn ${method}_with_options(`);
286
+ for (const arg of argsOpts) sig.push(` ${arg},`);
287
+ sig.push(` ) -> Result<${returnType}, Error> {`);
288
+ }
289
+
290
+ const pathFormat = segments
291
+ .map((s) => (s.kind === 'literal' ? s.value : `{${methodName(s.name as string)}}`))
292
+ .join('');
293
+ const pathHasParams = segments.some((s) => s.kind === 'param');
294
+
295
+ if (pathHasParams) {
296
+ for (const p of op.pathParams) {
297
+ const n = methodName(p.name);
298
+ sig.push(` let ${n} = crate::client::path_segment(${n});`);
299
+ }
300
+ sig.push(` let path = format!(${JSON.stringify(pathFormat)});`);
301
+ } else {
302
+ sig.push(` let path = ${JSON.stringify(pathFormat)}.to_string();`);
303
+ }
304
+
305
+ sig.push(` let method = http::Method::${op.httpMethod.toUpperCase()};`);
306
+
307
+ // For empty-params endpoints, pass `&()` as the (empty) query — `()`
308
+ // serialises to nothing under serde, matching the previous empty-struct
309
+ // behaviour without surfacing the struct in the public API.
310
+ const queryRef = emptyParams ? '&()' : '&params';
311
+ const emptyResp = isEmptyResponse(op);
312
+ const bodyMethod = emptyResp ? 'request_with_body_opts_empty' : 'request_with_body_opts';
313
+ const queryMethod = emptyResp ? 'request_with_query_opts_empty' : 'request_with_query_opts';
314
+
315
+ if (op.requestBody) {
316
+ sig.push(' self.client');
317
+ if (bodyRequired) {
318
+ sig.push(` .${bodyMethod}(method, &path, ${queryRef}, Some(&params.body), options)`);
319
+ } else {
320
+ sig.push(` .${bodyMethod}(method, &path, ${queryRef}, params.body.as_ref(), options)`);
321
+ }
322
+ sig.push(' .await');
323
+ } else {
324
+ sig.push(' self.client');
325
+ sig.push(` .${queryMethod}(method, &path, ${queryRef}, options)`);
326
+ sig.push(' .await');
327
+ }
328
+
329
+ sig.push(' }');
330
+
331
+ void plan;
332
+ void resolved;
333
+ return sig.join('\n');
334
+ }
335
+
336
+ /**
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).
341
+ */
342
+ function renderAutoPagingMethod(
343
+ op: Operation,
344
+ _resolved: ResolvedOperation,
345
+ paramsType: string,
346
+ method: string,
347
+ ctx: EmitterContext,
348
+ ): 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);
352
+ if (!responseModel) return null;
353
+
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
+
359
+ 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;
363
+
364
+ // 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`.
367
+ const pathArgList = op.pathParams.map((p) => `${methodName(p.name)}: impl Into<String>`);
368
+ const pathArgNames = op.pathParams.map((p) => methodName(p.name));
369
+
370
+ const sig: string[] = [];
371
+ sig.push('');
372
+ 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.`);
374
+ sig.push(' ///');
375
+ sig.push(' /// ```ignore');
376
+ sig.push(' /// use futures_util::TryStreamExt;');
377
+ sig.push(` /// let all: Vec<${itemType}> = self`);
378
+ sig.push(` /// .${method}_auto_paging(${[...pathArgNames, 'params'].join(', ')})`);
379
+ sig.push(' /// .try_collect()');
380
+ sig.push(' /// .await?;');
381
+ sig.push(' /// ```');
382
+ if (op.deprecated) sig.push(' #[deprecated]');
383
+
384
+ const argsAll = ['&self', ...pathArgList, `params: ${paramsType}`];
385
+ const optsSig = ` pub fn ${method}_auto_paging(${argsAll.join(', ')}) -> impl futures_util::Stream<Item = Result<${itemType}, Error>> + '_ {`;
386
+ if (optsSig.length <= 110) {
387
+ sig.push(optsSig);
388
+ } else {
389
+ sig.push(` pub fn ${method}_auto_paging(`);
390
+ for (const arg of argsAll) sig.push(` ${arg},`);
391
+ sig.push(` ) -> impl futures_util::Stream<Item = Result<${itemType}, Error>> + '_ {`);
392
+ }
393
+
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
+ for (const n of pathArgNames) {
397
+ sig.push(` let ${n}: String = ${n}.into();`);
398
+ }
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
+ sig.push(' crate::pagination::auto_paginate_pages(move |after| {');
403
+ for (const n of pathArgNames) sig.push(` let ${n} = ${n}.clone();`);
404
+ sig.push(' let mut params = params.clone();');
405
+ sig.push(' params.after = after;');
406
+ sig.push(' async move {');
407
+ const callArgs = [...pathArgNames.map((n) => `&${n}`), 'params'].join(', ');
408
+ sig.push(` let page = self.${method}(${callArgs}).await?;`);
409
+ sig.push(' Ok((page.data, page.list_metadata.after))');
410
+ sig.push(' }');
411
+ sig.push(' })');
412
+ sig.push(' }');
413
+
414
+ return sig.join('\n');
415
+ }
416
+
417
+ function renderWrapperParamsStruct(
418
+ name: string,
419
+ _op: Operation,
420
+ _wrapper: ResolvedWrapper,
421
+ params: ResolvedWrapperParam[],
422
+ registry: UnionRegistry,
423
+ ): string {
424
+ type FieldInfo = { fname: string; rust: string; required: boolean };
425
+ const fields: FieldInfo[] = [];
426
+ const fieldLines: string[] = [];
427
+ const seen = new Set<string>();
428
+ for (const rp of params) {
429
+ const fname = fieldName(rp.paramName);
430
+ if (seen.has(fname)) continue;
431
+ seen.add(fname);
432
+ let rust: string;
433
+ if (rp.field) {
434
+ rust = mapTypeRef(rp.field.type, { hint: `${name}${typeName(rp.paramName)}`, registry });
435
+ } else {
436
+ rust = 'String';
437
+ }
438
+ if (rp.isOptional && !rust.startsWith('Option<')) rust = makeOptional(rust);
439
+ rust = applySecretRedaction(rust, rp.paramName);
440
+ const desc = rp.field?.description?.trim();
441
+ if (desc) {
442
+ for (const c of paramDocComment(desc)) fieldLines.push(` ${c}`);
443
+ }
444
+ const required = !rp.isOptional && !rust.startsWith('Option<');
445
+ if (required) {
446
+ if (desc) fieldLines.push(' ///');
447
+ fieldLines.push(' /// Required.');
448
+ }
449
+ if (rust.startsWith('Option<')) {
450
+ fieldLines.push(' #[serde(skip_serializing_if = "Option::is_none")]');
451
+ }
452
+ if (fname !== rp.paramName) {
453
+ fieldLines.push(` #[serde(rename = ${JSON.stringify(rp.paramName)})]`);
454
+ }
455
+ fieldLines.push(` pub ${fname}: ${rust},`);
456
+ fields.push({ fname, rust, required });
457
+ }
458
+
459
+ // 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.
463
+ const requiredFields = fields.filter((f) => f.required);
464
+ const allOptional = fields.length === 0 || requiredFields.length === 0;
465
+ const derives = allOptional ? 'Debug, Clone, Default, Serialize' : 'Debug, Clone, Serialize';
466
+
467
+ const out: string[] = [];
468
+ if (fieldLines.length === 0) {
469
+ out.push(`#[derive(${derives})]`, `pub struct ${name} {}`);
470
+ } else {
471
+ out.push(`#[derive(${derives})]`, `pub struct ${name} {`, ...fieldLines, '}');
472
+ }
473
+
474
+ if (requiredFields.length > 0) {
475
+ const ctorArgs = requiredFields.map((f) => `${f.fname}: ${ctorParamType(f.rust)}`).join(', ');
476
+ const initLines: string[] = [];
477
+ for (const f of fields) {
478
+ if (f.required) {
479
+ const value = ctorParamConvert(f.rust, f.fname);
480
+ initLines.push(value === f.fname ? ` ${f.fname},` : ` ${f.fname}: ${value},`);
481
+ } else {
482
+ initLines.push(` ${f.fname}: Default::default(),`);
483
+ }
484
+ }
485
+ out.push('');
486
+ out.push(`impl ${name} {`);
487
+ out.push(` /// Construct a new \`${name}\` with the required fields set.`);
488
+ out.push(` pub fn new(${ctorArgs}) -> Self {`);
489
+ out.push(' Self {');
490
+ out.push(...initLines);
491
+ out.push(' }');
492
+ out.push(' }');
493
+ out.push('}');
494
+ }
495
+
496
+ return out.join('\n');
497
+ }
498
+
499
+ function renderWrapperMethod(
500
+ op: Operation,
501
+ wrapper: ResolvedWrapper,
502
+ params: ResolvedWrapperParam[],
503
+ paramsType: string,
504
+ method: string,
505
+ ): string {
506
+ const segments = parsePathTemplate(op.path);
507
+ const pathArgList = op.pathParams.map((p) => `${methodName(p.name)}: &str`);
508
+ const pathArgNames = op.pathParams.map((p) => methodName(p.name));
509
+ const returnType = wrapper.responseModelName ? typeName(wrapper.responseModelName) : renderResponseType(op);
510
+
511
+ const sig: string[] = [];
512
+ const docLines: string[] = [];
513
+ const desc = (op.description ?? '').trim();
514
+ if (desc) {
515
+ for (const raw of desc.split('\n')) {
516
+ const t = raw.trim();
517
+ docLines.push(t.length === 0 ? ' ///' : ` /// ${t}`);
518
+ }
519
+ } else {
520
+ docLines.push(` /// ${op.httpMethod.toUpperCase()} ${op.path} (${wrapper.name})`);
521
+ }
522
+
523
+ // Convenience method — delegates to `_with_options`.
524
+ sig.push(...docLines);
525
+ if (op.deprecated) sig.push(' #[deprecated]');
526
+ const argsConvenience = ['&self', ...pathArgList, `params: ${paramsType}`];
527
+ const convenienceSig = ` pub async fn ${method}(${argsConvenience.join(', ')}) -> Result<${returnType}, Error> {`;
528
+ if (convenienceSig.length <= 100) {
529
+ sig.push(convenienceSig);
530
+ } else {
531
+ sig.push(` pub async fn ${method}(`);
532
+ for (const arg of argsConvenience) sig.push(` ${arg},`);
533
+ sig.push(` ) -> Result<${returnType}, Error> {`);
534
+ }
535
+ const delegateArgs = [...pathArgNames, 'params', 'None'].join(', ');
536
+ sig.push(` self.${method}_with_options(${delegateArgs}).await`);
537
+ sig.push(' }');
538
+ sig.push('');
539
+
540
+ // `_with_options` variant.
541
+ sig.push(` /// Variant of [\`Self::${method}\`] that accepts per-request [\`crate::RequestOptions\`].`);
542
+ if (op.deprecated) sig.push(' #[deprecated]');
543
+ const argsOpts = ['&self', ...pathArgList, `params: ${paramsType}`, 'options: Option<&crate::RequestOptions>'];
544
+ const optsSig = ` pub async fn ${method}_with_options(${argsOpts.join(', ')}) -> Result<${returnType}, Error> {`;
545
+ if (optsSig.length <= 100) {
546
+ sig.push(optsSig);
547
+ } else {
548
+ sig.push(` pub async fn ${method}_with_options(`);
549
+ for (const arg of argsOpts) sig.push(` ${arg},`);
550
+ sig.push(` ) -> Result<${returnType}, Error> {`);
551
+ }
552
+
553
+ const pathFormat = segments
554
+ .map((s) => (s.kind === 'literal' ? s.value : `{${methodName(s.name as string)}}`))
555
+ .join('');
556
+ const pathHasParams = segments.some((s) => s.kind === 'param');
557
+
558
+ if (pathHasParams) {
559
+ for (const p of op.pathParams) {
560
+ const n = methodName(p.name);
561
+ sig.push(` let ${n} = crate::client::path_segment(${n});`);
562
+ }
563
+ sig.push(` let path = format!(${JSON.stringify(pathFormat)});`);
564
+ } else {
565
+ sig.push(` let path = ${JSON.stringify(pathFormat)}.to_string();`);
566
+ }
567
+
568
+ sig.push(` let method = http::Method::${op.httpMethod.toUpperCase()};`);
569
+
570
+ // Build the JSON body inline: defaults + inferFromClient (read from the
571
+ // client at request time) + each exposed param.
572
+ sig.push(' let body = serde_json::json!({');
573
+ for (const [k, v] of Object.entries(wrapper.defaults ?? {})) {
574
+ sig.push(` ${JSON.stringify(k)}: ${JSON.stringify(v)},`);
575
+ }
576
+ for (const k of wrapper.inferFromClient ?? []) {
577
+ sig.push(` ${JSON.stringify(k)}: ${clientFieldExpression(k)},`);
578
+ }
579
+ for (const rp of params) {
580
+ sig.push(` ${JSON.stringify(rp.paramName)}: params.${fieldName(rp.paramName)},`);
581
+ }
582
+ sig.push(' });');
583
+
584
+ sig.push(' #[derive(Serialize)]');
585
+ sig.push(' struct EmptyQuery {}');
586
+ sig.push(' self.client');
587
+ sig.push(' .request_with_body_opts(method, &path, &EmptyQuery {}, Some(&body), options)');
588
+ sig.push(' .await');
589
+ sig.push(' }');
590
+
591
+ return sig.join('\n');
592
+ }
593
+
594
+ /**
595
+ * Rust expression for reading a client-config field at request time. Mirrors
596
+ * the Go emitter's `clientFieldExpression`. Falls back to an empty literal
597
+ * for unknown fields so the body still compiles.
598
+ */
599
+ function clientFieldExpression(field: string): string {
600
+ switch (field) {
601
+ case 'client_id':
602
+ return 'self.client.client_id()';
603
+ case 'client_secret':
604
+ return 'self.client.api_key()';
605
+ default:
606
+ return '""';
607
+ }
608
+ }
609
+
610
+ /** Multi-line `///` doc comment from a free-form description. */
611
+ function paramDocComment(text: string): string[] {
612
+ return text
613
+ .split('\n')
614
+ .map((l) => l.trim())
615
+ .filter((l) => l.length > 0)
616
+ .map((l) => `/// ${l}`);
617
+ }
618
+
619
+ function methodDocLines(op: Operation): string[] {
620
+ const lines: string[] = [];
621
+ if (op.description && op.description.trim().length > 0) {
622
+ for (const raw of op.description.split('\n')) {
623
+ const trimmed = raw.trim();
624
+ if (trimmed.length === 0) {
625
+ lines.push('///');
626
+ } else {
627
+ lines.push(`/// ${trimmed}`);
628
+ }
629
+ }
630
+ } else {
631
+ lines.push(`/// ${op.httpMethod.toUpperCase()} ${op.path}`);
632
+ }
633
+ return lines;
634
+ }
635
+
636
+ function renderResponseType(op: Operation): string {
637
+ if (isEmptyResponse(op)) return '()';
638
+ return mapTypeRef(op.response!);
639
+ }
640
+
641
+ /**
642
+ * True when the operation has no usable response schema. We treat the IR's
643
+ * `primitive: unknown` and missing-response cases the same way: the spec
644
+ * declared no JSON body, so the SDK promises nothing about the contents.
645
+ * Returning `Result<(), Error>` is more honest than handing back a
646
+ * `serde_json::Value` that's almost always `Object({})` — and it lets the
647
+ * caller `?` the result without an unused-variable warning.
648
+ */
649
+ function isEmptyResponse(op: Operation): boolean {
650
+ if (!op.response) return true;
651
+ if (op.response.kind === 'primitive' && op.response.type === 'unknown') return true;
652
+ return false;
653
+ }
654
+
655
+ /** Treat a body as optional when the IR wraps it in `nullable`. */
656
+ function isBodyRequired(op: Operation): boolean {
657
+ return op.requestBody !== undefined && op.requestBody.kind !== 'nullable';
658
+ }
659
+
660
+ /**
661
+ * `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.
665
+ */
666
+ function isEmptyParams(op: Operation, resolved: ResolvedOperation): boolean {
667
+ if (op.requestBody) return false;
668
+ const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
669
+ const visibleQuery = op.queryParams.filter((p) => !hidden.has(p.name));
670
+ const visibleHeader = op.headerParams.filter((p) => !hidden.has(p.name));
671
+ return visibleQuery.length === 0 && visibleHeader.length === 0;
672
+ }
673
+
674
+ function renderResourcesBarrel(exports: { module: string; struct: string }[]): string {
675
+ const seen = new Set<string>();
676
+ const unique: { module: string; struct: string }[] = [];
677
+ for (const e of exports) {
678
+ if (seen.has(e.module)) continue;
679
+ seen.add(e.module);
680
+ unique.push(e);
681
+ }
682
+ unique.sort((a, b) => a.module.localeCompare(b.module));
683
+
684
+ const lines: string[] = [];
685
+ for (const { module } of unique) lines.push(`pub mod ${module};`);
686
+ lines.push('');
687
+ for (const { module, struct } of unique) lines.push(`pub use ${module}::${struct};`);
688
+ return lines.join('\n') + '\n';
689
+ }