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::generators::multipart::{MultipartValueEncoding, multipart_parts_for_request_body};
11use crate::generators::request_inputs::{RequestInputPlan, request_input_for_operation};
12use crate::generators::response_names::response_entry_name as response_variant_name;
13use crate::ir::types::{
14    IrOperation, IrParameter, IrPrimitive, IrRequestBody, IrResponse, IrSpec, IrTypeExpr,
15    ParameterLocation,
16};
17use heck::{ToPascalCase, ToSnakeCase};
18use sigil_stitch::code_block::CodeBlock;
19use sigil_stitch::lang::python::Python;
20use sigil_stitch::prelude::*;
21
22use super::emit_models::{
23    api_type_name, future_annotations_header, is_object_schema, python_field_name,
24};
25
26/// Generate every API file from the IR.
27pub fn generate_api_files(
28    ir: &IrSpec,
29    header: &str,
30    request_inputs: &RequestInputPlan,
31) -> Result<Vec<FileInfo>, String> {
32    let by_tag = group_by_tag(&ir.operations);
33    let mut files = Vec::with_capacity(by_tag.len());
34    for (tag, ops) in &by_tag {
35        let stem = tag.to_snake_case();
36        let filename = format!("{stem}_api.py");
37        let body = emit_api_file(tag, ops, ir, header, request_inputs);
38        files.push(FileInfo::api(filename, body));
39    }
40    Ok(files)
41}
42
43fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
44    let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
45    for op in operations {
46        let tags: Vec<String> = if op.tags.is_empty() {
47            vec!["default".to_string()]
48        } else {
49            op.tags.clone()
50        };
51        for tag in tags {
52            out.entry(tag).or_default().push(op);
53        }
54    }
55    out
56}
57
58fn emit_api_file(
59    tag: &str,
60    ops: &[&IrOperation],
61    ir: &IrSpec,
62    header: &str,
63    request_inputs: &RequestInputPlan,
64) -> String {
65    let class_name = format!("{}Api", tag.to_pascal_case());
66    let plans: Vec<OpPlan> = ops
67        .iter()
68        .map(|op| plan_operation(op, ir, request_inputs))
69        .collect();
70
71    let client_type = TypeName::importable("..runtime.client", "Client");
72    let error_type = TypeName::importable("..runtime.errors", "ApiError");
73
74    // __init__ method via FunSpec
75    let init_body = CodeBlock::of("self._client = client", ()).expect("static body");
76    let init = FunSpec::builder("__init__")
77        .add_param(ParameterSpec::of("self", TypeName::primitive("")))
78        .add_param(ParameterSpec::of("client", client_type))
79        .returns(TypeName::primitive("None"))
80        .body(init_body)
81        .build()
82        .expect("__init__ FunSpec builds");
83
84    let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).add_method(init);
85
86    for plan in &plans {
87        cls = cls.add_method(build_api_method(plan, ir, &error_type));
88    }
89
90    let mut fb = FileSpec::builder_with(&format!("{}_api.py", tag.to_snake_case()), Python::new())
91        .header(future_annotations_header())
92        .add_code(build_error_classes_block(&plans, ir))
93        .add_type(cls.build().expect("API TypeSpec builds"));
94    if plans
95        .iter()
96        .flat_map(|plan| &plan.error_responses)
97        .any(|response| response.decoding == Some(ResponseDecoding::Json))
98    {
99        fb = fb.add_import(ImportSpec::side_effect("json"));
100    }
101    if plans.iter().any(|plan| {
102        plan.body.as_ref().is_some_and(|body| {
103            body.multipart_parts.as_ref().is_some_and(|parts| {
104                parts
105                    .iter()
106                    .any(|part| part.value_encoding == MultipartValueEncoding::Json)
107            })
108        })
109    }) {
110        fb = fb.add_import(ImportSpec::side_effect("json"));
111    }
112    let file = fb.build().expect("API FileSpec builds");
113
114    let body = file.render(120).unwrap_or_default();
115    let mut content = String::with_capacity(header.len() + body.len());
116    content.push_str(header);
117    content.push_str(&body);
118    content
119}
120
121fn build_api_method(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> FunSpec {
122    let mut fun = FunSpec::builder(&plan.method_name);
123
124    // self (bare, no type annotation)
125    fun = fun.add_param(ParameterSpec::of("self", TypeName::primitive("")));
126
127    // Positional params (path params)
128    for p in &plan.path_params {
129        fun = fun.add_param(ParameterSpec::of(
130            &p.var_name,
131            api_type_name(&p.param.type_expr),
132        ));
133    }
134
135    // Keyword-only separator
136    let has_keyword_params =
137        !plan.query_params.is_empty() || !plan.header_params.is_empty() || plan.body.is_some();
138    if has_keyword_params {
139        fun = fun.add_param(ParameterSpec::of("*", TypeName::primitive("")));
140    }
141
142    // Required query/header params first
143    for p in plan.query_params.iter().chain(&plan.header_params) {
144        if p.param.required {
145            fun = fun.add_param(ParameterSpec::of(
146                &p.var_name,
147                api_type_name(&p.param.type_expr),
148            ));
149        }
150    }
151
152    // Body param
153    if let Some(b) = &plan.body {
154        let ty = api_type_name(&b.type_expr);
155        if b.required {
156            fun = fun.add_param(ParameterSpec::of(&b.var_name, ty));
157        } else {
158            fun = fun.add_param(
159                ParameterSpec::builder(&b.var_name, TypeName::optional(ty))
160                    .default_value(CodeBlock::of("None", ()).expect("None"))
161                    .build()
162                    .expect("optional body param"),
163            );
164        }
165    }
166
167    // Optional query/header params last
168    for p in plan.query_params.iter().chain(&plan.header_params) {
169        if !p.param.required {
170            let param_ty = api_type_name(&p.param.type_expr);
171            let param_ty = if is_already_optional(&p.param.type_expr) {
172                param_ty
173            } else {
174                TypeName::optional(param_ty)
175            };
176            fun = fun.add_param(
177                ParameterSpec::builder(&p.var_name, param_ty)
178                    .default_value(CodeBlock::of("None", ()).expect("None"))
179                    .build()
180                    .expect("optional param"),
181            );
182        }
183    }
184
185    // Return type — auto-tracked via TypeName
186    let return_type = if plan.typed_responses.is_empty() {
187        TypeName::primitive("None")
188    } else {
189        response_type_name(&plan.typed_responses[0])
190    };
191    fun = fun.returns(return_type);
192
193    // Docstring
194    if let Some(summary) = &plan.op.summary {
195        fun = fun.doc(&format!("{summary}."));
196    }
197
198    // Method body (imperative control flow, stays as CodeBlock)
199    fun = fun.body(build_method_body(plan, ir, error_type));
200
201    fun.build().expect("API method FunSpec builds")
202}
203
204fn build_method_body(plan: &OpPlan<'_>, ir: &IrSpec, _error_type: &TypeName) -> CodeBlock {
205    let mut cb = CodeBlock::builder();
206
207    // Path interpolation
208    if plan.path_params.is_empty() {
209        cb.add_statement(&format!("path = \"{}\"", plan.op.path), ());
210    } else {
211        let mut path_template = plan.op.path.clone();
212        for p in &plan.path_params {
213            let placeholder = format!("{{{}}}", p.param.name);
214            let replacement = format!("{{{}}}", p.var_name);
215            path_template = path_template.replace(&placeholder, &replacement);
216        }
217        cb.add_statement("path = %V", VerbatimStrArg(path_template));
218    }
219
220    // Query params
221    let has_query = !plan.query_params.is_empty();
222    if has_query {
223        cb.add_statement("params: dict[str, str] = {}", ());
224        for p in &plan.query_params {
225            let stringify = render_stringify(&p.var_name, &p.param.type_expr);
226            if p.param.required {
227                cb.add_statement(&format!("params[\"{}\"] = {stringify}", p.param.name), ());
228            } else {
229                cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
230                cb.add_statement(&format!("params[\"{}\"] = {stringify}%<", p.param.name), ());
231            }
232        }
233    }
234
235    // Header params
236    let body_content_type = plan.body.as_ref().and_then(|body| {
237        let base = media_type_base(&body.media_type);
238        if base != "multipart/form-data" {
239            Some(body.media_type.as_str())
240        } else {
241            None
242        }
243    });
244    let has_headers = !plan.header_params.is_empty() || body_content_type.is_some();
245    if has_headers {
246        cb.add_statement("headers: dict[str, str] = {}", ());
247        if let Some(media_type) = body_content_type {
248            cb.add_statement(&format!("headers[\"Content-Type\"] = \"{media_type}\""), ());
249        }
250        for p in &plan.header_params {
251            let stringify = render_stringify(&p.var_name, &p.param.type_expr);
252            if p.param.required {
253                cb.add_statement(&format!("headers[\"{}\"] = {stringify}", p.param.name), ());
254            } else {
255                cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
256                cb.add_statement(
257                    &format!("headers[\"{}\"] = {stringify}%<", p.param.name),
258                    (),
259                );
260            }
261        }
262    }
263
264    // Body serialization
265    let body_expr = if let Some(b) = &plan.body {
266        if is_object_type(&b.type_expr, ir) {
267            if b.required {
268                format!("{}.to_dict()", b.var_name)
269            } else {
270                format!(
271                    "{}.to_dict() if {} is not None else None",
272                    b.var_name, b.var_name
273                )
274            }
275        } else if is_array_of_objects(&b.type_expr, ir) {
276            if b.required {
277                format!("[item.to_dict() for item in {}]", b.var_name)
278            } else {
279                format!(
280                    "[item.to_dict() for item in {}] if {} is not None else None",
281                    b.var_name, b.var_name
282                )
283            }
284        } else {
285            b.var_name.clone()
286        }
287    } else {
288        String::new()
289    };
290
291    // Request call
292    let mut request_args = vec![
293        format!("\"{}\"", plan.op.method.to_uppercase()),
294        "path".to_string(),
295    ];
296    if has_query {
297        request_args.push("params=params".to_string());
298    }
299    if let Some(body) = &plan.body {
300        if media_type_base(&body.media_type) == "multipart/form-data" {
301            if let Some(parts) = &body.multipart_parts {
302                emit_multipart_data(&mut cb, body, parts, ir);
303                request_args.push("files=files if files else None".to_string());
304            } else {
305                cb.add_statement(
306                    "raise ValueError(\"unsupported multipart request body: schema must be object-shaped\")",
307                    (),
308                );
309            }
310        } else {
311            match body.encoding {
312                BodyEncoding::Json => request_args.push(format!("json={body_expr}")),
313                BodyEncoding::FormUrlEncoded => request_args.push(format!("data={body_expr}")),
314                BodyEncoding::TextPlain | BodyEncoding::OctetStream => {
315                    request_args.push(format!("content={body_expr}"));
316                }
317                BodyEncoding::Xml | BodyEncoding::Other => {
318                    if body.required {
319                        cb.add_statement(
320                            &format!(
321                                "raise ValueError(\"unsupported request body media type: {}\")",
322                                body.media_type
323                            ),
324                            (),
325                        );
326                    } else {
327                        cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
328                        cb.add_statement(
329                            &format!(
330                                "raise ValueError(\"unsupported request body media type: {}\")%<",
331                                body.media_type
332                            ),
333                            (),
334                        );
335                    }
336                }
337                BodyEncoding::Multipart => unreachable!("multipart handled separately"),
338            }
339        }
340    }
341    if has_headers {
342        request_args.push("headers=headers".to_string());
343    }
344
345    cb.add_code(
346        sigil_quote!(Python {
347            response = self._client.request($for(arg in &request_args; separator = ", ") { $L(arg.as_str()) })
348        })
349        .expect("request call"),
350    );
351
352    // Error handling
353    cb.add_code(emit_error_raise(plan, "response.reason_phrase"));
354
355    // Response parsing
356    if !plan.typed_responses.is_empty() {
357        let tr = &plan.typed_responses[0];
358        let parse_expr = render_response_parse(tr, ir);
359        cb.add_statement(&format!("return {parse_expr}"), ());
360    } else {
361        cb.add_statement("return None", ());
362    }
363
364    cb.build().expect("API method body builds")
365}
366
367fn emit_multipart_data(
368    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
369    body: &BodyBinding,
370    parts: &[MultipartPart],
371    ir: &IrSpec,
372) {
373    cb.add_statement("files: dict[str, object] = {}", ());
374    if !body.required {
375        cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
376    }
377    for part in parts {
378        let access = format!("{}.{}", body.var_name, part.field_name);
379        if part.required {
380            emit_required_multipart_part(cb, part, &access, ir);
381        } else {
382            cb.add_statement(&format!("if {access} is not None:%>"), ());
383            emit_required_multipart_part(cb, part, &access, ir);
384            cb.add_statement("%<", ());
385        }
386    }
387    if !body.required {
388        cb.add_statement("%<", ());
389    }
390}
391
392fn emit_required_multipart_part(
393    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
394    part: &MultipartPart,
395    access: &str,
396    ir: &IrSpec,
397) {
398    cb.add_code(multipart_part_assignment(part, access, ir));
399}
400
401fn multipart_part_assignment(part: &MultipartPart, access: &str, ir: &IrSpec) -> CodeBlock {
402    let binary_stmt = format!(
403        "files[\"{}\"] = ({}.filename_or_default(\"{}\"), {}.data, \"{}\")",
404        part.wire_name, access, part.wire_name, access, part.content_type
405    );
406    let json_value = render_multipart_json_value(access, &part.type_expr, ir);
407    let json_stmt = format!(
408        "files[\"{}\"] = (None, json.dumps({json_value}), \"{}\")",
409        part.wire_name, part.content_type
410    );
411    let unsupported_stmt = "raise ValueError(\"unsupported multipart part content type\")";
412    let scalar_stmt = format!(
413        "files[\"{}\"] = (None, str({access}), \"{}\")",
414        part.wire_name, part.content_type
415    );
416
417    sigil_quote!(Python {
418        $if(part.is_binary) {
419            $L(binary_stmt.as_str())
420        } $else_if(part.value_encoding == MultipartValueEncoding::Json) {
421            $L(json_stmt.as_str())
422        } $else_if(part.value_encoding == MultipartValueEncoding::Unsupported) {
423            $L(unsupported_stmt)
424        } $else {
425            $L(scalar_stmt.as_str())
426        }
427    })
428    .expect("multipart part assignment builds")
429}
430
431fn render_multipart_json_value(access: &str, expr: &IrTypeExpr, ir: &IrSpec) -> String {
432    match expr {
433        IrTypeExpr::Named(name) if is_object_schema(name, ir) => format!("{access}.to_dict()"),
434        IrTypeExpr::Nullable(inner) => render_multipart_json_value(access, inner, ir),
435        IrTypeExpr::Array(inner) => {
436            if let IrTypeExpr::Named(name) = inner.as_ref()
437                && is_object_schema(name, ir)
438            {
439                format!("[item.to_dict() for item in {access}]")
440            } else {
441                access.to_string()
442            }
443        }
444        _ => access.to_string(),
445    }
446}
447
448fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
449    match type_expr {
450        IrTypeExpr::Primitive(
451            IrPrimitive::String
452            | IrPrimitive::Date
453            | IrPrimitive::DateTime
454            | IrPrimitive::Uuid
455            | IrPrimitive::StringWithFormat(_),
456        )
457        | IrTypeExpr::StringLiteral(_)
458        | IrTypeExpr::StringEnum(_)
459        | IrTypeExpr::Named(_) => format!("str({var})"),
460        IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
461        IrTypeExpr::Primitive(
462            IrPrimitive::Integer
463            | IrPrimitive::IntegerWithFormat(_)
464            | IrPrimitive::Number
465            | IrPrimitive::NumberWithFormat(_),
466        ) => format!("str({var})"),
467        IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
468        IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
469        _ => format!("str({var})"),
470    }
471}
472
473fn response_type_name(response: &TypedResponse) -> TypeName {
474    match response.decoding {
475        ResponseDecoding::Json => api_type_name(&response.type_expr),
476        ResponseDecoding::Text => TypeName::primitive("str"),
477        ResponseDecoding::Bytes => TypeName::primitive("bytes"),
478    }
479}
480
481fn render_response_parse(response: &TypedResponse, ir: &IrSpec) -> String {
482    match response.decoding {
483        ResponseDecoding::Json => render_json_response_parse(&response.type_expr, ir),
484        ResponseDecoding::Text => "response.text".to_string(),
485        ResponseDecoding::Bytes => "response.content".to_string(),
486    }
487}
488
489fn render_json_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
490    match type_expr {
491        IrTypeExpr::Named(name) => {
492            let py_name = name.to_pascal_case();
493            if is_object_schema(name, ir) {
494                format!("{py_name}.from_dict(response.json())")
495            } else {
496                "response.json()  # type: ignore[return-value]".to_string()
497            }
498        }
499        IrTypeExpr::Array(inner) => {
500            if let IrTypeExpr::Named(name) = inner.as_ref()
501                && is_object_schema(name, ir)
502            {
503                let py_name = name.to_pascal_case();
504                return format!("[{py_name}.from_dict(item) for item in response.json()]");
505            }
506            "response.json()  # type: ignore[return-value]".to_string()
507        }
508        IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
509            "response.text".to_string()
510        }
511        _ => "response.json()  # type: ignore[return-value]".to_string(),
512    }
513}
514
515fn build_error_classes_block(plans: &[OpPlan<'_>], ir: &IrSpec) -> CodeBlock {
516    let mut cb = CodeBlock::builder();
517    for plan in plans {
518        let mut seen = HashSet::new();
519        let mut detail_class_names = Vec::new();
520        for response in &plan.error_responses {
521            if !seen.insert(response.class_name.clone()) {
522                continue;
523            }
524            detail_class_names.push(response.class_name.clone());
525            cb.add_statement(&format!("class {}:%>", response.class_name), ());
526            cb.add_statement(
527                "def __init__(self, status_code: int, headers: %T[str, str], raw_body: bytes) -> None:%>",
528                (TypeName::importable("collections.abc", "Mapping"),),
529            );
530            cb.add_statement("self.status_code: int = status_code", ());
531            cb.add_statement(
532                "self.headers: %T[str, str] = headers",
533                (TypeName::importable("collections.abc", "Mapping"),),
534            );
535            if let (Some(type_expr), Some(decoding)) = (&response.type_expr, response.decoding) {
536                let return_ty = error_body_return_type(type_expr, decoding);
537                cb.add_statement("self.raw_body: bytes = raw_body", ());
538                cb.add_statement("self._body_loaded: bool = False", ());
539                cb.add_statement("self._body_value: %T | None = None", (return_ty,));
540                cb.add_statement("self._body_error: Exception | None = None%<", ());
541                cb.add_code(error_body_property(type_expr, decoding, ir));
542            } else {
543                cb.add_statement("self.raw_body: bytes = raw_body%<", ());
544            }
545            cb.add("%<", ());
546            cb.add_line();
547        }
548        let unexpected = format!("{}Unexpected", plan.error_type.trim_end_matches("Error"));
549        detail_class_names.push(unexpected.clone());
550        cb.add_statement(&format!("class {unexpected}:%>"), ());
551        cb.add_statement(
552            "def __init__(self, status_code: int, headers: %T[str, str], raw_body: bytes) -> None:%>",
553            (TypeName::importable("collections.abc", "Mapping"),),
554        );
555        cb.add_statement("self.status_code: int = status_code", ());
556        cb.add_statement(
557            "self.headers: %T[str, str] = headers",
558            (TypeName::importable("collections.abc", "Mapping"),),
559        );
560        cb.add_statement("self.raw_body: bytes = raw_body%<", ());
561        cb.add_statement("@property", ());
562        cb.add_statement("def body(self) -> bytes:%>", ());
563        cb.add_statement("return self.raw_body%<%<", ());
564        cb.add_line();
565
566        let detail_type = format!("{}Detail", plan.error_type);
567        cb.add_statement(&format!("type {detail_type} = (%>"), ());
568        for (index, class_name) in detail_class_names.iter().enumerate() {
569            let prefix = if index == 0 { "" } else { "| " };
570            let suffix = if index + 1 == detail_class_names.len() {
571                "%<"
572            } else {
573                ""
574            };
575            cb.add_statement(&format!("{prefix}{class_name}{suffix}"), ());
576        }
577        cb.add_statement(")", ());
578        cb.add_line();
579
580        cb.add_statement(
581            &format!("class {}(%T):%>", plan.error_type),
582            (TypeName::importable("..runtime.errors", "ApiError"),),
583        );
584        cb.add_statement(
585            &format!(
586                "def __init__(self, status_code: int, status: str, body: bytes, detail: {detail_type}, headers: %T[str, str] | None = None, response: object | None = None) -> None:%>"
587            ),
588            (TypeName::importable("collections.abc", "Mapping"),),
589        );
590        cb.add_statement(&format!("self.detail: {detail_type} = detail"), ());
591        cb.add_statement(
592            "super().__init__(status_code, status, body, headers=headers, response=response)%<%<",
593            (),
594        );
595        cb.add_line();
596    }
597    cb.build().expect("Python error classes block builds")
598}
599
600fn error_body_property(
601    type_expr: &IrTypeExpr,
602    decoding: ResponseDecoding,
603    ir: &IrSpec,
604) -> CodeBlock {
605    let mut cb = CodeBlock::builder();
606    match decoding {
607        ResponseDecoding::Json => {
608            let return_ty = api_type_name(type_expr);
609            let parse_expr = render_error_json_body_parse(type_expr, ir);
610            cb.add_statement("@property", ());
611            cb.add_statement("def body(self) -> %T:%>", (return_ty.clone(),));
612            cb.add_statement("if not self._body_loaded:%>", ());
613            cb.add_statement("try:%>", ());
614            cb.add_statement(&format!("self._body_value = {parse_expr}%<"), ());
615            cb.add_statement("except Exception as exc:%>", ());
616            cb.add_statement("self._body_error = exc%<", ());
617            cb.add_statement("self._body_loaded = True%<", ());
618            cb.add_statement("if self._body_error is not None:%>", ());
619            cb.add_statement("raise self._body_error%<", ());
620            cb.add_statement("if self._body_value is None:%>", ());
621            cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
622            cb.add_statement("return self._body_value%<", ());
623        }
624        ResponseDecoding::Text => {
625            cb.add_statement("@property", ());
626            cb.add_statement("def body(self) -> str:%>", ());
627            cb.add_statement("if not self._body_loaded:%>", ());
628            cb.add_statement("try:%>", ());
629            cb.add_statement("self._body_value = self.raw_body.decode(\"utf-8\")%<", ());
630            cb.add_statement("except Exception as exc:%>", ());
631            cb.add_statement("self._body_error = exc%<", ());
632            cb.add_statement("self._body_loaded = True%<", ());
633            cb.add_statement("if self._body_error is not None:%>", ());
634            cb.add_statement("raise self._body_error%<", ());
635            cb.add_statement("if self._body_value is None:%>", ());
636            cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
637            cb.add_statement("return self._body_value%<", ());
638        }
639        ResponseDecoding::Bytes => {
640            cb.add_statement("@property", ());
641            cb.add_statement("def body(self) -> bytes:%>", ());
642            cb.add_statement("if not self._body_loaded:%>", ());
643            cb.add_statement("self._body_value = self.raw_body", ());
644            cb.add_statement("self._body_loaded = True", ());
645            cb.add_statement("if self._body_value is None:%>", ());
646            cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
647            cb.add_statement("return self._body_value%<", ());
648        }
649    }
650    cb.build().expect("Python error body property builds")
651}
652
653fn error_body_return_type(type_expr: &IrTypeExpr, decoding: ResponseDecoding) -> TypeName {
654    match decoding {
655        ResponseDecoding::Json => api_type_name(type_expr),
656        ResponseDecoding::Text => TypeName::primitive("str"),
657        ResponseDecoding::Bytes => TypeName::primitive("bytes"),
658    }
659}
660
661fn render_error_json_body_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
662    match type_expr {
663        IrTypeExpr::Named(name) => {
664            let py_name = name.to_pascal_case();
665            if is_object_schema(name, ir) {
666                format!("{py_name}.from_dict(json.loads(self.raw_body.decode(\"utf-8\")))")
667            } else {
668                "json.loads(self.raw_body.decode(\"utf-8\"))  # type: ignore[return-value]"
669                    .to_string()
670            }
671        }
672        IrTypeExpr::Array(inner) => {
673            if let IrTypeExpr::Named(name) = inner.as_ref()
674                && is_object_schema(name, ir)
675            {
676                let py_name = name.to_pascal_case();
677                return format!(
678                    "[{py_name}.from_dict(item) for item in json.loads(self.raw_body.decode(\"utf-8\"))]"
679                );
680            }
681            "json.loads(self.raw_body.decode(\"utf-8\"))  # type: ignore[return-value]".to_string()
682        }
683        _ => {
684            "json.loads(self.raw_body.decode(\"utf-8\"))  # type: ignore[return-value]".to_string()
685        }
686    }
687}
688
689fn emit_error_raise(plan: &OpPlan<'_>, reason_expr: &str) -> CodeBlock {
690    let mut cb = CodeBlock::builder();
691    cb.add_statement("if not (200 <= response.status_code < 300):%>", ());
692    cb.add_code(error_detail_assignment(plan));
693    cb.add_statement(
694        &format!(
695            "raise {}(response.status_code, {reason_expr}, response.content, detail, headers=response.headers, response=response)%<",
696            plan.error_type
697        ),
698        (),
699    );
700    cb.build().expect("Python error raise builds")
701}
702
703fn error_detail_assignment(plan: &OpPlan<'_>) -> CodeBlock {
704    let mut cb = CodeBlock::builder();
705    let mut exact: Vec<&ErrorResponse> = plan
706        .error_responses
707        .iter()
708        .filter(|r| r.status.parse::<u16>().is_ok())
709        .collect();
710    exact.sort_by_key(|r| r.status.parse::<u16>().unwrap());
711    let wildcards: Vec<&ErrorResponse> = plan
712        .error_responses
713        .iter()
714        .filter(|r| r.status.ends_with("XX"))
715        .collect();
716    let default = plan
717        .error_responses
718        .iter()
719        .find(|r| r.status.eq_ignore_ascii_case("default"));
720
721    let mut emitted = false;
722    for response in exact {
723        let keyword = if emitted { "elif" } else { "if" };
724        cb.add_statement(
725            &format!("{keyword} response.status_code == {}:%>", response.status),
726            (),
727        );
728        cb.add_statement(&format!("{}%<", detail_ctor_statement(response)), ());
729        emitted = true;
730    }
731    for response in wildcards {
732        let (low, high) = wildcard_status_range(&response.status);
733        let keyword = if emitted { "elif" } else { "if" };
734        cb.add_statement(
735            &format!("{keyword} {low} <= response.status_code < {high}:%>"),
736            (),
737        );
738        cb.add_statement(&format!("{}%<", detail_ctor_statement(response)), ());
739        emitted = true;
740    }
741    let unexpected = format!("{}Unexpected", plan.error_type.trim_end_matches("Error"));
742    if let Some(default) = default {
743        if emitted {
744            cb.add_statement("else:%>", ());
745            cb.add_statement(&format!("{}%<", detail_ctor_statement(default)), ());
746        } else {
747            cb.add_statement(&detail_ctor_statement(default), ());
748        }
749    } else if emitted {
750        cb.add_statement("else:%>", ());
751        cb.add_statement(
752            &format!(
753                "detail = {unexpected}(response.status_code, response.headers, response.content)%<"
754            ),
755            (),
756        );
757    } else {
758        cb.add_statement(
759            &format!(
760                "detail = {unexpected}(response.status_code, response.headers, response.content)"
761            ),
762            (),
763        );
764    }
765    cb.build().expect("Python error detail assignment builds")
766}
767
768fn detail_ctor_statement(response: &ErrorResponse) -> String {
769    format!(
770        "detail = {}(response.status_code, response.headers, response.content)",
771        response.class_name
772    )
773}
774
775fn wildcard_status_range(status: &str) -> (u16, u16) {
776    match status.to_uppercase().as_str() {
777        "1XX" => (100, 200),
778        "2XX" => (200, 300),
779        "3XX" => (300, 400),
780        "4XX" => (400, 500),
781        "5XX" => (500, 600),
782        _ => (0, 1000),
783    }
784}
785
786fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
787    if let IrTypeExpr::Named(name) = type_expr {
788        return is_object_schema(name, ir);
789    }
790    false
791}
792
793fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
794    if let IrTypeExpr::Array(inner) = type_expr
795        && let IrTypeExpr::Named(name) = inner.as_ref()
796    {
797        return is_object_schema(name, ir);
798    }
799    false
800}
801
802// ---------------------------------------------------------------------------
803// Planning
804// ---------------------------------------------------------------------------
805
806struct OpPlan<'a> {
807    op: &'a IrOperation,
808    method_name: String,
809    error_type: String,
810    path_params: Vec<ParamBinding<'a>>,
811    query_params: Vec<ParamBinding<'a>>,
812    header_params: Vec<ParamBinding<'a>>,
813    body: Option<BodyBinding>,
814    typed_responses: Vec<TypedResponse>,
815    error_responses: Vec<ErrorResponse>,
816}
817
818struct ParamBinding<'a> {
819    param: &'a IrParameter,
820    var_name: String,
821}
822
823struct BodyBinding {
824    var_name: String,
825    type_expr: IrTypeExpr,
826    required: bool,
827    media_type: String,
828    encoding: BodyEncoding,
829    multipart_parts: Option<Vec<MultipartPart>>,
830}
831
832struct MultipartPart {
833    wire_name: String,
834    field_name: String,
835    type_expr: IrTypeExpr,
836    is_binary: bool,
837    required: bool,
838    content_type: String,
839    value_encoding: MultipartValueEncoding,
840}
841
842#[derive(Clone, Copy, PartialEq, Eq)]
843enum BodyEncoding {
844    Json,
845    Multipart,
846    FormUrlEncoded,
847    Xml,
848    TextPlain,
849    OctetStream,
850    Other,
851}
852
853#[derive(Clone, Copy, PartialEq, Eq)]
854enum ResponseDecoding {
855    Json,
856    Text,
857    Bytes,
858}
859
860struct TypedResponse {
861    type_expr: IrTypeExpr,
862    decoding: ResponseDecoding,
863}
864
865#[derive(Clone)]
866struct ErrorResponse {
867    status: String,
868    class_name: String,
869    type_expr: Option<IrTypeExpr>,
870    decoding: Option<ResponseDecoding>,
871}
872
873fn plan_operation<'a>(
874    op: &'a IrOperation,
875    ir: &IrSpec,
876    request_inputs: &RequestInputPlan,
877) -> OpPlan<'a> {
878    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
879    let method_name = op_id.to_snake_case();
880    let error_type = format!("{}Error", op_id.to_pascal_case());
881
882    let mut used_names: HashSet<String> = HashSet::new();
883    used_names.insert("self".to_string());
884
885    let mut path_params = Vec::new();
886    let mut query_params = Vec::new();
887    let mut header_params = Vec::new();
888
889    for p in &op.parameters {
890        let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
891        let binding = ParamBinding { param: p, var_name };
892        match p.location {
893            ParameterLocation::Path => path_params.push(binding),
894            ParameterLocation::Query => query_params.push(binding),
895            ParameterLocation::Header => header_params.push(binding),
896            ParameterLocation::Cookie => header_params.push(binding),
897        }
898    }
899
900    let body = op
901        .request_body
902        .as_ref()
903        .and_then(|b| plan_body(op, b, ir, request_inputs, &mut used_names));
904
905    let typed_responses = op
906        .responses
907        .iter()
908        .filter(|r| is_success_status(&r.status))
909        .filter_map(plan_response)
910        .collect();
911    let error_responses = op
912        .responses
913        .iter()
914        .filter(|r| !is_success_status(&r.status))
915        .map(|r| plan_error_response(&op_id, r))
916        .collect();
917
918    OpPlan {
919        op,
920        method_name,
921        error_type,
922        path_params,
923        query_params,
924        header_params,
925        body,
926        typed_responses,
927        error_responses,
928    }
929}
930
931fn plan_body(
932    op: &IrOperation,
933    b: &IrRequestBody,
934    ir: &IrSpec,
935    request_inputs: &RequestInputPlan,
936    used_names: &mut HashSet<String>,
937) -> Option<BodyBinding> {
938    let (media_type, t) = pick_body_content(b)?;
939    let encoding = body_encoding(&media_type);
940    let var_name = unique_name("body", used_names);
941    let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
942        multipart_parts_for(b, &media_type, ir)
943    } else {
944        None
945    };
946    Some(BodyBinding {
947        var_name,
948        type_expr: if encoding == BodyEncoding::Multipart {
949            request_input_for_operation(request_inputs, op, &media_type)
950                .map(|input| IrTypeExpr::Named(input.name.clone()))
951                .unwrap_or(t)
952        } else {
953            t
954        },
955        required: b.required,
956        media_type,
957        encoding,
958        multipart_parts,
959    })
960}
961
962fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
963    let (media_type, t) = pick_response_content(r)?;
964    Some(TypedResponse {
965        type_expr: t,
966        decoding: response_decoding(&media_type),
967    })
968}
969
970fn plan_error_response(op_id: &str, r: &IrResponse) -> ErrorResponse {
971    match pick_response_content(r) {
972        Some((media_type, t)) => ErrorResponse {
973            status: r.status.clone(),
974            class_name: format!(
975                "{}{}",
976                op_id.to_pascal_case(),
977                response_variant_name(&r.status)
978            ),
979            type_expr: Some(t),
980            decoding: Some(response_decoding(&media_type)),
981        },
982        None => ErrorResponse {
983            status: r.status.clone(),
984            class_name: format!(
985                "{}{}",
986                op_id.to_pascal_case(),
987                response_variant_name(&r.status)
988            ),
989            type_expr: None,
990            decoding: None,
991        },
992    }
993}
994
995fn is_success_status(status: &str) -> bool {
996    status
997        .parse::<u16>()
998        .is_ok_and(|code| (200..300).contains(&code))
999        || status.eq_ignore_ascii_case("2XX")
1000}
1001
1002fn body_encoding(media_type: &str) -> BodyEncoding {
1003    let base = media_type_base(media_type);
1004    if base == "multipart/form-data" {
1005        BodyEncoding::Multipart
1006    } else if is_json_media_type(media_type) {
1007        BodyEncoding::Json
1008    } else if base == "application/x-www-form-urlencoded" {
1009        BodyEncoding::FormUrlEncoded
1010    } else if is_xml_media_type(media_type) {
1011        BodyEncoding::Xml
1012    } else if base == "text/plain" {
1013        BodyEncoding::TextPlain
1014    } else if base == "application/octet-stream" {
1015        BodyEncoding::OctetStream
1016    } else {
1017        BodyEncoding::Other
1018    }
1019}
1020
1021fn response_decoding(media_type: &str) -> ResponseDecoding {
1022    let base = media_type_base(media_type);
1023    if is_json_media_type(media_type) {
1024        ResponseDecoding::Json
1025    } else if base == "text/plain" || is_xml_media_type(media_type) {
1026        ResponseDecoding::Text
1027    } else {
1028        ResponseDecoding::Bytes
1029    }
1030}
1031
1032fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
1033    pick_media_type(&body.content, |media_type| {
1034        media_type_base(media_type) == "application/json"
1035    })
1036    .or_else(|| pick_media_type(&body.content, is_json_media_type))
1037    .or_else(|| {
1038        pick_media_type(&body.content, |media_type| {
1039            media_type_base(media_type) == "multipart/form-data"
1040        })
1041    })
1042    .or_else(|| {
1043        pick_media_type(&body.content, |media_type| {
1044            media_type_base(media_type) == "application/x-www-form-urlencoded"
1045        })
1046    })
1047    .or_else(|| pick_media_type(&body.content, is_xml_media_type))
1048    .or_else(|| {
1049        pick_media_type(&body.content, |media_type| {
1050            media_type_base(media_type) == "text/plain"
1051        })
1052    })
1053    .or_else(|| {
1054        pick_media_type(&body.content, |media_type| {
1055            media_type_base(media_type) == "application/octet-stream"
1056        })
1057    })
1058    .or_else(|| pick_first_content(&body.content))
1059}
1060
1061fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
1062    pick_media_type(&r.content, |media_type| {
1063        media_type_base(media_type) == "application/json"
1064    })
1065    .or_else(|| pick_media_type(&r.content, is_json_media_type))
1066    .or_else(|| {
1067        pick_media_type(&r.content, |media_type| {
1068            media_type_base(media_type) == "application/octet-stream"
1069        })
1070    })
1071    .or_else(|| {
1072        pick_media_type(&r.content, |media_type| {
1073            media_type_base(media_type) == "text/plain"
1074        })
1075    })
1076    .or_else(|| pick_media_type(&r.content, is_xml_media_type))
1077    .or_else(|| pick_first_content(&r.content))
1078}
1079
1080fn pick_media_type(
1081    content: &indexmap::IndexMap<String, IrTypeExpr>,
1082    predicate: impl Fn(&str) -> bool,
1083) -> Option<(String, IrTypeExpr)> {
1084    content
1085        .iter()
1086        .find(|(media_type, _)| predicate(media_type))
1087        .map(|(media_type, t)| (media_type.clone(), t.clone()))
1088}
1089
1090fn pick_first_content(
1091    content: &indexmap::IndexMap<String, IrTypeExpr>,
1092) -> Option<(String, IrTypeExpr)> {
1093    content
1094        .iter()
1095        .next()
1096        .map(|(media_type, t)| (media_type.clone(), t.clone()))
1097}
1098
1099fn media_type_base(media_type: &str) -> String {
1100    media_type
1101        .split(';')
1102        .next()
1103        .unwrap_or(media_type)
1104        .trim()
1105        .to_ascii_lowercase()
1106}
1107
1108fn is_json_media_type(media_type: &str) -> bool {
1109    let base = media_type_base(media_type);
1110    base == "application/json" || base.ends_with("+json")
1111}
1112
1113fn is_xml_media_type(media_type: &str) -> bool {
1114    let base = media_type_base(media_type);
1115    base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
1116}
1117
1118fn multipart_parts_for(
1119    body: &IrRequestBody,
1120    media_type: &str,
1121    ir: &IrSpec,
1122) -> Option<Vec<MultipartPart>> {
1123    multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
1124        parts
1125            .into_iter()
1126            .map(|part| MultipartPart {
1127                field_name: python_field_name(&part.wire_name),
1128                wire_name: part.wire_name,
1129                type_expr: part.type_expr,
1130                is_binary: part.is_binary,
1131                required: part.required,
1132                content_type: part.content_type,
1133                value_encoding: part.value_encoding,
1134            })
1135            .collect()
1136    })
1137}
1138
1139fn python_param_name(name: &str) -> String {
1140    let snake = name.to_snake_case();
1141    if snake.is_empty() {
1142        return "param".to_string();
1143    }
1144    match snake.as_str() {
1145        "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
1146        | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
1147        | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
1148        | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
1149            format!("{snake}_")
1150        }
1151        _ => snake,
1152    }
1153}
1154
1155fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
1156    if used.insert(desired.to_string()) {
1157        return desired.to_string();
1158    }
1159    for i in 2..=u32::MAX {
1160        let candidate = format!("{desired}{i}");
1161        if used.insert(candidate.clone()) {
1162            return candidate;
1163        }
1164    }
1165    unreachable!("name collision space exhausted")
1166}
1167
1168fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
1169    if !op_id.is_empty() {
1170        return op_id.to_string();
1171    }
1172    let path_part: String = path
1173        .chars()
1174        .map(|c| if c.is_alphanumeric() { c } else { '_' })
1175        .collect();
1176    format!("{method}_{path_part}")
1177}
1178
1179/// Returns true if the type expression is already nullable (wrapped in None),
1180/// so that the caller can avoid double-wrapping with TypeName::optional.
1181fn is_already_optional(expr: &IrTypeExpr) -> bool {
1182    matches!(expr, IrTypeExpr::Nullable(_))
1183}