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