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