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