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::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
291                | BodyEncoding::TextPlain
292                | BodyEncoding::OctetStream => request_args.push(format!("data={body_expr}")),
293                BodyEncoding::Xml | BodyEncoding::Other => {
294                    if body.required {
295                        cb.add_statement(
296                            &format!(
297                                "raise ValueError(\"unsupported request body media type: {}\")",
298                                body.media_type
299                            ),
300                            (),
301                        );
302                    } else {
303                        cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
304                        cb.add_statement(
305                            &format!(
306                                "raise ValueError(\"unsupported request body media type: {}\")%<",
307                                body.media_type
308                            ),
309                            (),
310                        );
311                    }
312                }
313                BodyEncoding::Multipart => unreachable!("multipart handled separately"),
314            }
315        }
316    }
317    if has_headers {
318        request_args.push("headers=headers".to_string());
319    }
320
321    cb.add_statement(
322        &format!(
323            "response = self._client.request({})",
324            request_args.join(", "),
325        ),
326        (),
327    );
328
329    // Error handling
330    cb.add_statement("if response.status_code >= 400:%>", ());
331    cb.add_statement(
332        "raise %T(response.status_code, response.reason, response.content)%<",
333        (error_type.clone(),),
334    );
335
336    // Response parsing
337    if !plan.typed_responses.is_empty() {
338        let tr = &plan.typed_responses[0];
339        let parse_expr = render_response_parse(tr, ir);
340        cb.add_statement(&format!("return {parse_expr}"), ());
341    } else {
342        cb.add_statement("return None", ());
343    }
344
345    cb.build().expect("API method body builds")
346}
347
348fn emit_multipart_data(
349    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
350    body: &BodyBinding,
351    parts: &[MultipartPart],
352    ir: &IrSpec,
353) {
354    cb.add_statement("files: dict[str, object] = {}", ());
355    if !body.required {
356        cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
357    }
358    for part in parts {
359        let access = format!("{}.{}", body.var_name, part.field_name);
360        if part.required {
361            emit_required_multipart_part(cb, part, &access, ir);
362        } else {
363            cb.add_statement(&format!("if {access} is not None:%>"), ());
364            emit_required_multipart_part(cb, part, &access, ir);
365            cb.add_statement("%<", ());
366        }
367    }
368    if !body.required {
369        cb.add_statement("%<", ());
370    }
371}
372
373fn emit_required_multipart_part(
374    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
375    part: &MultipartPart,
376    access: &str,
377    ir: &IrSpec,
378) {
379    if part.is_binary {
380        cb.add_statement(
381            &format!(
382                "files[\"{}\"] = (\"{}\", {access}, \"{}\")",
383                part.wire_name, part.wire_name, part.content_type
384            ),
385            (),
386        );
387    } else if part.value_encoding == MultipartValueEncoding::Json {
388        let json_value = render_multipart_json_value(access, &part.type_expr, ir);
389        cb.add_statement(
390            &format!(
391                "files[\"{}\"] = (None, json.dumps({json_value}), \"{}\")",
392                part.wire_name, part.content_type
393            ),
394            (),
395        );
396    } else if part.value_encoding == MultipartValueEncoding::Unsupported {
397        cb.add_statement(
398            "raise ValueError(\"unsupported multipart part content type\")",
399            (),
400        );
401    } else {
402        cb.add_statement(
403            &format!(
404                "files[\"{}\"] = (None, str({access}), \"{}\")",
405                part.wire_name, part.content_type
406            ),
407            (),
408        );
409    }
410}
411
412fn render_multipart_json_value(access: &str, expr: &IrTypeExpr, ir: &IrSpec) -> String {
413    match expr {
414        IrTypeExpr::Named(name) if is_object_schema(name, ir) => format!("{access}.to_dict()"),
415        IrTypeExpr::Nullable(inner) => render_multipart_json_value(access, inner, ir),
416        IrTypeExpr::Array(inner) => {
417            if let IrTypeExpr::Named(name) = inner.as_ref()
418                && is_object_schema(name, ir)
419            {
420                format!("[item.to_dict() for item in {access}]")
421            } else {
422                access.to_string()
423            }
424        }
425        _ => access.to_string(),
426    }
427}
428
429fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
430    match type_expr {
431        IrTypeExpr::Primitive(
432            IrPrimitive::String
433            | IrPrimitive::Date
434            | IrPrimitive::DateTime
435            | IrPrimitive::Uuid
436            | IrPrimitive::StringWithFormat(_),
437        )
438        | IrTypeExpr::StringLiteral(_)
439        | IrTypeExpr::StringEnum(_)
440        | IrTypeExpr::Named(_) => format!("str({var})"),
441        IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
442        IrTypeExpr::Primitive(
443            IrPrimitive::Integer
444            | IrPrimitive::IntegerWithFormat(_)
445            | IrPrimitive::Number
446            | IrPrimitive::NumberWithFormat(_),
447        ) => format!("str({var})"),
448        IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
449        IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
450        _ => format!("str({var})"),
451    }
452}
453
454fn response_type_name(response: &TypedResponse) -> TypeName {
455    match response.decoding {
456        ResponseDecoding::Json => api_type_name(&response.type_expr),
457        ResponseDecoding::Text => TypeName::primitive("str"),
458        ResponseDecoding::Bytes => TypeName::primitive("bytes"),
459    }
460}
461
462fn render_response_parse(response: &TypedResponse, ir: &IrSpec) -> String {
463    match response.decoding {
464        ResponseDecoding::Json => render_json_response_parse(&response.type_expr, ir),
465        ResponseDecoding::Text => "response.text".to_string(),
466        ResponseDecoding::Bytes => "response.content".to_string(),
467    }
468}
469
470fn render_json_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
471    match type_expr {
472        IrTypeExpr::Named(name) => {
473            let py_name = name.to_pascal_case();
474            if is_object_schema(name, ir) {
475                format!("{py_name}.from_dict(response.json())")
476            } else {
477                "response.json()  # type: ignore[return-value]".to_string()
478            }
479        }
480        IrTypeExpr::Array(inner) => {
481            if let IrTypeExpr::Named(name) = inner.as_ref()
482                && is_object_schema(name, ir)
483            {
484                let py_name = name.to_pascal_case();
485                return format!("[{py_name}.from_dict(item) for item in response.json()]");
486            }
487            "response.json()  # type: ignore[return-value]".to_string()
488        }
489        IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
490            "response.text".to_string()
491        }
492        _ => "response.json()  # type: ignore[return-value]".to_string(),
493    }
494}
495
496fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
497    if let IrTypeExpr::Named(name) = type_expr {
498        return is_object_schema(name, ir);
499    }
500    false
501}
502
503fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
504    if let IrTypeExpr::Array(inner) = type_expr
505        && let IrTypeExpr::Named(name) = inner.as_ref()
506    {
507        return is_object_schema(name, ir);
508    }
509    false
510}
511
512// ---------------------------------------------------------------------------
513// Planning
514// ---------------------------------------------------------------------------
515
516struct OpPlan<'a> {
517    op: &'a IrOperation,
518    method_name: String,
519    path_params: Vec<ParamBinding<'a>>,
520    query_params: Vec<ParamBinding<'a>>,
521    header_params: Vec<ParamBinding<'a>>,
522    body: Option<BodyBinding>,
523    typed_responses: Vec<TypedResponse>,
524}
525
526struct ParamBinding<'a> {
527    param: &'a IrParameter,
528    var_name: String,
529}
530
531struct BodyBinding {
532    var_name: String,
533    type_expr: IrTypeExpr,
534    required: bool,
535    media_type: String,
536    encoding: BodyEncoding,
537    multipart_parts: Option<Vec<MultipartPart>>,
538}
539
540struct MultipartPart {
541    wire_name: String,
542    field_name: String,
543    type_expr: IrTypeExpr,
544    is_binary: bool,
545    required: bool,
546    content_type: String,
547    value_encoding: MultipartValueEncoding,
548}
549
550#[derive(Clone, Copy, PartialEq, Eq)]
551enum BodyEncoding {
552    Json,
553    Multipart,
554    FormUrlEncoded,
555    Xml,
556    TextPlain,
557    OctetStream,
558    Other,
559}
560
561#[derive(Clone, Copy, PartialEq, Eq)]
562enum ResponseDecoding {
563    Json,
564    Text,
565    Bytes,
566}
567
568struct TypedResponse {
569    type_expr: IrTypeExpr,
570    decoding: ResponseDecoding,
571}
572
573fn plan_operation<'a>(op: &'a IrOperation, ir: &IrSpec) -> OpPlan<'a> {
574    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
575    let method_name = op_id.to_snake_case();
576
577    let mut used_names: HashSet<String> = HashSet::new();
578    used_names.insert("self".to_string());
579
580    let mut path_params = Vec::new();
581    let mut query_params = Vec::new();
582    let mut header_params = Vec::new();
583
584    for p in &op.parameters {
585        let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
586        let binding = ParamBinding { param: p, var_name };
587        match p.location {
588            ParameterLocation::Path => path_params.push(binding),
589            ParameterLocation::Query => query_params.push(binding),
590            ParameterLocation::Header => header_params.push(binding),
591            ParameterLocation::Cookie => header_params.push(binding),
592        }
593    }
594
595    let body = op
596        .request_body
597        .as_ref()
598        .and_then(|b| plan_body(b, ir, &mut used_names));
599
600    let typed_responses = op.responses.iter().filter_map(plan_response).collect();
601
602    OpPlan {
603        op,
604        method_name,
605        path_params,
606        query_params,
607        header_params,
608        body,
609        typed_responses,
610    }
611}
612
613fn plan_body(
614    b: &IrRequestBody,
615    ir: &IrSpec,
616    used_names: &mut HashSet<String>,
617) -> Option<BodyBinding> {
618    let (media_type, t) = pick_body_content(b)?;
619    let encoding = body_encoding(&media_type);
620    let var_name = unique_name("body", used_names);
621    let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
622        multipart_parts_for(b, &media_type, ir)
623    } else {
624        None
625    };
626    Some(BodyBinding {
627        var_name,
628        type_expr: t,
629        required: b.required,
630        media_type,
631        encoding,
632        multipart_parts,
633    })
634}
635
636fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
637    let (media_type, t) = pick_response_content(r)?;
638    Some(TypedResponse {
639        type_expr: t,
640        decoding: response_decoding(&media_type),
641    })
642}
643
644fn body_encoding(media_type: &str) -> BodyEncoding {
645    let base = media_type_base(media_type);
646    if base == "multipart/form-data" {
647        BodyEncoding::Multipart
648    } else if is_json_media_type(media_type) {
649        BodyEncoding::Json
650    } else if base == "application/x-www-form-urlencoded" {
651        BodyEncoding::FormUrlEncoded
652    } else if is_xml_media_type(media_type) {
653        BodyEncoding::Xml
654    } else if base == "text/plain" {
655        BodyEncoding::TextPlain
656    } else if base == "application/octet-stream" {
657        BodyEncoding::OctetStream
658    } else {
659        BodyEncoding::Other
660    }
661}
662
663fn response_decoding(media_type: &str) -> ResponseDecoding {
664    let base = media_type_base(media_type);
665    if is_json_media_type(media_type) {
666        ResponseDecoding::Json
667    } else if base == "text/plain" || is_xml_media_type(media_type) {
668        ResponseDecoding::Text
669    } else {
670        ResponseDecoding::Bytes
671    }
672}
673
674fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
675    pick_media_type(&body.content, |media_type| {
676        media_type_base(media_type) == "application/json"
677    })
678    .or_else(|| pick_media_type(&body.content, is_json_media_type))
679    .or_else(|| {
680        pick_media_type(&body.content, |media_type| {
681            media_type_base(media_type) == "multipart/form-data"
682        })
683    })
684    .or_else(|| {
685        pick_media_type(&body.content, |media_type| {
686            media_type_base(media_type) == "application/x-www-form-urlencoded"
687        })
688    })
689    .or_else(|| pick_media_type(&body.content, is_xml_media_type))
690    .or_else(|| {
691        pick_media_type(&body.content, |media_type| {
692            media_type_base(media_type) == "text/plain"
693        })
694    })
695    .or_else(|| {
696        pick_media_type(&body.content, |media_type| {
697            media_type_base(media_type) == "application/octet-stream"
698        })
699    })
700    .or_else(|| pick_first_content(&body.content))
701}
702
703fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
704    pick_media_type(&r.content, |media_type| {
705        media_type_base(media_type) == "application/json"
706    })
707    .or_else(|| pick_media_type(&r.content, is_json_media_type))
708    .or_else(|| {
709        pick_media_type(&r.content, |media_type| {
710            media_type_base(media_type) == "application/octet-stream"
711        })
712    })
713    .or_else(|| {
714        pick_media_type(&r.content, |media_type| {
715            media_type_base(media_type) == "text/plain"
716        })
717    })
718    .or_else(|| pick_media_type(&r.content, is_xml_media_type))
719    .or_else(|| pick_first_content(&r.content))
720}
721
722fn pick_media_type(
723    content: &indexmap::IndexMap<String, IrTypeExpr>,
724    predicate: impl Fn(&str) -> bool,
725) -> Option<(String, IrTypeExpr)> {
726    content
727        .iter()
728        .find(|(media_type, _)| predicate(media_type))
729        .map(|(media_type, t)| (media_type.clone(), t.clone()))
730}
731
732fn pick_first_content(
733    content: &indexmap::IndexMap<String, IrTypeExpr>,
734) -> Option<(String, IrTypeExpr)> {
735    content
736        .iter()
737        .next()
738        .map(|(media_type, t)| (media_type.clone(), t.clone()))
739}
740
741fn media_type_base(media_type: &str) -> String {
742    media_type
743        .split(';')
744        .next()
745        .unwrap_or(media_type)
746        .trim()
747        .to_ascii_lowercase()
748}
749
750fn is_json_media_type(media_type: &str) -> bool {
751    let base = media_type_base(media_type);
752    base == "application/json" || base.ends_with("+json")
753}
754
755fn is_xml_media_type(media_type: &str) -> bool {
756    let base = media_type_base(media_type);
757    base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
758}
759
760fn multipart_parts_for(
761    body: &IrRequestBody,
762    media_type: &str,
763    ir: &IrSpec,
764) -> Option<Vec<MultipartPart>> {
765    multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
766        parts
767            .into_iter()
768            .map(|part| MultipartPart {
769                field_name: python_field_name(&part.wire_name),
770                wire_name: part.wire_name,
771                type_expr: part.type_expr,
772                is_binary: part.is_binary,
773                required: part.required,
774                content_type: part.content_type,
775                value_encoding: part.value_encoding,
776            })
777            .collect()
778    })
779}
780
781fn python_param_name(name: &str) -> String {
782    let snake = name.to_snake_case();
783    if snake.is_empty() {
784        return "param".to_string();
785    }
786    match snake.as_str() {
787        "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
788        | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
789        | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
790        | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
791            format!("{snake}_")
792        }
793        _ => snake,
794    }
795}
796
797fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
798    if used.insert(desired.to_string()) {
799        return desired.to_string();
800    }
801    for i in 2..=u32::MAX {
802        let candidate = format!("{desired}{i}");
803        if used.insert(candidate.clone()) {
804            return candidate;
805        }
806    }
807    unreachable!("name collision space exhausted")
808}
809
810fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
811    if !op_id.is_empty() {
812        return op_id.to_string();
813    }
814    let path_part: String = path
815        .chars()
816        .map(|c| if c.is_alphanumeric() { c } else { '_' })
817        .collect();
818    format!("{method}_{path_part}")
819}
820
821/// Returns true if the type expression is already nullable (wrapped in None),
822/// so that the caller can avoid double-wrapping with TypeName::optional.
823fn is_already_optional(expr: &IrTypeExpr) -> bool {
824    matches!(expr, IrTypeExpr::Nullable(_))
825}