Skip to main content

openapi_nexus/generators/python/requests/
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
314                | BodyEncoding::TextPlain
315                | BodyEncoding::OctetStream => request_args.push(format!("data={body_expr}")),
316                BodyEncoding::Xml | BodyEncoding::Other => {
317                    if body.required {
318                        cb.add_statement(
319                            &format!(
320                                "raise ValueError(\"unsupported request body media type: {}\")",
321                                body.media_type
322                            ),
323                            (),
324                        );
325                    } else {
326                        cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
327                        cb.add_statement(
328                            &format!(
329                                "raise ValueError(\"unsupported request body media type: {}\")%<",
330                                body.media_type
331                            ),
332                            (),
333                        );
334                    }
335                }
336                BodyEncoding::Multipart => unreachable!("multipart handled separately"),
337            }
338        }
339    }
340    if has_headers {
341        request_args.push("headers=headers".to_string());
342    }
343
344    cb.add_code(
345        sigil_quote!(Python {
346            response = self._client.request($for(arg in &request_args; separator = ", ") { $L(arg.as_str()) })
347        })
348        .expect("request call"),
349    );
350
351    // Error handling
352    cb.add_code(emit_error_raise(plan, "response.reason"));
353
354    // Response parsing
355    if !plan.typed_responses.is_empty() {
356        let tr = &plan.typed_responses[0];
357        let parse_expr = render_response_parse(tr, ir);
358        cb.add_statement(&format!("return {parse_expr}"), ());
359    } else {
360        cb.add_statement("return None", ());
361    }
362
363    cb.build().expect("API method body builds")
364}
365
366fn emit_multipart_data(
367    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
368    body: &BodyBinding,
369    parts: &[MultipartPart],
370    ir: &IrSpec,
371) {
372    cb.add_statement("files: dict[str, object] = {}", ());
373    if !body.required {
374        cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
375    }
376    for part in parts {
377        let access = format!("{}.{}", body.var_name, part.field_name);
378        if part.required {
379            emit_required_multipart_part(cb, part, &access, ir);
380        } else {
381            cb.add_statement(&format!("if {access} is not None:%>"), ());
382            emit_required_multipart_part(cb, part, &access, ir);
383            cb.add_statement("%<", ());
384        }
385    }
386    if !body.required {
387        cb.add_statement("%<", ());
388    }
389}
390
391fn emit_required_multipart_part(
392    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
393    part: &MultipartPart,
394    access: &str,
395    ir: &IrSpec,
396) {
397    cb.add_code(multipart_part_assignment(part, access, ir));
398}
399
400fn multipart_part_assignment(part: &MultipartPart, access: &str, ir: &IrSpec) -> CodeBlock {
401    let binary_stmt = format!(
402        "files[\"{}\"] = ({}.filename_or_default(\"{}\"), {}.data, \"{}\")",
403        part.wire_name, access, part.wire_name, access, part.content_type
404    );
405    let json_value = render_multipart_json_value(access, &part.type_expr, ir);
406    let json_stmt = format!(
407        "files[\"{}\"] = (None, json.dumps({json_value}), \"{}\")",
408        part.wire_name, part.content_type
409    );
410    let unsupported_stmt = "raise ValueError(\"unsupported multipart part content type\")";
411    let scalar_stmt = format!(
412        "files[\"{}\"] = (None, str({access}), \"{}\")",
413        part.wire_name, part.content_type
414    );
415
416    sigil_quote!(Python {
417        $if(part.is_binary) {
418            $L(binary_stmt.as_str())
419        } $else_if(part.value_encoding == MultipartValueEncoding::Json) {
420            $L(json_stmt.as_str())
421        } $else_if(part.value_encoding == MultipartValueEncoding::Unsupported) {
422            $L(unsupported_stmt)
423        } $else {
424            $L(scalar_stmt.as_str())
425        }
426    })
427    .expect("multipart part assignment builds")
428}
429
430fn render_multipart_json_value(access: &str, expr: &IrTypeExpr, ir: &IrSpec) -> String {
431    match expr {
432        IrTypeExpr::Named(name) if is_object_schema(name, ir) => format!("{access}.to_dict()"),
433        IrTypeExpr::Nullable(inner) => render_multipart_json_value(access, inner, ir),
434        IrTypeExpr::Array(inner) => {
435            if let IrTypeExpr::Named(name) = inner.as_ref()
436                && is_object_schema(name, ir)
437            {
438                format!("[item.to_dict() for item in {access}]")
439            } else {
440                access.to_string()
441            }
442        }
443        _ => access.to_string(),
444    }
445}
446
447fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
448    match type_expr {
449        IrTypeExpr::Primitive(
450            IrPrimitive::String
451            | IrPrimitive::Date
452            | IrPrimitive::DateTime
453            | IrPrimitive::Uuid
454            | IrPrimitive::StringWithFormat(_),
455        )
456        | IrTypeExpr::StringLiteral(_)
457        | IrTypeExpr::StringEnum(_)
458        | IrTypeExpr::Named(_) => format!("str({var})"),
459        IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
460        IrTypeExpr::Primitive(
461            IrPrimitive::Integer
462            | IrPrimitive::IntegerWithFormat(_)
463            | IrPrimitive::Number
464            | IrPrimitive::NumberWithFormat(_),
465        ) => format!("str({var})"),
466        IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
467        IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
468        _ => format!("str({var})"),
469    }
470}
471
472fn response_type_name(response: &TypedResponse) -> TypeName {
473    match response.decoding {
474        ResponseDecoding::Json => api_type_name(&response.type_expr),
475        ResponseDecoding::Text => TypeName::primitive("str"),
476        ResponseDecoding::Bytes => TypeName::primitive("bytes"),
477    }
478}
479
480fn render_response_parse(response: &TypedResponse, ir: &IrSpec) -> String {
481    match response.decoding {
482        ResponseDecoding::Json => render_json_response_parse(&response.type_expr, ir),
483        ResponseDecoding::Text => "response.text".to_string(),
484        ResponseDecoding::Bytes => "response.content".to_string(),
485    }
486}
487
488fn render_json_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
489    match type_expr {
490        IrTypeExpr::Named(name) => {
491            let py_name = name.to_pascal_case();
492            if is_object_schema(name, ir) {
493                format!("{py_name}.from_dict(response.json())")
494            } else {
495                "response.json()  # type: ignore[return-value]".to_string()
496            }
497        }
498        IrTypeExpr::Array(inner) => {
499            if let IrTypeExpr::Named(name) = inner.as_ref()
500                && is_object_schema(name, ir)
501            {
502                let py_name = name.to_pascal_case();
503                return format!("[{py_name}.from_dict(item) for item in response.json()]");
504            }
505            "response.json()  # type: ignore[return-value]".to_string()
506        }
507        IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
508            "response.text".to_string()
509        }
510        _ => "response.json()  # type: ignore[return-value]".to_string(),
511    }
512}
513
514fn build_error_classes_block(plans: &[OpPlan<'_>], ir: &IrSpec) -> CodeBlock {
515    let mut cb = CodeBlock::builder();
516    for plan in plans {
517        let mut seen = HashSet::new();
518        let mut detail_class_names = Vec::new();
519        for response in &plan.error_responses {
520            if !seen.insert(response.class_name.clone()) {
521                continue;
522            }
523            detail_class_names.push(response.class_name.clone());
524            cb.add_statement(&format!("class {}:%>", response.class_name), ());
525            cb.add_statement(
526                "def __init__(self, status_code: int, headers: %T[str, str], raw_body: bytes) -> None:%>",
527                (TypeName::importable("collections.abc", "Mapping"),),
528            );
529            cb.add_statement("self.status_code: int = status_code", ());
530            cb.add_statement(
531                "self.headers: %T[str, str] = headers",
532                (TypeName::importable("collections.abc", "Mapping"),),
533            );
534            if let (Some(type_expr), Some(decoding)) = (&response.type_expr, response.decoding) {
535                let return_ty = error_body_return_type(type_expr, decoding);
536                cb.add_statement("self.raw_body: bytes = raw_body", ());
537                cb.add_statement("self._body_loaded: bool = False", ());
538                cb.add_statement("self._body_value: %T | None = None", (return_ty,));
539                cb.add_statement("self._body_error: Exception | None = None%<", ());
540                cb.add_code(error_body_property(type_expr, decoding, ir));
541            } else {
542                cb.add_statement("self.raw_body: bytes = raw_body%<", ());
543            }
544            cb.add("%<", ());
545            cb.add_line();
546        }
547        let unexpected = format!("{}Unexpected", plan.error_type.trim_end_matches("Error"));
548        detail_class_names.push(unexpected.clone());
549        cb.add_statement(&format!("class {unexpected}:%>"), ());
550        cb.add_statement(
551            "def __init__(self, status_code: int, headers: %T[str, str], raw_body: bytes) -> None:%>",
552            (TypeName::importable("collections.abc", "Mapping"),),
553        );
554        cb.add_statement("self.status_code: int = status_code", ());
555        cb.add_statement(
556            "self.headers: %T[str, str] = headers",
557            (TypeName::importable("collections.abc", "Mapping"),),
558        );
559        cb.add_statement("self.raw_body: bytes = raw_body%<", ());
560        cb.add_statement("@property", ());
561        cb.add_statement("def body(self) -> bytes:%>", ());
562        cb.add_statement("return self.raw_body%<%<", ());
563        cb.add_line();
564
565        let detail_type = format!("{}Detail", plan.error_type);
566        cb.add_statement(&format!("type {detail_type} = (%>"), ());
567        for (index, class_name) in detail_class_names.iter().enumerate() {
568            let prefix = if index == 0 { "" } else { "| " };
569            let suffix = if index + 1 == detail_class_names.len() {
570                "%<"
571            } else {
572                ""
573            };
574            cb.add_statement(&format!("{prefix}{class_name}{suffix}"), ());
575        }
576        cb.add_statement(")", ());
577        cb.add_line();
578
579        cb.add_statement(
580            &format!("class {}(%T):%>", plan.error_type),
581            (TypeName::importable("..runtime.errors", "ApiError"),),
582        );
583        cb.add_statement(
584            &format!(
585                "def __init__(self, status_code: int, status: str, body: bytes, detail: {detail_type}, headers: %T[str, str] | None = None, response: object | None = None) -> None:%>"
586            ),
587            (TypeName::importable("collections.abc", "Mapping"),),
588        );
589        cb.add_statement(&format!("self.detail: {detail_type} = detail"), ());
590        cb.add_statement(
591            "super().__init__(status_code, status, body, headers=headers, response=response)%<%<",
592            (),
593        );
594        cb.add_line();
595    }
596    cb.build().expect("Python error classes block builds")
597}
598
599fn error_body_property(
600    type_expr: &IrTypeExpr,
601    decoding: ResponseDecoding,
602    ir: &IrSpec,
603) -> CodeBlock {
604    let mut cb = CodeBlock::builder();
605    match decoding {
606        ResponseDecoding::Json => {
607            let return_ty = api_type_name(type_expr);
608            let parse_expr = render_error_json_body_parse(type_expr, ir);
609            cb.add_statement("@property", ());
610            cb.add_statement("def body(self) -> %T:%>", (return_ty.clone(),));
611            cb.add_statement("if not self._body_loaded:%>", ());
612            cb.add_statement("try:%>", ());
613            cb.add_statement(&format!("self._body_value = {parse_expr}%<"), ());
614            cb.add_statement("except Exception as exc:%>", ());
615            cb.add_statement("self._body_error = exc%<", ());
616            cb.add_statement("self._body_loaded = True%<", ());
617            cb.add_statement("if self._body_error is not None:%>", ());
618            cb.add_statement("raise self._body_error%<", ());
619            cb.add_statement("if self._body_value is None:%>", ());
620            cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
621            cb.add_statement("return self._body_value%<", ());
622        }
623        ResponseDecoding::Text => {
624            cb.add_statement("@property", ());
625            cb.add_statement("def body(self) -> str:%>", ());
626            cb.add_statement("if not self._body_loaded:%>", ());
627            cb.add_statement("try:%>", ());
628            cb.add_statement("self._body_value = self.raw_body.decode(\"utf-8\")%<", ());
629            cb.add_statement("except Exception as exc:%>", ());
630            cb.add_statement("self._body_error = exc%<", ());
631            cb.add_statement("self._body_loaded = True%<", ());
632            cb.add_statement("if self._body_error is not None:%>", ());
633            cb.add_statement("raise self._body_error%<", ());
634            cb.add_statement("if self._body_value is None:%>", ());
635            cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
636            cb.add_statement("return self._body_value%<", ());
637        }
638        ResponseDecoding::Bytes => {
639            cb.add_statement("@property", ());
640            cb.add_statement("def body(self) -> bytes:%>", ());
641            cb.add_statement("if not self._body_loaded:%>", ());
642            cb.add_statement("self._body_value = self.raw_body", ());
643            cb.add_statement("self._body_loaded = True", ());
644            cb.add_statement("if self._body_value is None:%>", ());
645            cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
646            cb.add_statement("return self._body_value%<", ());
647        }
648    }
649    cb.build().expect("Python error body property builds")
650}
651
652fn error_body_return_type(type_expr: &IrTypeExpr, decoding: ResponseDecoding) -> TypeName {
653    match decoding {
654        ResponseDecoding::Json => api_type_name(type_expr),
655        ResponseDecoding::Text => TypeName::primitive("str"),
656        ResponseDecoding::Bytes => TypeName::primitive("bytes"),
657    }
658}
659
660fn render_error_json_body_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
661    match type_expr {
662        IrTypeExpr::Named(name) => {
663            let py_name = name.to_pascal_case();
664            if is_object_schema(name, ir) {
665                format!("{py_name}.from_dict(json.loads(self.raw_body.decode(\"utf-8\")))")
666            } else {
667                "json.loads(self.raw_body.decode(\"utf-8\"))  # type: ignore[return-value]"
668                    .to_string()
669            }
670        }
671        IrTypeExpr::Array(inner) => {
672            if let IrTypeExpr::Named(name) = inner.as_ref()
673                && is_object_schema(name, ir)
674            {
675                let py_name = name.to_pascal_case();
676                return format!(
677                    "[{py_name}.from_dict(item) for item in json.loads(self.raw_body.decode(\"utf-8\"))]"
678                );
679            }
680            "json.loads(self.raw_body.decode(\"utf-8\"))  # type: ignore[return-value]".to_string()
681        }
682        _ => {
683            "json.loads(self.raw_body.decode(\"utf-8\"))  # type: ignore[return-value]".to_string()
684        }
685    }
686}
687
688fn emit_error_raise(plan: &OpPlan<'_>, reason_expr: &str) -> CodeBlock {
689    let mut cb = CodeBlock::builder();
690    cb.add_statement("if not (200 <= response.status_code < 300):%>", ());
691    cb.add_code(error_detail_assignment(plan));
692    cb.add_statement(
693        &format!(
694            "raise {}(response.status_code, {reason_expr}, response.content, detail, headers=response.headers, response=response)%<",
695            plan.error_type
696        ),
697        (),
698    );
699    cb.build().expect("Python error raise builds")
700}
701
702fn error_detail_assignment(plan: &OpPlan<'_>) -> CodeBlock {
703    let mut cb = CodeBlock::builder();
704    let mut exact: Vec<&ErrorResponse> = plan
705        .error_responses
706        .iter()
707        .filter(|r| r.status.parse::<u16>().is_ok())
708        .collect();
709    exact.sort_by_key(|r| r.status.parse::<u16>().unwrap());
710    let wildcards: Vec<&ErrorResponse> = plan
711        .error_responses
712        .iter()
713        .filter(|r| r.status.ends_with("XX"))
714        .collect();
715    let default = plan
716        .error_responses
717        .iter()
718        .find(|r| r.status.eq_ignore_ascii_case("default"));
719
720    let mut emitted = false;
721    for response in exact {
722        let keyword = if emitted { "elif" } else { "if" };
723        cb.add_statement(
724            &format!("{keyword} response.status_code == {}:%>", response.status),
725            (),
726        );
727        cb.add_statement(&format!("{}%<", detail_ctor_statement(response)), ());
728        emitted = true;
729    }
730    for response in wildcards {
731        let (low, high) = wildcard_status_range(&response.status);
732        let keyword = if emitted { "elif" } else { "if" };
733        cb.add_statement(
734            &format!("{keyword} {low} <= response.status_code < {high}:%>"),
735            (),
736        );
737        cb.add_statement(&format!("{}%<", detail_ctor_statement(response)), ());
738        emitted = true;
739    }
740    let unexpected = format!("{}Unexpected", plan.error_type.trim_end_matches("Error"));
741    if let Some(default) = default {
742        if emitted {
743            cb.add_statement("else:%>", ());
744            cb.add_statement(&format!("{}%<", detail_ctor_statement(default)), ());
745        } else {
746            cb.add_statement(&detail_ctor_statement(default), ());
747        }
748    } else if emitted {
749        cb.add_statement("else:%>", ());
750        cb.add_statement(
751            &format!(
752                "detail = {unexpected}(response.status_code, response.headers, response.content)%<"
753            ),
754            (),
755        );
756    } else {
757        cb.add_statement(
758            &format!(
759                "detail = {unexpected}(response.status_code, response.headers, response.content)"
760            ),
761            (),
762        );
763    }
764    cb.build().expect("Python error detail assignment builds")
765}
766
767fn detail_ctor_statement(response: &ErrorResponse) -> String {
768    format!(
769        "detail = {}(response.status_code, response.headers, response.content)",
770        response.class_name
771    )
772}
773
774fn wildcard_status_range(status: &str) -> (u16, u16) {
775    match status.to_uppercase().as_str() {
776        "1XX" => (100, 200),
777        "2XX" => (200, 300),
778        "3XX" => (300, 400),
779        "4XX" => (400, 500),
780        "5XX" => (500, 600),
781        _ => (0, 1000),
782    }
783}
784
785fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
786    if let IrTypeExpr::Named(name) = type_expr {
787        return is_object_schema(name, ir);
788    }
789    false
790}
791
792fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
793    if let IrTypeExpr::Array(inner) = type_expr
794        && let IrTypeExpr::Named(name) = inner.as_ref()
795    {
796        return is_object_schema(name, ir);
797    }
798    false
799}
800
801// ---------------------------------------------------------------------------
802// Planning
803// ---------------------------------------------------------------------------
804
805struct OpPlan<'a> {
806    op: &'a IrOperation,
807    method_name: String,
808    error_type: String,
809    path_params: Vec<ParamBinding<'a>>,
810    query_params: Vec<ParamBinding<'a>>,
811    header_params: Vec<ParamBinding<'a>>,
812    body: Option<BodyBinding>,
813    typed_responses: Vec<TypedResponse>,
814    error_responses: Vec<ErrorResponse>,
815}
816
817struct ParamBinding<'a> {
818    param: &'a IrParameter,
819    var_name: String,
820}
821
822struct BodyBinding {
823    var_name: String,
824    type_expr: IrTypeExpr,
825    required: bool,
826    media_type: String,
827    encoding: BodyEncoding,
828    multipart_parts: Option<Vec<MultipartPart>>,
829}
830
831struct MultipartPart {
832    wire_name: String,
833    field_name: String,
834    type_expr: IrTypeExpr,
835    is_binary: bool,
836    required: bool,
837    content_type: String,
838    value_encoding: MultipartValueEncoding,
839}
840
841#[derive(Clone, Copy, PartialEq, Eq)]
842enum BodyEncoding {
843    Json,
844    Multipart,
845    FormUrlEncoded,
846    Xml,
847    TextPlain,
848    OctetStream,
849    Other,
850}
851
852#[derive(Clone, Copy, PartialEq, Eq)]
853enum ResponseDecoding {
854    Json,
855    Text,
856    Bytes,
857}
858
859struct TypedResponse {
860    type_expr: IrTypeExpr,
861    decoding: ResponseDecoding,
862}
863
864#[derive(Clone)]
865struct ErrorResponse {
866    status: String,
867    class_name: String,
868    type_expr: Option<IrTypeExpr>,
869    decoding: Option<ResponseDecoding>,
870}
871
872fn plan_operation<'a>(
873    op: &'a IrOperation,
874    ir: &IrSpec,
875    request_inputs: &RequestInputPlan,
876) -> OpPlan<'a> {
877    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
878    let method_name = op_id.to_snake_case();
879    let error_type = format!("{}Error", op_id.to_pascal_case());
880
881    let mut used_names: HashSet<String> = HashSet::new();
882    used_names.insert("self".to_string());
883
884    let mut path_params = Vec::new();
885    let mut query_params = Vec::new();
886    let mut header_params = Vec::new();
887
888    for p in &op.parameters {
889        let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
890        let binding = ParamBinding { param: p, var_name };
891        match p.location {
892            ParameterLocation::Path => path_params.push(binding),
893            ParameterLocation::Query => query_params.push(binding),
894            ParameterLocation::Header => header_params.push(binding),
895            ParameterLocation::Cookie => header_params.push(binding),
896        }
897    }
898
899    let body = op
900        .request_body
901        .as_ref()
902        .and_then(|b| plan_body(op, b, ir, request_inputs, &mut used_names));
903
904    let typed_responses = op
905        .responses
906        .iter()
907        .filter(|r| is_success_status(&r.status))
908        .filter_map(plan_response)
909        .collect();
910    let error_responses = op
911        .responses
912        .iter()
913        .filter(|r| !is_success_status(&r.status))
914        .map(|r| plan_error_response(&op_id, r))
915        .collect();
916
917    OpPlan {
918        op,
919        method_name,
920        error_type,
921        path_params,
922        query_params,
923        header_params,
924        body,
925        typed_responses,
926        error_responses,
927    }
928}
929
930fn plan_body(
931    op: &IrOperation,
932    b: &IrRequestBody,
933    ir: &IrSpec,
934    request_inputs: &RequestInputPlan,
935    used_names: &mut HashSet<String>,
936) -> Option<BodyBinding> {
937    let (media_type, t) = pick_body_content(b)?;
938    let encoding = body_encoding(&media_type);
939    let var_name = unique_name("body", used_names);
940    let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
941        multipart_parts_for(b, &media_type, ir)
942    } else {
943        None
944    };
945    Some(BodyBinding {
946        var_name,
947        type_expr: if encoding == BodyEncoding::Multipart {
948            request_input_for_operation(request_inputs, op, &media_type)
949                .map(|input| IrTypeExpr::Named(input.name.clone()))
950                .unwrap_or(t)
951        } else {
952            t
953        },
954        required: b.required,
955        media_type,
956        encoding,
957        multipart_parts,
958    })
959}
960
961fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
962    let (media_type, t) = pick_response_content(r)?;
963    Some(TypedResponse {
964        type_expr: t,
965        decoding: response_decoding(&media_type),
966    })
967}
968
969fn plan_error_response(op_id: &str, r: &IrResponse) -> ErrorResponse {
970    match pick_response_content(r) {
971        Some((media_type, t)) => ErrorResponse {
972            status: r.status.clone(),
973            class_name: format!(
974                "{}{}",
975                op_id.to_pascal_case(),
976                response_variant_name(&r.status)
977            ),
978            type_expr: Some(t),
979            decoding: Some(response_decoding(&media_type)),
980        },
981        None => ErrorResponse {
982            status: r.status.clone(),
983            class_name: format!(
984                "{}{}",
985                op_id.to_pascal_case(),
986                response_variant_name(&r.status)
987            ),
988            type_expr: None,
989            decoding: None,
990        },
991    }
992}
993
994fn is_success_status(status: &str) -> bool {
995    status
996        .parse::<u16>()
997        .is_ok_and(|code| (200..300).contains(&code))
998        || status.eq_ignore_ascii_case("2XX")
999}
1000
1001fn body_encoding(media_type: &str) -> BodyEncoding {
1002    let base = media_type_base(media_type);
1003    if base == "multipart/form-data" {
1004        BodyEncoding::Multipart
1005    } else if is_json_media_type(media_type) {
1006        BodyEncoding::Json
1007    } else if base == "application/x-www-form-urlencoded" {
1008        BodyEncoding::FormUrlEncoded
1009    } else if is_xml_media_type(media_type) {
1010        BodyEncoding::Xml
1011    } else if base == "text/plain" {
1012        BodyEncoding::TextPlain
1013    } else if base == "application/octet-stream" {
1014        BodyEncoding::OctetStream
1015    } else {
1016        BodyEncoding::Other
1017    }
1018}
1019
1020fn response_decoding(media_type: &str) -> ResponseDecoding {
1021    let base = media_type_base(media_type);
1022    if is_json_media_type(media_type) {
1023        ResponseDecoding::Json
1024    } else if base == "text/plain" || is_xml_media_type(media_type) {
1025        ResponseDecoding::Text
1026    } else {
1027        ResponseDecoding::Bytes
1028    }
1029}
1030
1031fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
1032    pick_media_type(&body.content, |media_type| {
1033        media_type_base(media_type) == "application/json"
1034    })
1035    .or_else(|| pick_media_type(&body.content, is_json_media_type))
1036    .or_else(|| {
1037        pick_media_type(&body.content, |media_type| {
1038            media_type_base(media_type) == "multipart/form-data"
1039        })
1040    })
1041    .or_else(|| {
1042        pick_media_type(&body.content, |media_type| {
1043            media_type_base(media_type) == "application/x-www-form-urlencoded"
1044        })
1045    })
1046    .or_else(|| pick_media_type(&body.content, is_xml_media_type))
1047    .or_else(|| {
1048        pick_media_type(&body.content, |media_type| {
1049            media_type_base(media_type) == "text/plain"
1050        })
1051    })
1052    .or_else(|| {
1053        pick_media_type(&body.content, |media_type| {
1054            media_type_base(media_type) == "application/octet-stream"
1055        })
1056    })
1057    .or_else(|| pick_first_content(&body.content))
1058}
1059
1060fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
1061    pick_media_type(&r.content, |media_type| {
1062        media_type_base(media_type) == "application/json"
1063    })
1064    .or_else(|| pick_media_type(&r.content, is_json_media_type))
1065    .or_else(|| {
1066        pick_media_type(&r.content, |media_type| {
1067            media_type_base(media_type) == "application/octet-stream"
1068        })
1069    })
1070    .or_else(|| {
1071        pick_media_type(&r.content, |media_type| {
1072            media_type_base(media_type) == "text/plain"
1073        })
1074    })
1075    .or_else(|| pick_media_type(&r.content, is_xml_media_type))
1076    .or_else(|| pick_first_content(&r.content))
1077}
1078
1079fn pick_media_type(
1080    content: &indexmap::IndexMap<String, IrTypeExpr>,
1081    predicate: impl Fn(&str) -> bool,
1082) -> Option<(String, IrTypeExpr)> {
1083    content
1084        .iter()
1085        .find(|(media_type, _)| predicate(media_type))
1086        .map(|(media_type, t)| (media_type.clone(), t.clone()))
1087}
1088
1089fn pick_first_content(
1090    content: &indexmap::IndexMap<String, IrTypeExpr>,
1091) -> Option<(String, IrTypeExpr)> {
1092    content
1093        .iter()
1094        .next()
1095        .map(|(media_type, t)| (media_type.clone(), t.clone()))
1096}
1097
1098fn media_type_base(media_type: &str) -> String {
1099    media_type
1100        .split(';')
1101        .next()
1102        .unwrap_or(media_type)
1103        .trim()
1104        .to_ascii_lowercase()
1105}
1106
1107fn is_json_media_type(media_type: &str) -> bool {
1108    let base = media_type_base(media_type);
1109    base == "application/json" || base.ends_with("+json")
1110}
1111
1112fn is_xml_media_type(media_type: &str) -> bool {
1113    let base = media_type_base(media_type);
1114    base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
1115}
1116
1117fn multipart_parts_for(
1118    body: &IrRequestBody,
1119    media_type: &str,
1120    ir: &IrSpec,
1121) -> Option<Vec<MultipartPart>> {
1122    multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
1123        parts
1124            .into_iter()
1125            .map(|part| MultipartPart {
1126                field_name: python_field_name(&part.wire_name),
1127                wire_name: part.wire_name,
1128                type_expr: part.type_expr,
1129                is_binary: part.is_binary,
1130                required: part.required,
1131                content_type: part.content_type,
1132                value_encoding: part.value_encoding,
1133            })
1134            .collect()
1135    })
1136}
1137
1138fn python_param_name(name: &str) -> String {
1139    let snake = name.to_snake_case();
1140    if snake.is_empty() {
1141        return "param".to_string();
1142    }
1143    match snake.as_str() {
1144        "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
1145        | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
1146        | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
1147        | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
1148            format!("{snake}_")
1149        }
1150        _ => snake,
1151    }
1152}
1153
1154fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
1155    if used.insert(desired.to_string()) {
1156        return desired.to_string();
1157    }
1158    for i in 2..=u32::MAX {
1159        let candidate = format!("{desired}{i}");
1160        if used.insert(candidate.clone()) {
1161            return candidate;
1162        }
1163    }
1164    unreachable!("name collision space exhausted")
1165}
1166
1167fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
1168    if !op_id.is_empty() {
1169        return op_id.to_string();
1170    }
1171    let path_part: String = path
1172        .chars()
1173        .map(|c| if c.is_alphanumeric() { c } else { '_' })
1174        .collect();
1175    format!("{method}_{path_part}")
1176}
1177
1178/// Returns true if the type expression is already nullable (wrapped in None),
1179/// so that the caller can avoid double-wrapping with TypeName::optional.
1180fn is_already_optional(expr: &IrTypeExpr) -> bool {
1181    matches!(expr, IrTypeExpr::Nullable(_))
1182}