Skip to main content

openapi_nexus/generators/python/httpx/
emit_api.rs

1//! API emission for IR operations (Python API classes).
2//!
3//! Uses sigil-stitch high-level APIs (TypeSpec, FunSpec, TypeName, FileSpec) for
4//! structured code generation with automatic import tracking. Groups operations
5//! by tag, emits one `apis/{tag}_api.py` per tag.
6
7use std::collections::{BTreeMap, HashSet};
8
9use crate::codegen::traits::file_writer::FileInfo;
10use crate::ir::types::{
11    IrOperation, IrParameter, IrPrimitive, IrRequestBody, IrResponse, IrSpec, IrTypeExpr,
12    ParameterLocation,
13};
14use heck::{ToPascalCase, ToSnakeCase};
15use sigil_stitch::code_block::CodeBlock;
16use sigil_stitch::lang::python::Python;
17use sigil_stitch::prelude::*;
18
19use super::emit_models::{api_type_name, future_annotations_header, is_object_schema};
20
21/// Generate every API file from the IR.
22pub fn generate_api_files(ir: &IrSpec, header: &str) -> Result<Vec<FileInfo>, String> {
23    let by_tag = group_by_tag(&ir.operations);
24    let mut files = Vec::with_capacity(by_tag.len());
25    for (tag, ops) in &by_tag {
26        let stem = tag.to_snake_case();
27        let filename = format!("{stem}_api.py");
28        let body = emit_api_file(tag, ops, ir, header);
29        files.push(FileInfo::api(filename, body));
30    }
31    Ok(files)
32}
33
34fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
35    let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
36    for op in operations {
37        let tags: Vec<String> = if op.tags.is_empty() {
38            vec!["default".to_string()]
39        } else {
40            op.tags.clone()
41        };
42        for tag in tags {
43            out.entry(tag).or_default().push(op);
44        }
45    }
46    out
47}
48
49fn emit_api_file(tag: &str, ops: &[&IrOperation], ir: &IrSpec, header: &str) -> String {
50    let class_name = format!("{}Api", tag.to_pascal_case());
51    let plans: Vec<OpPlan> = ops.iter().map(|op| plan_operation(op)).collect();
52
53    let client_type = TypeName::importable("..runtime.client", "Client");
54    let error_type = TypeName::importable("..runtime.errors", "ApiError");
55
56    // __init__ method via FunSpec
57    let init_body = CodeBlock::of("self._client = client", ()).expect("static body");
58    let init = FunSpec::builder("__init__")
59        .add_param(ParameterSpec::of("self", TypeName::primitive("")))
60        .add_param(ParameterSpec::of("client", client_type))
61        .returns(TypeName::primitive("None"))
62        .body(init_body)
63        .build()
64        .expect("__init__ FunSpec builds");
65
66    let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).add_method(init);
67
68    for plan in &plans {
69        cls = cls.add_method(build_api_method(plan, ir, &error_type));
70    }
71
72    let file = FileSpec::builder_with(&format!("{}_api.py", tag.to_snake_case()), Python::new())
73        .header(future_annotations_header())
74        .add_type(cls.build().expect("API TypeSpec builds"))
75        .build()
76        .expect("API FileSpec builds");
77
78    let body = file.render(120).unwrap_or_default();
79    let mut content = String::with_capacity(header.len() + body.len());
80    content.push_str(header);
81    content.push_str(&body);
82    content
83}
84
85fn build_api_method(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> FunSpec {
86    let mut fun = FunSpec::builder(&plan.method_name);
87
88    // self (bare, no type annotation)
89    fun = fun.add_param(ParameterSpec::of("self", TypeName::primitive("")));
90
91    // Positional params (path params)
92    for p in &plan.path_params {
93        fun = fun.add_param(ParameterSpec::of(
94            &p.var_name,
95            api_type_name(&p.param.type_expr),
96        ));
97    }
98
99    // Keyword-only separator
100    let has_keyword_params =
101        !plan.query_params.is_empty() || !plan.header_params.is_empty() || plan.body.is_some();
102    if has_keyword_params {
103        fun = fun.add_param(ParameterSpec::of("*", TypeName::primitive("")));
104    }
105
106    // Required query/header params first
107    for p in plan.query_params.iter().chain(&plan.header_params) {
108        if p.param.required {
109            fun = fun.add_param(ParameterSpec::of(
110                &p.var_name,
111                api_type_name(&p.param.type_expr),
112            ));
113        }
114    }
115
116    // Body param
117    if let Some(b) = &plan.body {
118        let ty = api_type_name(&b.type_expr);
119        if b.required {
120            fun = fun.add_param(ParameterSpec::of(&b.var_name, ty));
121        } else {
122            fun = fun.add_param(
123                ParameterSpec::builder(&b.var_name, TypeName::optional(ty))
124                    .default_value(CodeBlock::of("None", ()).expect("None"))
125                    .build()
126                    .expect("optional body param"),
127            );
128        }
129    }
130
131    // Optional query/header params last
132    for p in plan.query_params.iter().chain(&plan.header_params) {
133        if !p.param.required {
134            fun = fun.add_param(
135                ParameterSpec::builder(
136                    &p.var_name,
137                    TypeName::optional(api_type_name(&p.param.type_expr)),
138                )
139                .default_value(CodeBlock::of("None", ()).expect("None"))
140                .build()
141                .expect("optional param"),
142            );
143        }
144    }
145
146    // Return type — auto-tracked via TypeName
147    let return_type = if plan.typed_responses.is_empty() {
148        TypeName::primitive("None")
149    } else {
150        api_type_name(&plan.typed_responses[0].type_expr)
151    };
152    fun = fun.returns(return_type);
153
154    // Docstring
155    if let Some(summary) = &plan.op.summary {
156        fun = fun.doc(&format!("{summary}."));
157    }
158
159    // Method body (imperative control flow, stays as CodeBlock)
160    fun = fun.body(build_method_body(plan, ir, error_type));
161
162    fun.build().expect("API method FunSpec builds")
163}
164
165fn build_method_body(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> CodeBlock {
166    let mut cb = CodeBlock::builder();
167
168    // Path interpolation
169    let path_expr = if plan.path_params.is_empty() {
170        format!("\"{}\"", plan.op.path)
171    } else {
172        let mut path_template = plan.op.path.clone();
173        for p in &plan.path_params {
174            let placeholder = format!("{{{}}}", p.param.name);
175            let replacement = format!("{{{}}}", p.var_name);
176            path_template = path_template.replace(&placeholder, &replacement);
177        }
178        format!("f\"{}\"", path_template)
179    };
180    cb.add_statement(&format!("path = {path_expr}"), ());
181
182    // Query params
183    let has_query = !plan.query_params.is_empty();
184    if has_query {
185        cb.add_statement("params: dict[str, str] = {}", ());
186        for p in &plan.query_params {
187            let stringify = render_stringify(&p.var_name, &p.param.type_expr);
188            if p.param.required {
189                cb.add_statement(&format!("params[\"{}\"] = {stringify}", p.param.name), ());
190            } else {
191                cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
192                cb.add_statement(&format!("params[\"{}\"] = {stringify}%<", p.param.name), ());
193            }
194        }
195    }
196
197    // Header params
198    let has_headers = !plan.header_params.is_empty();
199    if has_headers {
200        cb.add_statement("headers: dict[str, str] = {}", ());
201        for p in &plan.header_params {
202            let stringify = render_stringify(&p.var_name, &p.param.type_expr);
203            if p.param.required {
204                cb.add_statement(&format!("headers[\"{}\"] = {stringify}", p.param.name), ());
205            } else {
206                cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
207                cb.add_statement(
208                    &format!("headers[\"{}\"] = {stringify}%<", p.param.name),
209                    (),
210                );
211            }
212        }
213    }
214
215    // Body serialization
216    let body_expr = if let Some(b) = &plan.body {
217        if is_object_type(&b.type_expr, ir) {
218            if b.required {
219                format!("{}.to_dict()", b.var_name)
220            } else {
221                format!(
222                    "{}.to_dict() if {} is not None else None",
223                    b.var_name, b.var_name
224                )
225            }
226        } else if is_array_of_objects(&b.type_expr, ir) {
227            if b.required {
228                format!("[item.to_dict() for item in {}]", b.var_name)
229            } else {
230                format!(
231                    "[item.to_dict() for item in {}] if {} is not None else None",
232                    b.var_name, b.var_name
233                )
234            }
235        } else {
236            b.var_name.clone()
237        }
238    } else {
239        String::new()
240    };
241
242    // Request call
243    let mut request_args = vec![
244        format!("\"{}\"", plan.op.method.to_uppercase()),
245        "path".to_string(),
246    ];
247    if has_query {
248        request_args.push("params=params".to_string());
249    }
250    if plan.body.is_some() {
251        request_args.push(format!("json={body_expr}"));
252    }
253    if has_headers {
254        request_args.push("headers=headers".to_string());
255    }
256
257    cb.add_statement(
258        &format!(
259            "response = self._client.request({})",
260            request_args.join(", "),
261        ),
262        (),
263    );
264
265    // Error handling — %T for ApiError auto-import
266    cb.add_statement("if response.status_code >= 400:%>", ());
267    cb.add_statement(
268        "raise %T(response.status_code, response.reason_phrase, response.content)%<",
269        (error_type.clone(),),
270    );
271
272    // Response parsing
273    if !plan.typed_responses.is_empty() {
274        let tr = &plan.typed_responses[0];
275        let parse_expr = render_response_parse(&tr.type_expr, ir);
276        cb.add_statement(&format!("return {parse_expr}"), ());
277    } else {
278        cb.add_statement("return None", ());
279    }
280
281    cb.build().expect("API method body builds")
282}
283
284fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
285    match type_expr {
286        IrTypeExpr::Primitive(
287            IrPrimitive::String
288            | IrPrimitive::Date
289            | IrPrimitive::DateTime
290            | IrPrimitive::Uuid
291            | IrPrimitive::StringWithFormat(_),
292        )
293        | IrTypeExpr::StringLiteral(_)
294        | IrTypeExpr::StringEnum(_)
295        | IrTypeExpr::Named(_) => format!("str({var})"),
296        IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
297        IrTypeExpr::Primitive(
298            IrPrimitive::Integer
299            | IrPrimitive::IntegerWithFormat(_)
300            | IrPrimitive::Number
301            | IrPrimitive::NumberWithFormat(_),
302        ) => format!("str({var})"),
303        IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
304        IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
305        _ => format!("str({var})"),
306    }
307}
308
309fn render_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
310    match type_expr {
311        IrTypeExpr::Named(name) => {
312            let py_name = name.to_pascal_case();
313            if is_object_schema(name, ir) {
314                format!("{py_name}.from_dict(response.json())")
315            } else {
316                "response.json()  # type: ignore[return-value]".to_string()
317            }
318        }
319        IrTypeExpr::Array(inner) => {
320            if let IrTypeExpr::Named(name) = inner.as_ref()
321                && is_object_schema(name, ir)
322            {
323                let py_name = name.to_pascal_case();
324                return format!("[{py_name}.from_dict(item) for item in response.json()]");
325            }
326            "response.json()  # type: ignore[return-value]".to_string()
327        }
328        IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
329            "response.text".to_string()
330        }
331        _ => "response.json()  # type: ignore[return-value]".to_string(),
332    }
333}
334
335fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
336    if let IrTypeExpr::Named(name) = type_expr {
337        return is_object_schema(name, ir);
338    }
339    false
340}
341
342fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
343    if let IrTypeExpr::Array(inner) = type_expr
344        && let IrTypeExpr::Named(name) = inner.as_ref()
345    {
346        return is_object_schema(name, ir);
347    }
348    false
349}
350
351// ---------------------------------------------------------------------------
352// Planning
353// ---------------------------------------------------------------------------
354
355struct OpPlan<'a> {
356    op: &'a IrOperation,
357    method_name: String,
358    path_params: Vec<ParamBinding<'a>>,
359    query_params: Vec<ParamBinding<'a>>,
360    header_params: Vec<ParamBinding<'a>>,
361    body: Option<BodyBinding>,
362    typed_responses: Vec<TypedResponse>,
363}
364
365struct ParamBinding<'a> {
366    param: &'a IrParameter,
367    var_name: String,
368}
369
370struct BodyBinding {
371    var_name: String,
372    type_expr: IrTypeExpr,
373    required: bool,
374}
375
376struct TypedResponse {
377    type_expr: IrTypeExpr,
378}
379
380fn plan_operation<'a>(op: &'a IrOperation) -> OpPlan<'a> {
381    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
382    let method_name = op_id.to_snake_case();
383
384    let mut used_names: HashSet<String> = HashSet::new();
385    used_names.insert("self".to_string());
386
387    let mut path_params = Vec::new();
388    let mut query_params = Vec::new();
389    let mut header_params = Vec::new();
390
391    for p in &op.parameters {
392        let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
393        let binding = ParamBinding { param: p, var_name };
394        match p.location {
395            ParameterLocation::Path => path_params.push(binding),
396            ParameterLocation::Query => query_params.push(binding),
397            ParameterLocation::Header => header_params.push(binding),
398            ParameterLocation::Cookie => header_params.push(binding),
399        }
400    }
401
402    let body = op
403        .request_body
404        .as_ref()
405        .and_then(|b| plan_body(b, &mut used_names));
406
407    let typed_responses = op.responses.iter().filter_map(plan_response).collect();
408
409    OpPlan {
410        op,
411        method_name,
412        path_params,
413        query_params,
414        header_params,
415        body,
416        typed_responses,
417    }
418}
419
420fn plan_body(b: &IrRequestBody, used_names: &mut HashSet<String>) -> Option<BodyBinding> {
421    let t = pick_body_type(b)?;
422    let var_name = unique_name("body", used_names);
423    Some(BodyBinding {
424        var_name,
425        type_expr: t,
426        required: b.required,
427    })
428}
429
430fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
431    let t = pick_response_type(r)?;
432    Some(TypedResponse { type_expr: t })
433}
434
435fn pick_body_type(body: &IrRequestBody) -> Option<IrTypeExpr> {
436    body.content
437        .get("application/json")
438        .cloned()
439        .or_else(|| body.content.values().next().cloned())
440}
441
442fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
443    r.content
444        .get("application/json")
445        .cloned()
446        .or_else(|| r.content.values().next().cloned())
447}
448
449fn python_param_name(name: &str) -> String {
450    let snake = name.to_snake_case();
451    if snake.is_empty() {
452        return "param".to_string();
453    }
454    match snake.as_str() {
455        "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
456        | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
457        | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
458        | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
459            format!("{snake}_")
460        }
461        _ => snake,
462    }
463}
464
465fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
466    if used.insert(desired.to_string()) {
467        return desired.to_string();
468    }
469    for i in 2..=u32::MAX {
470        let candidate = format!("{desired}{i}");
471        if used.insert(candidate.clone()) {
472            return candidate;
473        }
474    }
475    unreachable!("name collision space exhausted")
476}
477
478fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
479    if !op_id.is_empty() {
480        return op_id.to_string();
481    }
482    let path_part: String = path
483        .chars()
484        .map(|c| if c.is_alphanumeric() { c } else { '_' })
485        .collect();
486    format!("{method}_{path_part}")
487}