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