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