Skip to main content

openapi_nexus/generators/rust/common/
emit_api.rs

1//! API emission for IR operations (Rust APIs).
2//!
3//! Groups operations by tag, emits one `apis/<tag>.rs` per tag group. Each file
4//! declares a `{Tag}Api` struct holding a `&runtime::Client` and exposes one
5//! method per operation.
6//!
7//! Backend-specific method bodies are injected via a closure, keeping this module
8//! agnostic to the HTTP library (reqwest, ureq, aioduct, etc.).
9
10use std::collections::{BTreeMap, HashSet};
11
12use crate::codegen::traits::file_writer::FileInfo;
13use crate::ir::types::{
14    IrOperation, IrParameter, IrRequestBody, IrResponse, IrSpec, IrTypeExpr, ParameterLocation,
15};
16use heck::{ToPascalCase, ToSnakeCase};
17use sigil_stitch::code_block::{CodeBlock, CodeBlockBuilder};
18use sigil_stitch::spec::annotation_spec::AnnotationSpec;
19use sigil_stitch::spec::field_spec::FieldSpec;
20use sigil_stitch::spec::file_spec::FileSpec;
21use sigil_stitch::spec::import_spec::ImportSpec;
22use sigil_stitch::spec::modifiers::{TypeKind, Visibility};
23use sigil_stitch::spec::type_spec::TypeSpec;
24use sigil_stitch::type_name::TypeName;
25
26use super::config::ExtraDeriveConfig;
27use super::emit_models::rust_type_str_qualified;
28
29// ---------------------------------------------------------------------------
30// Backend configuration
31// ---------------------------------------------------------------------------
32
33/// Captures the differences between Rust HTTP backends.
34pub struct RustBackendConfig {
35    /// Whether methods are async (reqwest, aioduct) or sync (ureq).
36    pub is_async: bool,
37    /// Extra generic parameters on the Api struct, e.g., `"R: aioduct::Runtime"`.
38    /// `None` for reqwest and ureq.
39    pub struct_generics: Option<String>,
40    /// Extra generic args for the client field type, e.g., `"<R>"`.
41    /// `None` for reqwest and ureq.
42    pub client_type_args: Option<String>,
43}
44
45// ---------------------------------------------------------------------------
46// Public API
47// ---------------------------------------------------------------------------
48
49/// Generate every API file from the IR.
50pub fn generate_api_files(
51    ir: &IrSpec,
52    header: &str,
53    config: &RustBackendConfig,
54    response_extra_derives: Option<&ExtraDeriveConfig>,
55    body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
56) -> Result<Vec<FileInfo>, String> {
57    let by_tag = group_by_tag(&ir.operations);
58    let mut files = Vec::with_capacity(by_tag.len());
59    let mut mod_entries = Vec::new();
60
61    for (tag, ops) in &by_tag {
62        let stem = tag.to_snake_case();
63        let filename = format!("{stem}.rs");
64        mod_entries.push(stem);
65        let body = emit_api_file(tag, ops, config, response_extra_derives, body_emitter);
66        let content = format!("{header}{body}");
67        files.push(FileInfo::api(filename, content));
68    }
69
70    // mod.rs
71    let mut mod_content = String::from(header);
72    for entry in &mod_entries {
73        mod_content.push_str(&format!("mod {entry};\npub use {entry}::*;\n"));
74    }
75    files.push(FileInfo::api("mod.rs".to_string(), mod_content));
76
77    Ok(files)
78}
79
80// ---------------------------------------------------------------------------
81// Grouping
82// ---------------------------------------------------------------------------
83
84fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
85    let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
86    for op in operations {
87        let tags: Vec<String> = if op.tags.is_empty() {
88            vec!["default".to_string()]
89        } else {
90            op.tags.clone()
91        };
92        for tag in tags {
93            out.entry(tag).or_default().push(op);
94        }
95    }
96    out
97}
98
99// ---------------------------------------------------------------------------
100// File assembly
101// ---------------------------------------------------------------------------
102
103fn emit_api_file(
104    tag: &str,
105    ops: &[&IrOperation],
106    config: &RustBackendConfig,
107    response_extra_derives: Option<&ExtraDeriveConfig>,
108    body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
109) -> String {
110    let struct_name = format!("{}Api", tag.to_pascal_case());
111    let plans: Vec<OpPlan> = ops.iter().map(|op| plan_operation(op)).collect();
112
113    let stem = tag.to_snake_case();
114    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
115
116    // Use imports
117    fsb = fsb.add_import(ImportSpec::named("crate::runtime::client", "Client"));
118    fsb = fsb.add_import(ImportSpec::named("crate::runtime::error", "Error"));
119
120    // Struct generics (e.g., `<'a, R: aioduct::Runtime>`)
121    let (struct_gen, impl_gen, type_args, client_field_args) = match &config.struct_generics {
122        Some(g) => {
123            let client_args = config.client_type_args.as_deref().unwrap_or("");
124            let param_name = g.split(':').next().unwrap_or(g).trim();
125            (
126                format!("<'a, {g}>"),
127                format!("<'a, {g}>"),
128                format!("<'a, {param_name}>"),
129                client_args.to_string(),
130            )
131        }
132        None => (
133            "<'a>".to_string(),
134            "<'a>".to_string(),
135            "<'a>".to_string(),
136            String::new(),
137        ),
138    };
139
140    // Build struct + impl as a CodeBlock (lifetimes/generics don't fit TypeSpec)
141    let mut body = CodeBlock::builder();
142
143    // Struct doc + declaration
144    body.add(&format!("/// API operations under the \"{tag}\" tag."), ());
145    body.add_line();
146    body.add(&format!("pub struct {struct_name}{struct_gen}"), ());
147    body.begin_control_flow("", ());
148    body.add(&format!("client: &'a Client{client_field_args},\n"), ());
149    body.end_control_flow();
150    body.add_line();
151
152    // Impl block
153    body.add(&format!("impl{impl_gen} {struct_name}{type_args}"), ());
154    body.begin_control_flow("", ());
155
156    // Constructor
157    body.add(
158        &format!("/// Create a new `{struct_name}` bound to the given client."),
159        (),
160    );
161    body.add_line();
162    body.add(
163        &format!("pub fn new(client: &'a Client{client_field_args}) -> Self"),
164        (),
165    );
166    body.begin_control_flow("", ());
167    body.add("Self", ());
168    body.begin_control_flow("", ());
169    body.add("client,\n", ());
170    body.end_control_flow();
171    body.end_control_flow();
172
173    // Methods
174    for plan in &plans {
175        body.add_line();
176        body.add_code(emit_operation(plan, config, body_emitter));
177    }
178
179    body.end_control_flow(); // close impl
180
181    fsb = fsb.add_code(body.build().expect("body builds"));
182
183    // Response structs -- add as TypeSpec members
184    for plan in &plans {
185        fsb = fsb.add_type(emit_response_struct(plan, response_extra_derives));
186    }
187
188    let file = fsb.build().expect("FileSpec builds");
189    file.render(100).expect("FileSpec renders")
190}
191
192// ---------------------------------------------------------------------------
193// Operation planning (public for backend use)
194// ---------------------------------------------------------------------------
195
196pub struct OpPlan<'a> {
197    pub op: &'a IrOperation,
198    pub method_name: String,
199    pub response_type: String,
200    pub path_params: Vec<ParamBinding<'a>>,
201    pub query_params: Vec<ParamBinding<'a>>,
202    pub header_params: Vec<ParamBinding<'a>>,
203    pub body: Option<BodyBinding>,
204    pub typed_responses: Vec<TypedResponse>,
205}
206
207pub struct ParamBinding<'a> {
208    pub param: &'a IrParameter,
209    pub var_name: String,
210    pub rust_type: String,
211    pub is_optional: bool,
212}
213
214pub struct BodyBinding {
215    pub var_name: String,
216    pub rust_type: String,
217}
218
219pub struct TypedResponse {
220    pub status: String,
221    pub field_name: String,
222    pub rust_type: String,
223}
224
225pub fn plan_operation<'a>(op: &'a IrOperation) -> OpPlan<'a> {
226    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
227    let method_name = op_id.to_snake_case();
228    let response_type = format!("{}Response", op_id.to_pascal_case());
229
230    let mut used_names: HashSet<String> = HashSet::new();
231    used_names.insert("self".to_string());
232
233    let mut path_params = Vec::new();
234    let mut query_params = Vec::new();
235    let mut header_params = Vec::new();
236    for p in &op.parameters {
237        let var_name = unique_name(&p.name.to_snake_case(), &mut used_names);
238        let (rust_type, is_optional) = param_rust_type(p);
239        let binding = ParamBinding {
240            param: p,
241            var_name,
242            rust_type,
243            is_optional,
244        };
245        match p.location {
246            ParameterLocation::Path => path_params.push(binding),
247            ParameterLocation::Query => query_params.push(binding),
248            ParameterLocation::Header => header_params.push(binding),
249            ParameterLocation::Cookie => header_params.push(binding),
250        }
251    }
252
253    let body = op
254        .request_body
255        .as_ref()
256        .and_then(|b| plan_body(b, &mut used_names));
257
258    let typed_responses = op.responses.iter().filter_map(plan_response).collect();
259
260    OpPlan {
261        op,
262        method_name,
263        response_type,
264        path_params,
265        query_params,
266        header_params,
267        body,
268        typed_responses,
269    }
270}
271
272pub fn plan_body(b: &IrRequestBody, used_names: &mut HashSet<String>) -> Option<BodyBinding> {
273    let t = pick_body_type(b)?;
274    let rust_type = rust_type_str_qualified(&t);
275    let var_name = unique_name("body", used_names);
276    Some(BodyBinding {
277        var_name,
278        rust_type,
279    })
280}
281
282pub fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
283    let t = pick_response_type(r)?;
284    let rust_type = rust_type_str_qualified(&t);
285    Some(TypedResponse {
286        status: r.status.clone(),
287        field_name: response_field_name(&r.status),
288        rust_type,
289    })
290}
291
292pub fn param_rust_type(p: &IrParameter) -> (String, bool) {
293    let base = rust_type_str_qualified(&p.type_expr);
294    if p.required {
295        (base, false)
296    } else {
297        (format!("Option<{base}>"), true)
298    }
299}
300
301pub fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
302    if used.insert(desired.to_string()) {
303        return desired.to_string();
304    }
305    for i in 2..=u32::MAX {
306        let candidate = format!("{desired}_{i}");
307        if used.insert(candidate.clone()) {
308            return candidate;
309        }
310    }
311    unreachable!("name collision space exhausted")
312}
313
314// ---------------------------------------------------------------------------
315// Per-operation emission
316// ---------------------------------------------------------------------------
317
318fn emit_operation(
319    plan: &OpPlan<'_>,
320    config: &RustBackendConfig,
321    body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
322) -> CodeBlock {
323    let OpPlan {
324        op,
325        method_name,
326        response_type,
327        ..
328    } = plan;
329
330    let mut b = CodeBlock::builder();
331
332    // Doc comment
333    if let Some(summary) = &op.summary {
334        b.add(&format!("/// {summary}\n"), ());
335    } else {
336        b.add(
337            &format!("/// {} {}\n", op.method.to_uppercase(), op.path),
338            (),
339        );
340    }
341    if let Some(desc) = &op.description {
342        b.add("///\n", ());
343        for line in desc.lines() {
344            b.add(&format!("/// {line}\n"), ());
345        }
346    }
347
348    // Method signature
349    let mut params = Vec::new();
350    params.push("&self".to_string());
351    for p in plan
352        .path_params
353        .iter()
354        .chain(&plan.query_params)
355        .chain(&plan.header_params)
356    {
357        let ty = if is_copy_type(&p.rust_type) {
358            p.rust_type.clone()
359        } else {
360            format!("&{}", p.rust_type)
361        };
362        params.push(format!("{}: {ty}", p.var_name));
363    }
364    if let Some(body) = &plan.body {
365        params.push(format!("{}: &{}", body.var_name, body.rust_type));
366    }
367
368    let async_kw = if config.is_async { "async " } else { "" };
369    b.add(
370        &format!(
371            "pub {async_kw}fn {method_name}(\n    {},\n) -> Result<{response_type}, Error>",
372            params.join(",\n    "),
373        ),
374        (),
375    );
376    b.begin_control_flow("", ());
377
378    // Method body from backend
379    b.add_code(body_emitter(plan));
380
381    b.end_control_flow();
382    b.build().unwrap()
383}
384
385pub fn emit_response_struct(plan: &OpPlan<'_>, extra: Option<&ExtraDeriveConfig>) -> TypeSpec {
386    let mut tb = TypeSpec::builder(&plan.response_type, TypeKind::Struct);
387    tb = tb.visibility(Visibility::Public);
388    tb = tb.doc(&format!("Response from `{}`.", plan.method_name));
389
390    let mut ann = AnnotationSpec::new("derive");
391    ann = ann.arg("Debug");
392    if let Some(cfg) = extra {
393        for d in &cfg.derives {
394            ann = ann.arg(d);
395        }
396    }
397    tb = tb.annotate(ann);
398
399    // status_code field
400    {
401        let fb = FieldSpec::builder("status_code", TypeName::primitive("u16"));
402        let fb = fb.visibility(Visibility::Public);
403        tb = tb.add_field(fb.build().expect("FieldSpec builds"));
404    }
405
406    // typed response fields
407    let mut seen: HashSet<String> = HashSet::new();
408    for tr in &plan.typed_responses {
409        if !seen.insert(tr.field_name.clone()) {
410            continue;
411        }
412        let fb = FieldSpec::builder(
413            &tr.field_name,
414            TypeName::raw(&format!("Option<{}>", tr.rust_type)),
415        );
416        let fb = fb.visibility(Visibility::Public);
417        tb = tb.add_field(fb.build().expect("FieldSpec builds"));
418    }
419
420    tb.build().expect("TypeSpec builds")
421}
422
423// ---------------------------------------------------------------------------
424// Helpers (public for backend use)
425// ---------------------------------------------------------------------------
426
427pub fn sanitize_operation_id(id: &str, method: &str, path: &str) -> String {
428    if !id.is_empty() {
429        return id.to_string();
430    }
431    format!(
432        "{}_{}",
433        method,
434        path.replace('/', "_").replace(['{', '}'], "")
435    )
436}
437
438pub fn response_field_name(status: &str) -> String {
439    match status {
440        "200" => "data".to_string(),
441        "201" => "created".to_string(),
442        "204" => "no_content".to_string(),
443        "default" => "error_body".to_string(),
444        s if s.ends_with("XX") => {
445            let prefix = &s[..s.len() - 2];
446            format!("status_{prefix}xx")
447        }
448        s => format!("status_{s}"),
449    }
450}
451
452/// Convert an OpenAPI status code string to a Rust match pattern.
453pub fn status_match_pattern(status: &str) -> String {
454    match status {
455        "default" => "_".to_string(),
456        s if s.ends_with("XX") => {
457            let prefix: u16 = s[..s.len() - 2].parse().unwrap_or(0);
458            let lo = prefix * 100;
459            let hi = lo + 99;
460            format!("{lo}..={hi}")
461        }
462        s => s.to_string(),
463    }
464}
465
466pub fn pick_body_type(b: &IrRequestBody) -> Option<IrTypeExpr> {
467    b.content
468        .get("application/json")
469        .or_else(|| b.content.values().next())
470        .cloned()
471}
472
473pub fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
474    r.content
475        .get("application/json")
476        .or_else(|| r.content.values().next())
477        .cloned()
478}
479
480pub fn render_to_string(var: &str, type_expr: &IrTypeExpr, _is_optional: bool) -> String {
481    match type_expr {
482        IrTypeExpr::Array(_) => {
483            format!("{var}.iter().map(ToString::to_string).collect::<Vec<_>>().join(\",\")")
484        }
485        _ => format!("{var}.to_string()"),
486    }
487}
488
489pub fn is_copy_type(ty: &str) -> bool {
490    matches!(
491        ty,
492        "bool" | "i32" | "i64" | "f32" | "f64" | "u8" | "u16" | "u32" | "u64"
493    ) || ty.starts_with("Option<")
494        && is_copy_type(
495            ty.strip_prefix("Option<")
496                .unwrap()
497                .strip_suffix('>')
498                .unwrap_or(""),
499        )
500}
501
502// ---------------------------------------------------------------------------
503// Shared body-emission helpers (used by all Rust backends)
504// ---------------------------------------------------------------------------
505
506/// Emit `let mut result = FooResponse { status_code, field1: None, ... };`
507pub fn emit_result_init(
508    b: &mut CodeBlockBuilder,
509    response_type: &str,
510    typed_responses: &[TypedResponse],
511) {
512    let mut fields = vec!["status_code".to_string()];
513    let mut seen: HashSet<String> = HashSet::new();
514    for tr in typed_responses {
515        if seen.insert(tr.field_name.clone()) {
516            fields.push(format!("{}: None", tr.field_name));
517        }
518    }
519    b.add(
520        &format!(
521            "let mut result = {response_type} {{ {} }};\n",
522            fields.join(", ")
523        ),
524        (),
525    );
526}
527
528/// Emit `match status_code { ... }` dispatching deserialized bodies into result fields.
529pub fn emit_response_match(
530    b: &mut CodeBlockBuilder,
531    typed_responses: &[TypedResponse],
532    deser_expr: &str,
533) {
534    b.begin_control_flow("match status_code", ());
535    let mut seen: HashSet<String> = HashSet::new();
536    for tr in typed_responses {
537        if !seen.insert(format!("{}-{}", tr.status, tr.field_name)) {
538            continue;
539        }
540        let status_pattern = status_match_pattern(&tr.status);
541        b.begin_control_flow(&format!("{status_pattern} =>"), ());
542        b.add(
543            &format!(
544                "result.{} = Some({deser_expr}.map_err(Error::Deserialize)?);\n",
545                tr.field_name
546            ),
547            (),
548        );
549        b.end_control_flow();
550    }
551    if !typed_responses.iter().any(|tr| tr.status == "default") {
552        b.add("_ => {}\n", ());
553    }
554    b.end_control_flow();
555}