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