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::ir::types::{
13    IrOperation, IrParameter, IrPrimitive, IrRequestBody, IrResponse, IrSpec, IrTypeExpr,
14    ParameterLocation,
15};
16use heck::{ToPascalCase, ToSnakeCase};
17use sigil_stitch::code_block::CodeBlock;
18use sigil_stitch::lang::python::Python;
19use sigil_stitch::prelude::*;
20
21use super::emit_models::{
22    api_type_name, future_annotations_header, is_object_schema, python_field_name,
23};
24
25/// Generate every API file from the IR.
26pub fn generate_api_files(
27    ir: &IrSpec,
28    header: &str,
29    request_inputs: &RequestInputPlan,
30) -> Result<Vec<FileInfo>, String> {
31    let by_tag = group_by_tag(&ir.operations);
32    let mut files = Vec::with_capacity(by_tag.len());
33    for (tag, ops) in &by_tag {
34        let stem = tag.to_snake_case();
35        let filename = format!("{stem}_api.py");
36        let body = emit_api_file(tag, ops, ir, header, request_inputs);
37        files.push(FileInfo::api(filename, body));
38    }
39    Ok(files)
40}
41
42fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
43    let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
44    for op in operations {
45        let tags: Vec<String> = if op.tags.is_empty() {
46            vec!["default".to_string()]
47        } else {
48            op.tags.clone()
49        };
50        for tag in tags {
51            out.entry(tag).or_default().push(op);
52        }
53    }
54    out
55}
56
57fn emit_api_file(
58    tag: &str,
59    ops: &[&IrOperation],
60    ir: &IrSpec,
61    header: &str,
62    request_inputs: &RequestInputPlan,
63) -> String {
64    let class_name = format!("{}Api", tag.to_pascal_case());
65    let plans: Vec<OpPlan> = ops
66        .iter()
67        .map(|op| plan_operation(op, ir, request_inputs))
68        .collect();
69
70    let client_type = TypeName::importable("..runtime.client", "Client");
71    let error_type = TypeName::importable("..runtime.errors", "ApiError");
72
73    // __init__ method via FunSpec
74    let init_body = CodeBlock::of("self._client = client", ()).expect("static body");
75    let init = FunSpec::builder("__init__")
76        .add_param(ParameterSpec::of("self", TypeName::primitive("")))
77        .add_param(ParameterSpec::of("client", client_type))
78        .returns(TypeName::primitive("None"))
79        .body(init_body)
80        .build()
81        .expect("__init__ FunSpec builds");
82
83    let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).add_method(init);
84
85    for plan in &plans {
86        cls = cls.add_method(build_api_method(plan, ir, &error_type));
87    }
88
89    let mut fb = FileSpec::builder_with(&format!("{}_api.py", tag.to_snake_case()), Python::new())
90        .header(future_annotations_header())
91        .add_type(cls.build().expect("API TypeSpec builds"));
92    if plans.iter().any(|plan| {
93        plan.body.as_ref().is_some_and(|body| {
94            body.multipart_parts.as_ref().is_some_and(|parts| {
95                parts
96                    .iter()
97                    .any(|part| part.value_encoding == MultipartValueEncoding::Json)
98            })
99        })
100    }) {
101        fb = fb.add_import(ImportSpec::side_effect("json"));
102    }
103    let file = fb.build().expect("API FileSpec builds");
104
105    let body = file.render(120).unwrap_or_default();
106    let mut content = String::with_capacity(header.len() + body.len());
107    content.push_str(header);
108    content.push_str(&body);
109    content
110}
111
112fn build_api_method(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> FunSpec {
113    let mut fun = FunSpec::builder(&plan.method_name);
114
115    // self (bare, no type annotation)
116    fun = fun.add_param(ParameterSpec::of("self", TypeName::primitive("")));
117
118    // Positional params (path params)
119    for p in &plan.path_params {
120        fun = fun.add_param(ParameterSpec::of(
121            &p.var_name,
122            api_type_name(&p.param.type_expr),
123        ));
124    }
125
126    // Keyword-only separator
127    let has_keyword_params =
128        !plan.query_params.is_empty() || !plan.header_params.is_empty() || plan.body.is_some();
129    if has_keyword_params {
130        fun = fun.add_param(ParameterSpec::of("*", TypeName::primitive("")));
131    }
132
133    // Required query/header params first
134    for p in plan.query_params.iter().chain(&plan.header_params) {
135        if p.param.required {
136            fun = fun.add_param(ParameterSpec::of(
137                &p.var_name,
138                api_type_name(&p.param.type_expr),
139            ));
140        }
141    }
142
143    // Body param
144    if let Some(b) = &plan.body {
145        let ty = api_type_name(&b.type_expr);
146        if b.required {
147            fun = fun.add_param(ParameterSpec::of(&b.var_name, ty));
148        } else {
149            fun = fun.add_param(
150                ParameterSpec::builder(&b.var_name, TypeName::optional(ty))
151                    .default_value(CodeBlock::of("None", ()).expect("None"))
152                    .build()
153                    .expect("optional body param"),
154            );
155        }
156    }
157
158    // Optional query/header params last
159    for p in plan.query_params.iter().chain(&plan.header_params) {
160        if !p.param.required {
161            let param_ty = api_type_name(&p.param.type_expr);
162            let param_ty = if is_already_optional(&p.param.type_expr) {
163                param_ty
164            } else {
165                TypeName::optional(param_ty)
166            };
167            fun = fun.add_param(
168                ParameterSpec::builder(&p.var_name, param_ty)
169                    .default_value(CodeBlock::of("None", ()).expect("None"))
170                    .build()
171                    .expect("optional param"),
172            );
173        }
174    }
175
176    // Return type — auto-tracked via TypeName
177    let return_type = if plan.typed_responses.is_empty() {
178        TypeName::primitive("None")
179    } else {
180        response_type_name(&plan.typed_responses[0])
181    };
182    fun = fun.returns(return_type);
183
184    // Docstring
185    if let Some(summary) = &plan.op.summary {
186        fun = fun.doc(&format!("{summary}."));
187    }
188
189    // Method body (imperative control flow, stays as CodeBlock)
190    fun = fun.body(build_method_body(plan, ir, error_type));
191
192    fun.build().expect("API method FunSpec builds")
193}
194
195fn build_method_body(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> CodeBlock {
196    let mut cb = CodeBlock::builder();
197
198    // Path interpolation
199    if plan.path_params.is_empty() {
200        cb.add_statement(&format!("path = \"{}\"", plan.op.path), ());
201    } else {
202        let mut path_template = plan.op.path.clone();
203        for p in &plan.path_params {
204            let placeholder = format!("{{{}}}", p.param.name);
205            let replacement = format!("{{{}}}", p.var_name);
206            path_template = path_template.replace(&placeholder, &replacement);
207        }
208        cb.add_statement("path = %V", VerbatimStrArg(path_template));
209    }
210
211    // Query params
212    let has_query = !plan.query_params.is_empty();
213    if has_query {
214        cb.add_statement("params: dict[str, str] = {}", ());
215        for p in &plan.query_params {
216            let stringify = render_stringify(&p.var_name, &p.param.type_expr);
217            if p.param.required {
218                cb.add_statement(&format!("params[\"{}\"] = {stringify}", p.param.name), ());
219            } else {
220                cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
221                cb.add_statement(&format!("params[\"{}\"] = {stringify}%<", p.param.name), ());
222            }
223        }
224    }
225
226    // Header params
227    let body_content_type = plan.body.as_ref().and_then(|body| {
228        let base = media_type_base(&body.media_type);
229        if base != "multipart/form-data" {
230            Some(body.media_type.as_str())
231        } else {
232            None
233        }
234    });
235    let has_headers = !plan.header_params.is_empty() || body_content_type.is_some();
236    if has_headers {
237        cb.add_statement("headers: dict[str, str] = {}", ());
238        if let Some(media_type) = body_content_type {
239            cb.add_statement(&format!("headers[\"Content-Type\"] = \"{media_type}\""), ());
240        }
241        for p in &plan.header_params {
242            let stringify = render_stringify(&p.var_name, &p.param.type_expr);
243            if p.param.required {
244                cb.add_statement(&format!("headers[\"{}\"] = {stringify}", p.param.name), ());
245            } else {
246                cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
247                cb.add_statement(
248                    &format!("headers[\"{}\"] = {stringify}%<", p.param.name),
249                    (),
250                );
251            }
252        }
253    }
254
255    // Body serialization
256    let body_expr = if let Some(b) = &plan.body {
257        if is_object_type(&b.type_expr, ir) {
258            if b.required {
259                format!("{}.to_dict()", b.var_name)
260            } else {
261                format!(
262                    "{}.to_dict() if {} is not None else None",
263                    b.var_name, b.var_name
264                )
265            }
266        } else if is_array_of_objects(&b.type_expr, ir) {
267            if b.required {
268                format!("[item.to_dict() for item in {}]", b.var_name)
269            } else {
270                format!(
271                    "[item.to_dict() for item in {}] if {} is not None else None",
272                    b.var_name, b.var_name
273                )
274            }
275        } else {
276            b.var_name.clone()
277        }
278    } else {
279        String::new()
280    };
281
282    // Request call
283    let mut request_args = vec![
284        format!("\"{}\"", plan.op.method.to_uppercase()),
285        "path".to_string(),
286    ];
287    if has_query {
288        request_args.push("params=params".to_string());
289    }
290    if let Some(body) = &plan.body {
291        if media_type_base(&body.media_type) == "multipart/form-data" {
292            if let Some(parts) = &body.multipart_parts {
293                emit_multipart_data(&mut cb, body, parts, ir);
294                request_args.push("files=files if files else None".to_string());
295            } else {
296                cb.add_statement(
297                    "raise ValueError(\"unsupported multipart request body: schema must be object-shaped\")",
298                    (),
299                );
300            }
301        } else {
302            match body.encoding {
303                BodyEncoding::Json => request_args.push(format!("json={body_expr}")),
304                BodyEncoding::FormUrlEncoded => request_args.push(format!("data={body_expr}")),
305                BodyEncoding::TextPlain | BodyEncoding::OctetStream => {
306                    request_args.push(format!("content={body_expr}"));
307                }
308                BodyEncoding::Xml | BodyEncoding::Other => {
309                    if body.required {
310                        cb.add_statement(
311                            &format!(
312                                "raise ValueError(\"unsupported request body media type: {}\")",
313                                body.media_type
314                            ),
315                            (),
316                        );
317                    } else {
318                        cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
319                        cb.add_statement(
320                            &format!(
321                                "raise ValueError(\"unsupported request body media type: {}\")%<",
322                                body.media_type
323                            ),
324                            (),
325                        );
326                    }
327                }
328                BodyEncoding::Multipart => unreachable!("multipart handled separately"),
329            }
330        }
331    }
332    if has_headers {
333        request_args.push("headers=headers".to_string());
334    }
335
336    cb.add_code(
337        sigil_quote!(Python {
338            response = self._client.request($for(arg in &request_args; separator = ", ") { $L(arg.as_str()) })
339        })
340        .expect("request call"),
341    );
342
343    // Error handling
344    cb.add_statement("if response.status_code >= 400:%>", ());
345    cb.add_statement(
346        "raise %T(response.status_code, response.reason_phrase, response.content)%<",
347        (error_type.clone(),),
348    );
349
350    // Response parsing
351    if !plan.typed_responses.is_empty() {
352        let tr = &plan.typed_responses[0];
353        let parse_expr = render_response_parse(tr, ir);
354        cb.add_statement(&format!("return {parse_expr}"), ());
355    } else {
356        cb.add_statement("return None", ());
357    }
358
359    cb.build().expect("API method body builds")
360}
361
362fn emit_multipart_data(
363    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
364    body: &BodyBinding,
365    parts: &[MultipartPart],
366    ir: &IrSpec,
367) {
368    cb.add_statement("files: dict[str, object] = {}", ());
369    if !body.required {
370        cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
371    }
372    for part in parts {
373        let access = format!("{}.{}", body.var_name, part.field_name);
374        if part.required {
375            emit_required_multipart_part(cb, part, &access, ir);
376        } else {
377            cb.add_statement(&format!("if {access} is not None:%>"), ());
378            emit_required_multipart_part(cb, part, &access, ir);
379            cb.add_statement("%<", ());
380        }
381    }
382    if !body.required {
383        cb.add_statement("%<", ());
384    }
385}
386
387fn emit_required_multipart_part(
388    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
389    part: &MultipartPart,
390    access: &str,
391    ir: &IrSpec,
392) {
393    cb.add_code(multipart_part_assignment(part, access, ir));
394}
395
396fn multipart_part_assignment(part: &MultipartPart, access: &str, ir: &IrSpec) -> CodeBlock {
397    let binary_stmt = format!(
398        "files[\"{}\"] = ({}.filename_or_default(\"{}\"), {}.data, \"{}\")",
399        part.wire_name, access, part.wire_name, access, part.content_type
400    );
401    let json_value = render_multipart_json_value(access, &part.type_expr, ir);
402    let json_stmt = format!(
403        "files[\"{}\"] = (None, json.dumps({json_value}), \"{}\")",
404        part.wire_name, part.content_type
405    );
406    let unsupported_stmt = "raise ValueError(\"unsupported multipart part content type\")";
407    let scalar_stmt = format!(
408        "files[\"{}\"] = (None, str({access}), \"{}\")",
409        part.wire_name, part.content_type
410    );
411
412    sigil_quote!(Python {
413        $if(part.is_binary) {
414            $L(binary_stmt.as_str())
415        } $else_if(part.value_encoding == MultipartValueEncoding::Json) {
416            $L(json_stmt.as_str())
417        } $else_if(part.value_encoding == MultipartValueEncoding::Unsupported) {
418            $L(unsupported_stmt)
419        } $else {
420            $L(scalar_stmt.as_str())
421        }
422    })
423    .expect("multipart part assignment builds")
424}
425
426fn render_multipart_json_value(access: &str, expr: &IrTypeExpr, ir: &IrSpec) -> String {
427    match expr {
428        IrTypeExpr::Named(name) if is_object_schema(name, ir) => format!("{access}.to_dict()"),
429        IrTypeExpr::Nullable(inner) => render_multipart_json_value(access, inner, ir),
430        IrTypeExpr::Array(inner) => {
431            if let IrTypeExpr::Named(name) = inner.as_ref()
432                && is_object_schema(name, ir)
433            {
434                format!("[item.to_dict() for item in {access}]")
435            } else {
436                access.to_string()
437            }
438        }
439        _ => access.to_string(),
440    }
441}
442
443fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
444    match type_expr {
445        IrTypeExpr::Primitive(
446            IrPrimitive::String
447            | IrPrimitive::Date
448            | IrPrimitive::DateTime
449            | IrPrimitive::Uuid
450            | IrPrimitive::StringWithFormat(_),
451        )
452        | IrTypeExpr::StringLiteral(_)
453        | IrTypeExpr::StringEnum(_)
454        | IrTypeExpr::Named(_) => format!("str({var})"),
455        IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
456        IrTypeExpr::Primitive(
457            IrPrimitive::Integer
458            | IrPrimitive::IntegerWithFormat(_)
459            | IrPrimitive::Number
460            | IrPrimitive::NumberWithFormat(_),
461        ) => format!("str({var})"),
462        IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
463        IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
464        _ => format!("str({var})"),
465    }
466}
467
468fn response_type_name(response: &TypedResponse) -> TypeName {
469    match response.decoding {
470        ResponseDecoding::Json => api_type_name(&response.type_expr),
471        ResponseDecoding::Text => TypeName::primitive("str"),
472        ResponseDecoding::Bytes => TypeName::primitive("bytes"),
473    }
474}
475
476fn render_response_parse(response: &TypedResponse, ir: &IrSpec) -> String {
477    match response.decoding {
478        ResponseDecoding::Json => render_json_response_parse(&response.type_expr, ir),
479        ResponseDecoding::Text => "response.text".to_string(),
480        ResponseDecoding::Bytes => "response.content".to_string(),
481    }
482}
483
484fn render_json_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
485    match type_expr {
486        IrTypeExpr::Named(name) => {
487            let py_name = name.to_pascal_case();
488            if is_object_schema(name, ir) {
489                format!("{py_name}.from_dict(response.json())")
490            } else {
491                "response.json()  # type: ignore[return-value]".to_string()
492            }
493        }
494        IrTypeExpr::Array(inner) => {
495            if let IrTypeExpr::Named(name) = inner.as_ref()
496                && is_object_schema(name, ir)
497            {
498                let py_name = name.to_pascal_case();
499                return format!("[{py_name}.from_dict(item) for item in response.json()]");
500            }
501            "response.json()  # type: ignore[return-value]".to_string()
502        }
503        IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
504            "response.text".to_string()
505        }
506        _ => "response.json()  # type: ignore[return-value]".to_string(),
507    }
508}
509
510fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
511    if let IrTypeExpr::Named(name) = type_expr {
512        return is_object_schema(name, ir);
513    }
514    false
515}
516
517fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
518    if let IrTypeExpr::Array(inner) = type_expr
519        && let IrTypeExpr::Named(name) = inner.as_ref()
520    {
521        return is_object_schema(name, ir);
522    }
523    false
524}
525
526// ---------------------------------------------------------------------------
527// Planning
528// ---------------------------------------------------------------------------
529
530struct OpPlan<'a> {
531    op: &'a IrOperation,
532    method_name: String,
533    path_params: Vec<ParamBinding<'a>>,
534    query_params: Vec<ParamBinding<'a>>,
535    header_params: Vec<ParamBinding<'a>>,
536    body: Option<BodyBinding>,
537    typed_responses: Vec<TypedResponse>,
538}
539
540struct ParamBinding<'a> {
541    param: &'a IrParameter,
542    var_name: String,
543}
544
545struct BodyBinding {
546    var_name: String,
547    type_expr: IrTypeExpr,
548    required: bool,
549    media_type: String,
550    encoding: BodyEncoding,
551    multipart_parts: Option<Vec<MultipartPart>>,
552}
553
554struct MultipartPart {
555    wire_name: String,
556    field_name: String,
557    type_expr: IrTypeExpr,
558    is_binary: bool,
559    required: bool,
560    content_type: String,
561    value_encoding: MultipartValueEncoding,
562}
563
564#[derive(Clone, Copy, PartialEq, Eq)]
565enum BodyEncoding {
566    Json,
567    Multipart,
568    FormUrlEncoded,
569    Xml,
570    TextPlain,
571    OctetStream,
572    Other,
573}
574
575#[derive(Clone, Copy, PartialEq, Eq)]
576enum ResponseDecoding {
577    Json,
578    Text,
579    Bytes,
580}
581
582struct TypedResponse {
583    type_expr: IrTypeExpr,
584    decoding: ResponseDecoding,
585}
586
587fn plan_operation<'a>(
588    op: &'a IrOperation,
589    ir: &IrSpec,
590    request_inputs: &RequestInputPlan,
591) -> OpPlan<'a> {
592    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
593    let method_name = op_id.to_snake_case();
594
595    let mut used_names: HashSet<String> = HashSet::new();
596    used_names.insert("self".to_string());
597
598    let mut path_params = Vec::new();
599    let mut query_params = Vec::new();
600    let mut header_params = Vec::new();
601
602    for p in &op.parameters {
603        let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
604        let binding = ParamBinding { param: p, var_name };
605        match p.location {
606            ParameterLocation::Path => path_params.push(binding),
607            ParameterLocation::Query => query_params.push(binding),
608            ParameterLocation::Header => header_params.push(binding),
609            ParameterLocation::Cookie => header_params.push(binding),
610        }
611    }
612
613    let body = op
614        .request_body
615        .as_ref()
616        .and_then(|b| plan_body(op, b, ir, request_inputs, &mut used_names));
617
618    let typed_responses = op.responses.iter().filter_map(plan_response).collect();
619
620    OpPlan {
621        op,
622        method_name,
623        path_params,
624        query_params,
625        header_params,
626        body,
627        typed_responses,
628    }
629}
630
631fn plan_body(
632    op: &IrOperation,
633    b: &IrRequestBody,
634    ir: &IrSpec,
635    request_inputs: &RequestInputPlan,
636    used_names: &mut HashSet<String>,
637) -> Option<BodyBinding> {
638    let (media_type, t) = pick_body_content(b)?;
639    let encoding = body_encoding(&media_type);
640    let var_name = unique_name("body", used_names);
641    let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
642        multipart_parts_for(b, &media_type, ir)
643    } else {
644        None
645    };
646    Some(BodyBinding {
647        var_name,
648        type_expr: if encoding == BodyEncoding::Multipart {
649            request_input_for_operation(request_inputs, op, &media_type)
650                .map(|input| IrTypeExpr::Named(input.name.clone()))
651                .unwrap_or(t)
652        } else {
653            t
654        },
655        required: b.required,
656        media_type,
657        encoding,
658        multipart_parts,
659    })
660}
661
662fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
663    let (media_type, t) = pick_response_content(r)?;
664    Some(TypedResponse {
665        type_expr: t,
666        decoding: response_decoding(&media_type),
667    })
668}
669
670fn body_encoding(media_type: &str) -> BodyEncoding {
671    let base = media_type_base(media_type);
672    if base == "multipart/form-data" {
673        BodyEncoding::Multipart
674    } else if is_json_media_type(media_type) {
675        BodyEncoding::Json
676    } else if base == "application/x-www-form-urlencoded" {
677        BodyEncoding::FormUrlEncoded
678    } else if is_xml_media_type(media_type) {
679        BodyEncoding::Xml
680    } else if base == "text/plain" {
681        BodyEncoding::TextPlain
682    } else if base == "application/octet-stream" {
683        BodyEncoding::OctetStream
684    } else {
685        BodyEncoding::Other
686    }
687}
688
689fn response_decoding(media_type: &str) -> ResponseDecoding {
690    let base = media_type_base(media_type);
691    if is_json_media_type(media_type) {
692        ResponseDecoding::Json
693    } else if base == "text/plain" || is_xml_media_type(media_type) {
694        ResponseDecoding::Text
695    } else {
696        ResponseDecoding::Bytes
697    }
698}
699
700fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
701    pick_media_type(&body.content, |media_type| {
702        media_type_base(media_type) == "application/json"
703    })
704    .or_else(|| pick_media_type(&body.content, is_json_media_type))
705    .or_else(|| {
706        pick_media_type(&body.content, |media_type| {
707            media_type_base(media_type) == "multipart/form-data"
708        })
709    })
710    .or_else(|| {
711        pick_media_type(&body.content, |media_type| {
712            media_type_base(media_type) == "application/x-www-form-urlencoded"
713        })
714    })
715    .or_else(|| pick_media_type(&body.content, is_xml_media_type))
716    .or_else(|| {
717        pick_media_type(&body.content, |media_type| {
718            media_type_base(media_type) == "text/plain"
719        })
720    })
721    .or_else(|| {
722        pick_media_type(&body.content, |media_type| {
723            media_type_base(media_type) == "application/octet-stream"
724        })
725    })
726    .or_else(|| pick_first_content(&body.content))
727}
728
729fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
730    pick_media_type(&r.content, |media_type| {
731        media_type_base(media_type) == "application/json"
732    })
733    .or_else(|| pick_media_type(&r.content, is_json_media_type))
734    .or_else(|| {
735        pick_media_type(&r.content, |media_type| {
736            media_type_base(media_type) == "application/octet-stream"
737        })
738    })
739    .or_else(|| {
740        pick_media_type(&r.content, |media_type| {
741            media_type_base(media_type) == "text/plain"
742        })
743    })
744    .or_else(|| pick_media_type(&r.content, is_xml_media_type))
745    .or_else(|| pick_first_content(&r.content))
746}
747
748fn pick_media_type(
749    content: &indexmap::IndexMap<String, IrTypeExpr>,
750    predicate: impl Fn(&str) -> bool,
751) -> Option<(String, IrTypeExpr)> {
752    content
753        .iter()
754        .find(|(media_type, _)| predicate(media_type))
755        .map(|(media_type, t)| (media_type.clone(), t.clone()))
756}
757
758fn pick_first_content(
759    content: &indexmap::IndexMap<String, IrTypeExpr>,
760) -> Option<(String, IrTypeExpr)> {
761    content
762        .iter()
763        .next()
764        .map(|(media_type, t)| (media_type.clone(), t.clone()))
765}
766
767fn media_type_base(media_type: &str) -> String {
768    media_type
769        .split(';')
770        .next()
771        .unwrap_or(media_type)
772        .trim()
773        .to_ascii_lowercase()
774}
775
776fn is_json_media_type(media_type: &str) -> bool {
777    let base = media_type_base(media_type);
778    base == "application/json" || base.ends_with("+json")
779}
780
781fn is_xml_media_type(media_type: &str) -> bool {
782    let base = media_type_base(media_type);
783    base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
784}
785
786fn multipart_parts_for(
787    body: &IrRequestBody,
788    media_type: &str,
789    ir: &IrSpec,
790) -> Option<Vec<MultipartPart>> {
791    multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
792        parts
793            .into_iter()
794            .map(|part| MultipartPart {
795                field_name: python_field_name(&part.wire_name),
796                wire_name: part.wire_name,
797                type_expr: part.type_expr,
798                is_binary: part.is_binary,
799                required: part.required,
800                content_type: part.content_type,
801                value_encoding: part.value_encoding,
802            })
803            .collect()
804    })
805}
806
807fn python_param_name(name: &str) -> String {
808    let snake = name.to_snake_case();
809    if snake.is_empty() {
810        return "param".to_string();
811    }
812    match snake.as_str() {
813        "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
814        | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
815        | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
816        | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
817            format!("{snake}_")
818        }
819        _ => snake,
820    }
821}
822
823fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
824    if used.insert(desired.to_string()) {
825        return desired.to_string();
826    }
827    for i in 2..=u32::MAX {
828        let candidate = format!("{desired}{i}");
829        if used.insert(candidate.clone()) {
830            return candidate;
831        }
832    }
833    unreachable!("name collision space exhausted")
834}
835
836fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
837    if !op_id.is_empty() {
838        return op_id.to_string();
839    }
840    let path_part: String = path
841        .chars()
842        .map(|c| if c.is_alphanumeric() { c } else { '_' })
843        .collect();
844    format!("{method}_{path_part}")
845}
846
847/// Returns true if the type expression is already nullable (wrapped in None),
848/// so that the caller can avoid double-wrapping with TypeName::optional.
849fn is_already_optional(expr: &IrTypeExpr) -> bool {
850    matches!(expr, IrTypeExpr::Nullable(_))
851}