@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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{plugin-DW3cnedr.mjs → plugin-CmfzawTp.mjs} +2851 -524
- package/dist/plugin-CmfzawTp.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +323 -0
- package/package.json +9 -9
- package/src/index.ts +1 -0
- package/src/plugin.ts +2 -1
- package/src/python/path-expression.ts +75 -26
- package/src/python/resources.ts +0 -9
- package/src/rust/client.ts +62 -0
- package/src/rust/enums.ts +201 -0
- package/src/rust/fixtures.ts +196 -0
- package/src/rust/index.ts +95 -0
- package/src/rust/manifest.ts +31 -0
- package/src/rust/models.ts +165 -0
- package/src/rust/naming.ts +131 -0
- package/src/rust/resources.ts +1324 -0
- package/src/rust/secret.ts +59 -0
- package/src/rust/tests.ts +818 -0
- package/src/rust/type-map.ts +225 -0
- package/test/entrypoint.test.ts +1 -0
- package/test/plugin.test.ts +2 -1
- package/test/python/resources.test.ts +2 -2
- package/test/rust/client.test.ts +62 -0
- package/test/rust/enums.test.ts +117 -0
- package/test/rust/fixtures.test.ts +227 -0
- package/test/rust/manifest.test.ts +73 -0
- package/test/rust/models.test.ts +177 -0
- package/test/rust/resources.test.ts +748 -0
- package/test/rust/tests.test.ts +504 -0
- package/test/rust/type-map.test.ts +83 -0
- package/dist/plugin-DW3cnedr.mjs.map +0 -1
|
@@ -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 ? '&()' : '¶ms';
|
|
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(¶ms.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(¶ms)');
|
|
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(¶ms)?;');
|
|
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
|
+
}
|