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, ir, 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    ir: &IrSpec,
107    config: &RustBackendConfig,
108    response_extra_derives: Option<&ExtraDeriveConfig>,
109    body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
110) -> String {
111    let struct_name = format!("{}Api", tag.to_pascal_case());
112    let plans: Vec<OpPlan> = ops.iter().map(|op| plan_operation(op, ir)).collect();
113
114    let stem = tag.to_snake_case();
115    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
116
117    // Use imports
118    fsb = fsb.add_import(ImportSpec::named("crate::runtime::client", "Client"));
119    fsb = fsb.add_import(ImportSpec::named("crate::runtime::error", "Error"));
120
121    // Struct generics (e.g., `<'a, R: aioduct::Runtime>`)
122    let (struct_gen, impl_gen, type_args, client_field_args) = match &config.struct_generics {
123        Some(g) => {
124            let client_args = config.client_type_args.as_deref().unwrap_or("");
125            let param_name = g.split(':').next().unwrap_or(g).trim();
126            (
127                format!("<'a, {g}>"),
128                format!("<'a, {g}>"),
129                format!("<'a, {param_name}>"),
130                client_args.to_string(),
131            )
132        }
133        None => (
134            "<'a>".to_string(),
135            "<'a>".to_string(),
136            "<'a>".to_string(),
137            String::new(),
138        ),
139    };
140
141    // Build struct + impl as a CodeBlock (lifetimes/generics don't fit TypeSpec)
142    let mut body = CodeBlock::builder();
143
144    // Struct doc + declaration
145    body.add(&format!("/// API operations under the \"{tag}\" tag."), ());
146    body.add_line();
147    body.add(&format!("pub struct {struct_name}{struct_gen}"), ());
148    body.begin_control_flow("", ());
149    body.add(&format!("client: &'a Client{client_field_args},\n"), ());
150    body.end_control_flow();
151    body.add_line();
152
153    // Impl block
154    body.add(&format!("impl{impl_gen} {struct_name}{type_args}"), ());
155    body.begin_control_flow("", ());
156
157    // Constructor
158    body.add(
159        &format!("/// Create a new `{struct_name}` bound to the given client."),
160        (),
161    );
162    body.add_line();
163    body.add(
164        &format!("pub fn new(client: &'a Client{client_field_args}) -> Self"),
165        (),
166    );
167    body.begin_control_flow("", ());
168    body.add("Self", ());
169    body.begin_control_flow("", ());
170    body.add("client,\n", ());
171    body.end_control_flow();
172    body.end_control_flow();
173
174    // Methods
175    for plan in &plans {
176        body.add_line();
177        body.add_code(emit_operation(plan, config, body_emitter));
178    }
179
180    body.end_control_flow(); // close impl
181
182    fsb = fsb.add_code(body.build().expect("body builds"));
183
184    // Response structs -- add as TypeSpec members
185    for plan in &plans {
186        fsb = fsb.add_type(emit_response_struct(plan, response_extra_derives));
187    }
188
189    let file = fsb.build().expect("FileSpec builds");
190    file.render(100).expect("FileSpec renders")
191}
192
193// ---------------------------------------------------------------------------
194// Operation planning (public for backend use)
195// ---------------------------------------------------------------------------
196
197pub struct OpPlan<'a> {
198    pub op: &'a IrOperation,
199    pub method_name: String,
200    pub response_type: String,
201    pub path_params: Vec<ParamBinding<'a>>,
202    pub query_params: Vec<ParamBinding<'a>>,
203    pub header_params: Vec<ParamBinding<'a>>,
204    pub body: Option<BodyBinding>,
205    pub typed_responses: Vec<TypedResponse>,
206}
207
208pub struct ParamBinding<'a> {
209    pub param: &'a IrParameter,
210    pub var_name: String,
211    pub rust_type: String,
212    pub is_optional: bool,
213}
214
215pub struct BodyBinding {
216    pub var_name: String,
217    pub rust_type: String,
218}
219
220pub struct TypedResponse {
221    pub status: String,
222    pub field_name: String,
223    pub rust_type: String,
224}
225
226pub fn plan_operation<'a>(op: &'a IrOperation, ir: &'a IrSpec) -> OpPlan<'a> {
227    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
228    let method_name = op_id.to_snake_case();
229    let response_type = format!("{}Response", op_id.to_pascal_case());
230
231    let mut used_names: HashSet<String> = HashSet::new();
232    used_names.insert("self".to_string());
233
234    let mut path_params = Vec::new();
235    let mut query_params = Vec::new();
236    let mut header_params = Vec::new();
237    for p in &op.parameters {
238        let var_name = unique_name(&p.name.to_snake_case(), &mut used_names);
239        let (rust_type, is_optional) = param_rust_type(p, ir);
240        let binding = ParamBinding {
241            param: p,
242            var_name,
243            rust_type,
244            is_optional,
245        };
246        match p.location {
247            ParameterLocation::Path => path_params.push(binding),
248            ParameterLocation::Query => query_params.push(binding),
249            ParameterLocation::Header => header_params.push(binding),
250            ParameterLocation::Cookie => header_params.push(binding),
251        }
252    }
253
254    let body = op
255        .request_body
256        .as_ref()
257        .and_then(|b| plan_body(b, &mut used_names, ir));
258
259    let typed_responses = op
260        .responses
261        .iter()
262        .filter_map(|r| plan_response(r, ir))
263        .collect();
264
265    OpPlan {
266        op,
267        method_name,
268        response_type,
269        path_params,
270        query_params,
271        header_params,
272        body,
273        typed_responses,
274    }
275}
276
277pub fn plan_body(
278    b: &IrRequestBody,
279    used_names: &mut HashSet<String>,
280    ir: &IrSpec,
281) -> Option<BodyBinding> {
282    let t = pick_body_type(b)?;
283    let rust_type = rust_type_str_qualified(&t, ir);
284    let var_name = unique_name("body", used_names);
285    Some(BodyBinding {
286        var_name,
287        rust_type,
288    })
289}
290
291pub fn plan_response(r: &IrResponse, ir: &IrSpec) -> Option<TypedResponse> {
292    let t = pick_response_type(r)?;
293    let rust_type = rust_type_str_qualified(&t, ir);
294    Some(TypedResponse {
295        status: r.status.clone(),
296        field_name: response_field_name(&r.status),
297        rust_type,
298    })
299}
300
301pub fn param_rust_type(p: &IrParameter, ir: &IrSpec) -> (String, bool) {
302    let base = rust_type_str_qualified(&p.type_expr, ir);
303    if p.required {
304        (base, false)
305    } else {
306        (format!("Option<{base}>"), true)
307    }
308}
309
310pub fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
311    if used.insert(desired.to_string()) {
312        return desired.to_string();
313    }
314    for i in 2..=u32::MAX {
315        let candidate = format!("{desired}_{i}");
316        if used.insert(candidate.clone()) {
317            return candidate;
318        }
319    }
320    unreachable!("name collision space exhausted")
321}
322
323// ---------------------------------------------------------------------------
324// Per-operation emission
325// ---------------------------------------------------------------------------
326
327fn emit_operation(
328    plan: &OpPlan<'_>,
329    config: &RustBackendConfig,
330    body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
331) -> CodeBlock {
332    let OpPlan {
333        op,
334        method_name,
335        response_type,
336        ..
337    } = plan;
338
339    let mut b = CodeBlock::builder();
340
341    // Doc comment
342    if let Some(summary) = &op.summary {
343        for line in summary.lines() {
344            if line.is_empty() {
345                b.add("///\n", ());
346            } else {
347                b.add(&format!("/// {line}\n"), ());
348            }
349        }
350    } else {
351        b.add(
352            &format!("/// {} {}\n", op.method.to_uppercase(), op.path),
353            (),
354        );
355    }
356    if let Some(desc) = &op.description {
357        b.add("///\n", ());
358        for line in desc.lines() {
359            if line.is_empty() {
360                b.add("///\n", ());
361            } else {
362                b.add(&format!("/// {line}\n"), ());
363            }
364        }
365    }
366
367    // Method signature
368    let mut params = Vec::new();
369    params.push("&self".to_string());
370    for p in plan
371        .path_params
372        .iter()
373        .chain(&plan.query_params)
374        .chain(&plan.header_params)
375    {
376        let ty = if is_copy_type(&p.rust_type) {
377            p.rust_type.clone()
378        } else if p.rust_type == "String" {
379            "&str".to_string()
380        } else if let Some(inner) = p
381            .rust_type
382            .strip_prefix("Vec<")
383            .and_then(|s| s.strip_suffix('>'))
384        {
385            format!("&[{inner}]")
386        } else {
387            format!("&{}", p.rust_type)
388        };
389        params.push(format!("{}: {ty}", p.var_name));
390    }
391    if let Some(body) = &plan.body {
392        params.push(format!("{}: &{}", body.var_name, body.rust_type));
393    }
394
395    let async_kw = if config.is_async { "async " } else { "" };
396    b.add(
397        &format!(
398            "pub {async_kw}fn {method_name}(\n    {},\n) -> Result<{response_type}, Error>",
399            params.join(",\n    "),
400        ),
401        (),
402    );
403    b.begin_control_flow("", ());
404
405    // Method body from backend
406    b.add_code(body_emitter(plan));
407
408    b.end_control_flow();
409    b.build().unwrap()
410}
411
412pub fn emit_response_struct(plan: &OpPlan<'_>, extra: Option<&ExtraDeriveConfig>) -> TypeSpec {
413    let mut tb = TypeSpec::builder(&plan.response_type, TypeKind::Struct);
414    tb = tb.visibility(Visibility::Public);
415    tb = tb.doc(&format!("Response from `{}`.", plan.method_name));
416
417    let mut ann = AnnotationSpec::new("derive").args(["Debug"]);
418    if let Some(cfg) = extra {
419        ann = ann.args(cfg.derives.iter().map(|s| s.as_str()));
420    }
421    tb = tb.annotate(ann);
422
423    // status_code field
424    {
425        let fb = FieldSpec::builder("status_code", TypeName::primitive("u16"));
426        let fb = fb.visibility(Visibility::Public);
427        tb = tb.add_field(fb.build().expect("FieldSpec builds"));
428    }
429
430    // typed response fields
431    let mut seen: HashSet<String> = HashSet::new();
432    for tr in &plan.typed_responses {
433        if !seen.insert(tr.field_name.clone()) {
434            continue;
435        }
436        let fb = FieldSpec::builder(
437            &tr.field_name,
438            TypeName::raw(&format!("Option<{}>", tr.rust_type)),
439        );
440        let fb = fb.visibility(Visibility::Public);
441        tb = tb.add_field(fb.build().expect("FieldSpec builds"));
442    }
443
444    tb.build().expect("TypeSpec builds")
445}
446
447// ---------------------------------------------------------------------------
448// Helpers (public for backend use)
449// ---------------------------------------------------------------------------
450
451pub fn sanitize_operation_id(id: &str, method: &str, path: &str) -> String {
452    if !id.is_empty() {
453        return id.to_string();
454    }
455    format!(
456        "{}_{}",
457        method,
458        path.replace('/', "_").replace(['{', '}'], "")
459    )
460}
461
462pub fn response_field_name(status: &str) -> String {
463    match status {
464        "200" => "data".to_string(),
465        "201" => "created".to_string(),
466        "204" => "no_content".to_string(),
467        "default" => "error_body".to_string(),
468        s if s.ends_with("XX") => {
469            let prefix = &s[..s.len() - 2];
470            format!("status_{prefix}xx")
471        }
472        s => format!("status_{s}"),
473    }
474}
475
476/// Convert an OpenAPI status code string to a Rust match pattern.
477pub fn status_match_pattern(status: &str) -> String {
478    match status {
479        "default" => "_".to_string(),
480        s if s.ends_with("XX") => {
481            let prefix: u16 = s[..s.len() - 2].parse().unwrap_or(0);
482            let lo = prefix * 100;
483            let hi = lo + 99;
484            format!("{lo}..={hi}")
485        }
486        s => s.to_string(),
487    }
488}
489
490pub fn pick_body_type(b: &IrRequestBody) -> Option<IrTypeExpr> {
491    b.content
492        .get("application/json")
493        .or_else(|| b.content.values().next())
494        .cloned()
495}
496
497pub fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
498    r.content
499        .get("application/json")
500        .or_else(|| r.content.values().next())
501        .cloned()
502}
503
504pub fn render_to_string(var: &str, type_expr: &IrTypeExpr, _is_optional: bool) -> String {
505    match type_expr {
506        IrTypeExpr::Array(_) => {
507            format!("{var}.iter().map(ToString::to_string).collect::<Vec<_>>().join(\",\")")
508        }
509        _ => format!("{var}.to_string()"),
510    }
511}
512
513pub fn is_copy_type(ty: &str) -> bool {
514    matches!(
515        ty,
516        "bool" | "i32" | "i64" | "f32" | "f64" | "u8" | "u16" | "u32" | "u64"
517    ) || ty.starts_with("Option<")
518        && is_copy_type(
519            ty.strip_prefix("Option<")
520                .unwrap()
521                .strip_suffix('>')
522                .unwrap_or(""),
523        )
524}
525
526// ---------------------------------------------------------------------------
527// Shared body-emission helpers (used by all Rust backends)
528// ---------------------------------------------------------------------------
529
530/// Emit `let mut result = FooResponse { status_code, field1: None, ... };`
531pub fn emit_result_init(
532    b: &mut CodeBlockBuilder,
533    response_type: &str,
534    typed_responses: &[TypedResponse],
535) {
536    let mut fields = vec!["status_code".to_string()];
537    let mut seen: HashSet<String> = HashSet::new();
538    for tr in typed_responses {
539        if seen.insert(tr.field_name.clone()) {
540            fields.push(format!("{}: None", tr.field_name));
541        }
542    }
543    b.add(
544        &format!(
545            "let mut result = {response_type} {{ {} }};\n",
546            fields.join(", ")
547        ),
548        (),
549    );
550}
551
552/// Emit `match status_code { ... }` dispatching deserialized bodies into result fields.
553pub fn emit_response_match(
554    b: &mut CodeBlockBuilder,
555    typed_responses: &[TypedResponse],
556    deser_expr: &str,
557) {
558    b.begin_control_flow("match status_code", ());
559    let mut seen: HashSet<String> = HashSet::new();
560    for tr in typed_responses {
561        if !seen.insert(format!("{}-{}", tr.status, tr.field_name)) {
562            continue;
563        }
564        let status_pattern = status_match_pattern(&tr.status);
565        b.begin_control_flow(&format!("{status_pattern} =>"), ());
566        b.add(
567            &format!(
568                "result.{} = Some({deser_expr}.map_err(Error::Deserialize)?);\n",
569                tr.field_name
570            ),
571            (),
572        );
573        b.end_control_flow();
574    }
575    if !typed_responses.iter().any(|tr| tr.status == "default") {
576        b.add("_ => {}\n", ());
577    }
578    b.end_control_flow();
579}