Skip to main content

openapi_nexus/generators/java/okhttp/
sigil_emit_api.rs

1use std::collections::{BTreeMap, HashSet};
2
3use crate::codegen::traits::file_writer::FileInfo;
4use crate::generators::multipart::{MultipartValueEncoding, multipart_parts_for_request_body};
5use crate::generators::request_inputs::{RequestInputPlan, request_input_for_operation};
6use crate::generators::response_names::{
7    response_entry_name as response_variant_name, response_match_rank,
8};
9use crate::ir::types::{
10    IrOperation, IrParameter, IrRequestBody, IrResponse, IrSpec, IrTypeExpr, ParameterLocation,
11};
12use heck::{ToLowerCamelCase, ToPascalCase};
13use sigil_stitch::lang::java::Java;
14use sigil_stitch::prelude::*;
15
16use super::util::{
17    build_java_getter, java_boxed_type_str, java_field_name, java_ident, java_type_str,
18    render_value_as_string, sanitize_operation_id, unique_name,
19};
20
21const RENDER_WIDTH: usize = 100;
22
23pub fn generate_api_files(
24    ir: &IrSpec,
25    package_name: &str,
26    header: &str,
27    request_inputs: &RequestInputPlan,
28) -> Result<Vec<FileInfo>, String> {
29    let by_tag = group_by_tag(&ir.operations);
30    let mut files = Vec::with_capacity(by_tag.len());
31    let mut support_files_seen = HashSet::new();
32    let has_models = has_java_models(ir, request_inputs);
33    for (tag, ops) in &by_tag {
34        let class_name = format!("{}Api", tag.to_pascal_case());
35        let filename = format!("{class_name}.java");
36        let body = emit_api_file(tag, ops, ir, package_name, request_inputs, has_models);
37        let content = format!("{header}{body}");
38        files.push(FileInfo::api(filename, content));
39        for op in ops {
40            let plan = plan_operation(op, ir, request_inputs);
41            for file in operation_support_files(&plan, package_name, header, has_models) {
42                if support_files_seen.insert(file.filename.clone()) {
43                    files.push(file);
44                }
45            }
46        }
47    }
48    Ok(files)
49}
50
51fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
52    let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
53    for op in operations {
54        let tags: Vec<String> = if op.tags.is_empty() {
55            vec!["default".to_string()]
56        } else {
57            op.tags.clone()
58        };
59        for tag in tags {
60            out.entry(tag).or_default().push(op);
61        }
62    }
63    out
64}
65
66fn has_java_models(ir: &IrSpec, request_inputs: &RequestInputPlan) -> bool {
67    !ir.schemas.is_empty() || !request_inputs.models().is_empty()
68}
69
70// ---------------------------------------------------------------------------
71// File assembly
72// ---------------------------------------------------------------------------
73
74fn emit_api_file(
75    tag: &str,
76    ops: &[&IrOperation],
77    ir: &IrSpec,
78    package_name: &str,
79    request_inputs: &RequestInputPlan,
80    has_models: bool,
81) -> String {
82    let class_name = format!("{}Api", tag.to_pascal_case());
83    let plans: Vec<OpPlan> = ops
84        .iter()
85        .map(|op| plan_operation(op, ir, request_inputs))
86        .collect();
87
88    let filename = format!("{class_name}.java");
89    let mut fb = FileSpec::builder_with(&filename, Java::new())
90        .header(package_header(package_name))
91        .add_import(ImportSpec::named(
92            &format!("{package_name}.runtime"),
93            "ApiClient",
94        ))
95        .add_import(ImportSpec::named(
96            &format!("{package_name}.runtime"),
97            "ApiException",
98        ))
99        .add_import(ImportSpec::named("com.google.gson", "Gson"))
100        .add_import(ImportSpec::named("com.google.gson.reflect", "TypeToken"))
101        .add_import(ImportSpec::named("java.io", "IOException"))
102        .add_import(ImportSpec::named("java.nio.charset", "StandardCharsets"))
103        .add_import(ImportSpec::named("java.util", "HashMap"))
104        .add_import(ImportSpec::named("java.util", "List"))
105        .add_import(ImportSpec::named("java.util", "Map"))
106        .add_import(ImportSpec::named("java.util.stream", "Collectors"))
107        .add_import(ImportSpec::named("okhttp3", "Request"))
108        .add_import(ImportSpec::named("okhttp3", "Response"));
109    if has_models {
110        fb = fb.add_import(ImportSpec::named(&format!("{package_name}.models"), "*"));
111    }
112    let has_supported_multipart_body = plans.iter().any(|plan| {
113        plan.body.as_ref().is_some_and(|body| {
114            media_type_base(&body.media_type) == "multipart/form-data"
115                && body.multipart_parts.is_some()
116        })
117    });
118    let has_raw_request_body = plans.iter().any(|plan| plan.body.is_some());
119    if has_supported_multipart_body {
120        fb = fb.add_import(ImportSpec::named("okhttp3", "MultipartBody"));
121    }
122    if has_raw_request_body {
123        fb = fb.add_import(ImportSpec::named("okhttp3", "RequestBody"));
124    }
125    if has_supported_multipart_body || has_raw_request_body {
126        fb = fb.add_import(ImportSpec::named("okhttp3", "MediaType"));
127    }
128
129    // API class
130    let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).visibility(Visibility::Public);
131    cls = cls.doc(&format!(
132        "{class_name} groups operations under the {tag} tag."
133    ));
134
135    // Fields
136    cls = cls.add_field(
137        FieldSpec::builder("client", TypeName::primitive("ApiClient"))
138            .visibility(Visibility::Private)
139            .is_readonly()
140            .build()
141            .expect("client field"),
142    );
143    cls = cls.add_field(
144        FieldSpec::builder("gson", TypeName::primitive("Gson"))
145            .visibility(Visibility::Private)
146            .is_readonly()
147            .initializer(CodeBlock::of("new Gson()", ()).expect("gson init"))
148            .build()
149            .expect("gson field"),
150    );
151
152    // Constructor
153    let mut ctor = FunSpec::builder(&class_name);
154    ctor = ctor.visibility(Visibility::Public);
155    ctor = ctor.add_param(
156        ParameterSpec::new("ApiClient client", TypeName::primitive("")).expect("client param"),
157    );
158    let ctor_body = sigil_quote!(Java {
159        this.client = client;
160    })
161    .expect("ctor body");
162    ctor = ctor.body(ctor_body);
163    cls = cls.add_method(ctor.build().expect("constructor"));
164
165    // API methods
166    for plan in &plans {
167        cls = cls.add_method(build_operation_fun(plan));
168    }
169
170    fb = fb.add_type(cls.build().expect("API class builds"));
171
172    let file = fb.build().expect("FileSpec builds for API file");
173    file.render(RENDER_WIDTH)
174        .expect("FileSpec renders for API file")
175}
176
177fn package_header(package_name: &str) -> CodeBlock {
178    sigil_quote!(Java {
179        package $L(format!("{package_name}.apis"));
180    })
181    .expect("package header builds")
182}
183
184fn operation_support_files(
185    plan: &OpPlan<'_>,
186    package_name: &str,
187    header: &str,
188    has_models: bool,
189) -> Vec<FileInfo> {
190    let mut files = Vec::new();
191    let mut response_imports = vec![
192        ImportSpec::named("java.util", "List"),
193        ImportSpec::named("java.util", "Map"),
194        ImportSpec::named("okhttp3", "Response"),
195    ];
196    if has_models {
197        response_imports.push(ImportSpec::named(&format!("{package_name}.models"), "*"));
198    }
199    files.push(java_type_file(
200        &plan.response_type,
201        package_name,
202        header,
203        response_imports,
204        build_response_class(plan),
205    ));
206
207    let detail_interface = format!("{}Detail", plan.error_type);
208    let mut detail_impl_names = Vec::new();
209    let mut detail_seen = HashSet::new();
210    for response in &plan.error_responses {
211        if !detail_seen.insert(response.field_name.clone()) {
212            continue;
213        }
214        detail_impl_names.push(format!(
215            "{}{}",
216            plan.method_name.to_pascal_case(),
217            response.field_name
218        ));
219    }
220    let unexpected = format!("{}Unexpected", plan.method_name.to_pascal_case());
221    detail_impl_names.push(unexpected.clone());
222    files.push(java_code_file(
223        &detail_interface,
224        package_name,
225        header,
226        Vec::new(),
227        java_error_detail_interface(&detail_interface, &detail_impl_names),
228    ));
229
230    files.push(java_code_file(
231        &plan.error_type,
232        package_name,
233        header,
234        vec![
235            ImportSpec::named(&format!("{package_name}.runtime"), "ApiException"),
236            ImportSpec::named("okhttp3", "Headers"),
237        ],
238        java_error_exception_class(&plan.error_type, &detail_interface),
239    ));
240
241    let mut seen = HashSet::new();
242    for response in &plan.error_responses {
243        if !seen.insert(response.field_name.clone()) {
244            continue;
245        }
246        let class_name = format!(
247            "{}{}",
248            plan.method_name.to_pascal_case(),
249            response.field_name
250        );
251        files.push(java_code_file(
252            &class_name,
253            package_name,
254            header,
255            error_detail_imports(package_name, has_models),
256            java_error_detail_class(&class_name, &detail_interface, response),
257        ));
258    }
259
260    files.push(java_code_file(
261        &unexpected,
262        package_name,
263        header,
264        Vec::new(),
265        java_unexpected_detail_class(&unexpected, &detail_interface),
266    ));
267    files
268}
269
270fn java_type_file(
271    class_name: &str,
272    package_name: &str,
273    header: &str,
274    imports: Vec<ImportSpec>,
275    type_spec: TypeSpec,
276) -> FileInfo {
277    let filename = format!("{class_name}.java");
278    let mut fb =
279        FileSpec::builder_with(&filename, Java::new()).header(package_header(package_name));
280    for import in imports {
281        fb = fb.add_import(import);
282    }
283    fb = fb.add_type(type_spec);
284    let file = fb.build().expect("Java support type file builds");
285    FileInfo::api(
286        filename,
287        format!(
288            "{header}{}",
289            file.render(RENDER_WIDTH)
290                .expect("Java support type file renders")
291        ),
292    )
293}
294
295fn java_code_file(
296    class_name: &str,
297    package_name: &str,
298    header: &str,
299    imports: Vec<ImportSpec>,
300    code: CodeBlock,
301) -> FileInfo {
302    let filename = format!("{class_name}.java");
303    let mut fb =
304        FileSpec::builder_with(&filename, Java::new()).header(package_header(package_name));
305    for import in imports {
306        fb = fb.add_import(import);
307    }
308    fb = fb.add_code(code);
309    let file = fb.build().expect("Java support code file builds");
310    FileInfo::api(
311        filename,
312        format!(
313            "{header}{}",
314            file.render(RENDER_WIDTH)
315                .expect("Java support code file renders")
316        ),
317    )
318}
319
320fn error_detail_imports(package_name: &str, has_models: bool) -> Vec<ImportSpec> {
321    let mut imports = vec![
322        ImportSpec::named("com.google.gson", "Gson"),
323        ImportSpec::named("com.google.gson.reflect", "TypeToken"),
324        ImportSpec::named("java.nio.charset", "StandardCharsets"),
325        ImportSpec::named("java.util", "List"),
326        ImportSpec::named("java.util", "Map"),
327    ];
328    if has_models {
329        imports.push(ImportSpec::named(&format!("{package_name}.models"), "*"));
330    }
331    imports
332}
333
334// ---------------------------------------------------------------------------
335// Response class
336// ---------------------------------------------------------------------------
337
338fn build_response_class(plan: &OpPlan<'_>) -> TypeSpec {
339    let mut tb =
340        TypeSpec::builder(&plan.response_type, TypeKind::Struct).visibility(Visibility::Public);
341    tb = tb.doc(&format!(
342        "{} carries the response from {}.",
343        plan.response_type, plan.method_name
344    ));
345
346    // Fields
347    tb = tb.add_field(
348        FieldSpec::builder("statusCode", TypeName::primitive("int"))
349            .visibility(Visibility::Private)
350            .is_readonly()
351            .build()
352            .expect("field"),
353    );
354    tb = tb.add_field(
355        FieldSpec::builder("raw", TypeName::primitive("Response"))
356            .visibility(Visibility::Private)
357            .is_readonly()
358            .build()
359            .expect("field"),
360    );
361
362    let mut seen: HashSet<String> = HashSet::new();
363    for tr in &plan.typed_responses {
364        if !seen.insert(tr.field_name.clone()) {
365            continue;
366        }
367        tb = tb.add_field(
368            FieldSpec::builder(&tr.field_name, TypeName::primitive(&tr.java_type))
369                .visibility(Visibility::Private)
370                .is_readonly()
371                .build()
372                .expect("field"),
373        );
374    }
375
376    // Constructor
377    let mut ctor = FunSpec::builder(&plan.response_type);
378    ctor = ctor.visibility(Visibility::Public);
379    ctor = ctor
380        .add_param(ParameterSpec::new("int statusCode", TypeName::primitive("")).expect("param"));
381    ctor =
382        ctor.add_param(ParameterSpec::new("Response raw", TypeName::primitive("")).expect("param"));
383    let mut ctor_seen: HashSet<String> = HashSet::new();
384    for tr in &plan.typed_responses {
385        if !ctor_seen.insert(tr.field_name.clone()) {
386            continue;
387        }
388        ctor = ctor.add_param(
389            ParameterSpec::new(
390                &format!("{} {}", tr.java_type, tr.field_name),
391                TypeName::primitive(""),
392            )
393            .expect("param"),
394        );
395    }
396    let mut assignment_fields = Vec::new();
397    let mut body_seen: HashSet<String> = HashSet::new();
398    for tr in &plan.typed_responses {
399        if !body_seen.insert(tr.field_name.clone()) {
400            continue;
401        }
402        assignment_fields.push(tr.field_name.clone());
403    }
404    let ctor_body = sigil_quote!(Java {
405        this.statusCode = statusCode;
406        this.raw = raw;
407        $for(field_name in &assignment_fields) {
408            this.$L(field_name.as_str()) = $L(field_name.as_str());
409        }
410    })
411    .expect("ctor body");
412    ctor = ctor.body(ctor_body);
413    tb = tb.add_method(ctor.build().expect("response ctor"));
414
415    // Getters
416    tb = tb.add_method(build_java_getter("getStatusCode", "int", "statusCode"));
417    tb = tb.add_method(build_java_getter("getRaw", "Response", "raw"));
418
419    let mut getter_seen: HashSet<String> = HashSet::new();
420    for tr in &plan.typed_responses {
421        if !getter_seen.insert(tr.field_name.clone()) {
422            continue;
423        }
424        let getter_name = format!("get{}", tr.field_name.to_pascal_case());
425        tb = tb.add_method(build_java_getter(
426            &getter_name,
427            &tr.java_type,
428            &tr.field_name,
429        ));
430    }
431
432    tb.build().expect("response class builds")
433}
434
435fn java_error_detail_interface(detail_interface: &str, permits: &[String]) -> CodeBlock {
436    let permits_clause = permits.join(", ");
437    CodeBlock::of(
438        &format!(
439            "public sealed interface {detail_interface} permits {permits_clause} {{\n    int statusCode();\n    okhttp3.Headers headers();\n    byte[] rawBody();\n}}\n"
440        ),
441        (),
442    )
443    .expect("Java error detail interface builds")
444}
445
446fn java_error_exception_class(error_type: &str, detail_interface: &str) -> CodeBlock {
447    CodeBlock::of(
448        &format!(
449            "public final class {error_type} extends ApiException {{\n    private final {detail_interface} detail;\n    private final Headers headers;\n    private final byte[] rawBody;\n\n    public {error_type}(int statusCode, String status, String body, Headers headers, byte[] rawBody, {detail_interface} detail) {{\n        super(statusCode, status, body);\n        this.headers = headers;\n        this.rawBody = rawBody.clone();\n        this.detail = detail;\n    }}\n\n    public {detail_interface} detail() {{\n        return this.detail;\n    }}\n\n    public Headers headers() {{\n        return this.headers;\n    }}\n\n    public byte[] rawBody() {{\n        return this.rawBody.clone();\n    }}\n}}\n"
450        ),
451        (),
452    )
453    .expect("Java error exception class builds")
454}
455
456fn java_unexpected_detail_class(unexpected: &str, detail_interface: &str) -> CodeBlock {
457    CodeBlock::of(
458        &format!(
459            "public final class {unexpected} implements {detail_interface} {{\n    private final int statusCode;\n    private final okhttp3.Headers headers;\n    private final byte[] rawBody;\n\n    public {unexpected}(int statusCode, okhttp3.Headers headers, byte[] rawBody) {{\n        this.statusCode = statusCode;\n        this.headers = headers;\n        this.rawBody = rawBody.clone();\n    }}\n\n    public int statusCode() {{ return this.statusCode; }}\n    public okhttp3.Headers headers() {{ return this.headers; }}\n    public byte[] rawBody() {{ return this.rawBody.clone(); }}\n    public byte[] body() {{ return this.rawBody.clone(); }}\n}}\n"
460        ),
461        (),
462    )
463    .expect("Java unexpected detail class builds")
464}
465
466fn java_error_detail_class(
467    class_name: &str,
468    detail_interface: &str,
469    response: &TypedResponse,
470) -> CodeBlock {
471    let body_method = java_error_body_method(response);
472    let text_helper = java_error_text_helper(response);
473    let block = format!(
474        "public final class {class_name} implements {detail_interface} {{\n    private final int statusCode;\n    private final okhttp3.Headers headers;\n    private final byte[] rawBody;\n\n    public {class_name}(int statusCode, okhttp3.Headers headers, byte[] rawBody) {{\n        this.statusCode = statusCode;\n        this.headers = headers;\n        this.rawBody = rawBody.clone();\n    }}\n\n    public int statusCode() {{ return this.statusCode; }}\n    public okhttp3.Headers headers() {{ return this.headers; }}\n    public byte[] rawBody() {{ return this.rawBody.clone(); }}\n{text_helper}{body_method}\n}}\n"
475    );
476    CodeBlock::of(&block, ()).expect("Java error detail class builds")
477}
478
479fn java_error_text_helper(response: &TypedResponse) -> &'static str {
480    match response.decoding {
481        ResponseDecoding::Json | ResponseDecoding::Text => {
482            "\n    private String textBody() {\n        String contentTypeHeader = this.headers.get(\"Content-Type\");\n        okhttp3.MediaType contentType = contentTypeHeader != null ? okhttp3.MediaType.parse(contentTypeHeader) : null;\n        java.nio.charset.Charset charset = contentType != null ? contentType.charset(StandardCharsets.UTF_8) : StandardCharsets.UTF_8;\n        return new String(this.rawBody, charset);\n    }\n"
483        }
484        ResponseDecoding::Bytes => "",
485    }
486}
487
488fn java_error_body_method(response: &TypedResponse) -> String {
489    match response.decoding {
490        ResponseDecoding::Json => format!(
491            "    public {} body() {{\n        String text = textBody();\n        return new Gson().fromJson(text.isEmpty() ? \"null\" : text, new TypeToken<{}>() {{}}.getType());\n    }}",
492            response.java_type, response.java_type
493        ),
494        ResponseDecoding::Text => {
495            "    public String body() {\n        return textBody();\n    }".to_string()
496        }
497        ResponseDecoding::Bytes => {
498            "    public byte[] body() {\n        return this.rawBody.clone();\n    }".to_string()
499        }
500    }
501}
502
503// ---------------------------------------------------------------------------
504// Operation method
505// ---------------------------------------------------------------------------
506
507fn build_operation_fun(plan: &OpPlan<'_>) -> FunSpec {
508    let mut fb = FunSpec::builder(&plan.method_name);
509    fb = fb.visibility(Visibility::Public);
510
511    if let Some(summary) = &plan.op.summary {
512        fb = fb.doc(summary);
513    } else {
514        fb = fb.doc(&format!(
515            "{} {} {}.",
516            plan.method_name,
517            plan.op.method.to_uppercase(),
518            plan.op.path,
519        ));
520    }
521
522    // Parameters
523    for p in plan
524        .path_params
525        .iter()
526        .chain(&plan.query_params)
527        .chain(&plan.header_params)
528    {
529        fb = fb.add_param(
530            ParameterSpec::new(
531                &format!("{} {}", p.java_type, p.var_name),
532                TypeName::primitive(""),
533            )
534            .expect("param"),
535        );
536    }
537    if let Some(body) = &plan.body {
538        fb = fb.add_param(
539            ParameterSpec::new(
540                &format!("{} {}", body.java_type, body.var_name),
541                TypeName::primitive(""),
542            )
543            .expect("body param"),
544        );
545    }
546
547    fb = fb.returns(TypeName::primitive(&plan.response_type));
548    fb = fb.suffix("throws IOException");
549    fb = fb.body(emit_method_body(plan));
550
551    fb.build().expect("operation FunSpec builds")
552}
553
554// ---------------------------------------------------------------------------
555// Method body
556// ---------------------------------------------------------------------------
557
558fn emit_method_body(plan: &OpPlan<'_>) -> CodeBlock {
559    let mut cb = CodeBlock::builder();
560
561    // Path
562    let mut path_expr = format!("\"{}\"", plan.op.path);
563    for p in &plan.path_params {
564        let placeholder = format!("{{{}}}", p.param.name);
565        let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
566        path_expr = format!("{path_expr}.replace(\"{placeholder}\", {stringified})");
567    }
568    cb.add_statement(&format!("String path = {path_expr}"), ());
569
570    // Query
571    let has_query = !plan.query_params.is_empty();
572    if has_query {
573        cb.add_statement("Map<String, String> query = new HashMap<>()", ());
574        for p in &plan.query_params {
575            let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
576            cb.add_code(java_query_param_put(
577                p.param.required,
578                &p.var_name,
579                &p.param.name,
580                &stringified,
581            ));
582        }
583    }
584
585    // Build request
586    let method = plan.op.method.to_uppercase();
587    if let Some(body) = &plan.body {
588        if let Some(message) = unsupported_request_body_message(body) {
589            cb.add_code(
590                sigil_quote!(Java {
591                    throw new IllegalArgumentException($S(message));
592                })
593                .expect("unsupported request body builds"),
594            );
595            return cb.build().expect("method body builds");
596        }
597        cb.add_statement("Request request", ());
598        if body.encoding == BodyEncoding::Multipart {
599            if let Some(parts) = &body.multipart_parts {
600                emit_multipart_body(&mut cb, body, parts);
601                cb.add_code(java_new_request_with_body(
602                    &method,
603                    has_query,
604                    "multipartBody",
605                ));
606            } else {
607                cb.add_statement(
608                    "throw new IllegalArgumentException(\"unsupported multipart request body: schema must be object-shaped\")",
609                    (),
610                );
611            }
612        } else {
613            emit_request_body(&mut cb, body);
614            cb.add_code(java_new_request_with_body(
615                &method,
616                has_query,
617                "requestBody",
618            ));
619        }
620    } else {
621        cb.add_code(java_new_request(&method, has_query));
622    }
623
624    // Headers
625    for p in &plan.header_params {
626        let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
627        cb.add_code(java_header_param_set(
628            p.param.required,
629            &p.var_name,
630            &p.param.name,
631            &stringified,
632        ));
633    }
634
635    // Execute
636    cb.add_statement("Response response = client.execute(request)", ());
637    cb.add_line();
638    cb.add_code(java_error_throw(plan));
639
640    // Response parsing
641    if !plan.typed_responses.is_empty() {
642        cb.add_statement(
643            "byte[] responseBytes = response.body() != null ? response.body().bytes() : new byte[0]",
644            (),
645        );
646        cb.add_statement(
647            "String responseText = new String(responseBytes, StandardCharsets.UTF_8)",
648            (),
649        );
650        let mut seen: HashSet<String> = HashSet::new();
651
652        // Numeric status codes
653        for tr in &plan.typed_responses {
654            if !seen.insert(tr.field_name.clone()) {
655                continue;
656            }
657            cb.add_statement(&format!("{} {} = null", tr.java_type, tr.field_name), ());
658        }
659        cb.add_code(java_response_decode_assignments(&plan.typed_responses));
660
661        // Return with typed fields
662        let args: Vec<String> = std::iter::once("response.code()".to_string())
663            .chain(std::iter::once("response".to_string()))
664            .chain(plan.typed_responses.iter().map(|tr| tr.field_name.clone()))
665            .collect();
666        // deduplicate
667        let mut dedup_args: Vec<String> = Vec::new();
668        let mut args_seen: HashSet<String> = HashSet::new();
669        for a in args {
670            if args_seen.insert(a.clone()) {
671                dedup_args.push(a);
672            }
673        }
674        cb.add_code(
675            sigil_quote!(Java {
676                return new $N(plan.response_type.as_str())($for(arg in &dedup_args; separator = ", ") { $L(arg.as_str()) });
677            })
678            .expect("typed response constructor return"),
679        );
680    } else {
681        cb.add_statement(
682            &format!(
683                "return new {}(response.code(), response)",
684                plan.response_type
685            ),
686            (),
687        );
688    }
689
690    cb.build().expect("method body builds")
691}
692
693fn unsupported_request_body_message(body: &BodyBinding) -> Option<String> {
694    if body.encoding == BodyEncoding::Multipart && body.multipart_parts.is_none() {
695        return Some(
696            "unsupported multipart request body: schema must be object-shaped".to_string(),
697        );
698    }
699    match body.encoding {
700        BodyEncoding::FormUrlEncoded | BodyEncoding::Xml | BodyEncoding::Other => Some(format!(
701            "unsupported request body media type: {}",
702            body.media_type
703        )),
704        _ => None,
705    }
706}
707
708fn java_new_request(method: &str, has_query: bool) -> CodeBlock {
709    let with_query =
710        format!("Request request = client.newRequest(\"{method}\", path, query, null);");
711    let without_query =
712        format!("Request request = client.newRequest(\"{method}\", path, null, null);");
713    sigil_quote!(Java {
714        $if(has_query) {
715            $L(with_query.as_str())
716        } $else {
717            $L(without_query.as_str())
718        }
719    })
720    .expect("Java request construction builds")
721}
722
723fn java_new_request_with_body(method: &str, has_query: bool, body_expr: &str) -> CodeBlock {
724    let with_query =
725        format!("request = client.newRequestWithBody(\"{method}\", path, query, {body_expr});");
726    let without_query =
727        format!("request = client.newRequestWithBody(\"{method}\", path, null, {body_expr});");
728    sigil_quote!(Java {
729        $if(has_query) {
730            $L(with_query.as_str())
731        } $else {
732            $L(without_query.as_str())
733        }
734    })
735    .expect("Java request body construction builds")
736}
737
738fn java_query_param_put(
739    required: bool,
740    var_name: &str,
741    param_name: &str,
742    value_expr: &str,
743) -> CodeBlock {
744    sigil_quote!(Java {
745        $if(required) {
746            query.put($S(param_name), $L(value_expr));
747        } $else {
748            if ($L(var_name) != null) {
749                query.put($S(param_name), $L(value_expr));
750            }
751        }
752    })
753    .expect("Java query param put builds")
754}
755
756fn java_header_param_set(
757    required: bool,
758    var_name: &str,
759    param_name: &str,
760    value_expr: &str,
761) -> CodeBlock {
762    sigil_quote!(Java {
763        $if(required) {
764            request = request.newBuilder().header($S(param_name), $L(value_expr)).build();
765        } $else {
766            if ($L(var_name) != null) {
767                request = request.newBuilder().header($S(param_name), $L(value_expr)).build();
768            }
769        }
770    })
771    .expect("Java header param set builds")
772}
773
774fn emit_multipart_body(
775    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
776    body: &BodyBinding,
777    parts: &[MultipartPart],
778) {
779    if !body.required {
780        cb.add_code(
781            sigil_quote!(Java {
782                RequestBody multipartBody = RequestBody.create(new byte[0], null);
783            })
784            .expect("default multipart body builds"),
785        );
786        cb.begin_control_flow(&format!("if ({} != null)", body.var_name), ());
787    }
788    cb.add_code(
789        sigil_quote!(Java {
790            MultipartBody.Builder multipartBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM);
791        })
792        .expect("multipart builder builds"),
793    );
794    for part in parts {
795        let access = format!(
796            "{}.get{}()",
797            body.var_name,
798            part.field_name.to_pascal_case()
799        );
800        if part.required {
801            emit_required_multipart_part(cb, part, &access);
802        } else {
803            cb.begin_control_flow(&format!("if ({access} != null)"), ());
804            emit_required_multipart_part(cb, part, &access);
805            cb.end_control_flow();
806        }
807    }
808    cb.add_code(java_multipart_body_finish(body.required));
809    if !body.required {
810        cb.end_control_flow();
811    }
812}
813
814fn java_multipart_body_finish(body_required: bool) -> CodeBlock {
815    sigil_quote!(Java {
816        $if(body_required) {
817            RequestBody multipartBody = multipartBuilder.build();
818        } $else {
819            multipartBody = multipartBuilder.build();
820        }
821    })
822    .expect("multipart body finish builds")
823}
824
825fn emit_request_body(cb: &mut sigil_stitch::code_block::CodeBlockBuilder, body: &BodyBinding) {
826    if !body.required {
827        cb.add_code(
828            sigil_quote!(Java {
829                RequestBody requestBody = RequestBody.create(new byte[0], null);
830            })
831            .expect("default request body builds"),
832        );
833        cb.begin_control_flow(&format!("if ({} != null)", body.var_name), ());
834    }
835    match body.encoding {
836        BodyEncoding::Json => {
837            let body_var = body.var_name.as_str();
838            let media_type = body.media_type.as_str();
839            cb.add_code(java_json_request_body(body.required, body_var, media_type));
840        }
841        BodyEncoding::TextPlain | BodyEncoding::OctetStream => {
842            let body_var = body.var_name.as_str();
843            let media_type = body.media_type.as_str();
844            cb.add_code(java_raw_request_body(body.required, body_var, media_type));
845        }
846        BodyEncoding::FormUrlEncoded | BodyEncoding::Xml | BodyEncoding::Other => {
847            let message = format!("unsupported request body media type: {}", body.media_type);
848            cb.add_code(
849                sigil_quote!(Java {
850                    throw new IllegalArgumentException($S(message));
851                })
852                .expect("unsupported request body builds"),
853            );
854        }
855        BodyEncoding::Multipart => unreachable!("multipart handled separately"),
856    }
857    if !body.required {
858        cb.end_control_flow();
859    }
860}
861
862fn java_json_request_body(body_required: bool, body_var: &str, media_type: &str) -> CodeBlock {
863    sigil_quote!(Java {
864        String jsonBody = gson.toJson($L(body_var));
865        $if(body_required) {
866            RequestBody requestBody = RequestBody.create(jsonBody, MediaType.get($S(media_type)));
867        } $else {
868            requestBody = RequestBody.create(jsonBody, MediaType.get($S(media_type)));
869        }
870    })
871    .expect("json request body builds")
872}
873
874fn java_raw_request_body(body_required: bool, body_var: &str, media_type: &str) -> CodeBlock {
875    sigil_quote!(Java {
876        $if(body_required) {
877            RequestBody requestBody = RequestBody.create($L(body_var), MediaType.get($S(media_type)));
878        } $else {
879            requestBody = RequestBody.create($L(body_var), MediaType.get($S(media_type)));
880        }
881    })
882    .expect("raw request body builds")
883}
884
885fn response_decode_expr(tr: &TypedResponse) -> String {
886    match tr.decoding {
887        ResponseDecoding::Json => {
888            let type_token = format!("new TypeToken<{}>() {{}}.getType()", tr.java_type);
889            format!("gson.fromJson(responseText.isEmpty() ? \"null\" : responseText, {type_token})")
890        }
891        ResponseDecoding::Text => "responseText".to_string(),
892        ResponseDecoding::Bytes => "responseBytes".to_string(),
893    }
894}
895
896fn java_response_decode_assignments(typed_responses: &[TypedResponse]) -> CodeBlock {
897    let mut cb = CodeBlock::builder();
898    let mut seen: HashSet<String> = HashSet::new();
899    let mut emitted_any = false;
900    for tr in typed_responses {
901        if !seen.insert(tr.field_name.clone()) {
902            continue;
903        }
904        let keyword = if emitted_any { "else if" } else { "if" };
905        let guard = response_status_guard_java(&tr.status);
906        let assignment = format!("{} = {}", tr.field_name, response_decode_expr(tr));
907        cb.begin_control_flow(&format!("{keyword} ({guard})"), ());
908        cb.add(&format!("{assignment};\n"), ());
909        cb.end_control_flow();
910        emitted_any = true;
911    }
912    cb.build().expect("Java response decode assignments build")
913}
914
915fn java_error_throw(plan: &OpPlan<'_>) -> CodeBlock {
916    let mut cb = CodeBlock::builder();
917    cb.begin_control_flow("if (!response.isSuccessful())", ());
918    cb.add_statement("okhttp3.ResponseBody responseBody = response.body()", ());
919    cb.add_statement(
920        "java.nio.charset.Charset responseCharset = responseBody != null && responseBody.contentType() != null ? responseBody.contentType().charset(StandardCharsets.UTF_8) : StandardCharsets.UTF_8",
921        (),
922    );
923    cb.add_statement(
924        "byte[] responseBytes = responseBody != null ? responseBody.bytes() : new byte[0]",
925        (),
926    );
927    cb.add_statement(
928        "String responseText = new String(responseBytes, responseCharset)",
929        (),
930    );
931    cb.add(&format!("{}Detail detail;\n", plan.error_type), ());
932    cb.begin_control_flow("switch (response.code())", ());
933    let mut seen = HashSet::new();
934    for response in plan
935        .error_responses
936        .iter()
937        .filter(|response| response.status.parse::<u16>().is_ok())
938    {
939        if !seen.insert(response.field_name.clone()) {
940            continue;
941        }
942        cb.add(&format!("case {}:\n", response.status), ());
943        cb.add("%>", ());
944        cb.add_code(java_error_detail_assign(plan, response));
945        cb.add("break;\n", ());
946        cb.add("%<", ());
947    }
948    cb.add("default:\n", ());
949    cb.add("%>", ());
950    for response in plan
951        .error_responses
952        .iter()
953        .filter(|response| response.status.ends_with("XX"))
954    {
955        cb.begin_control_flow(
956            &format!("if ({})", wildcard_status_guard_java(&response.status)),
957            (),
958        );
959        cb.add_code(java_error_detail_assign(plan, response));
960        cb.add("break;\n", ());
961        cb.end_control_flow();
962    }
963    if let Some(default) = plan
964        .error_responses
965        .iter()
966        .find(|response| response.status.eq_ignore_ascii_case("default"))
967    {
968        cb.add_code(java_error_detail_assign(plan, default));
969    } else {
970        let unexpected = format!("{}Unexpected", plan.method_name.to_pascal_case());
971        cb.add(
972            &format!(
973                "detail = new {unexpected}(response.code(), response.headers(), responseBytes);\n"
974            ),
975            (),
976        );
977    }
978    cb.add("%<", ());
979    cb.end_control_flow();
980    cb.add(
981        &format!(
982            "throw new {}(response.code(), response.message(), responseText, response.headers(), responseBytes, detail);\n",
983            plan.error_type
984        ),
985        (),
986    );
987    cb.end_control_flow();
988    cb.build().expect("Java error throw builds")
989}
990
991fn java_error_detail_assign(plan: &OpPlan<'_>, response: &TypedResponse) -> CodeBlock {
992    let class_name = format!(
993        "{}{}",
994        plan.method_name.to_pascal_case(),
995        response.field_name
996    );
997    let stmt =
998        format!("detail = new {class_name}(response.code(), response.headers(), responseBytes);");
999    CodeBlock::of(&stmt, ()).expect("Java error detail assign builds")
1000}
1001
1002fn emit_required_multipart_part(
1003    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
1004    part: &MultipartPart,
1005    access: &str,
1006) {
1007    cb.add_code(java_multipart_part(part, access));
1008}
1009
1010fn java_multipart_part(part: &MultipartPart, access: &str) -> CodeBlock {
1011    let wire_name = part.wire_name.as_str();
1012    let content_type = part.content_type.as_str();
1013    sigil_quote!(Java {
1014        $if(part.is_binary) {
1015            multipartBuilder.addFormDataPart($S(wire_name), $L(access).filenameOrDefault($S(wire_name)), RequestBody.create($L(access).getData(), MediaType.get($S(content_type))));
1016        } $else_if(part.value_encoding == MultipartValueEncoding::Json) {
1017            multipartBuilder.addFormDataPart($S(wire_name), null, RequestBody.create(gson.toJson($L(access)), MediaType.get($S(content_type))));
1018        } $else_if(part.value_encoding == MultipartValueEncoding::Unsupported) {
1019            throw new IllegalArgumentException($S("unsupported multipart part content type"));
1020        } $else {
1021            multipartBuilder.addFormDataPart($S(wire_name), null, RequestBody.create(String.valueOf($L(access)), MediaType.get($S(content_type))));
1022        }
1023    })
1024    .expect("multipart part block builds")
1025}
1026
1027// ---------------------------------------------------------------------------
1028// Planning
1029// ---------------------------------------------------------------------------
1030
1031struct OpPlan<'a> {
1032    op: &'a IrOperation,
1033    method_name: String,
1034    response_type: String,
1035    error_type: String,
1036    path_params: Vec<ParamBinding<'a>>,
1037    query_params: Vec<ParamBinding<'a>>,
1038    header_params: Vec<ParamBinding<'a>>,
1039    body: Option<BodyBinding>,
1040    typed_responses: Vec<TypedResponse>,
1041    error_responses: Vec<TypedResponse>,
1042}
1043
1044struct ParamBinding<'a> {
1045    param: &'a IrParameter,
1046    var_name: String,
1047    java_type: String,
1048}
1049
1050struct BodyBinding {
1051    var_name: String,
1052    java_type: String,
1053    media_type: String,
1054    required: bool,
1055    encoding: BodyEncoding,
1056    multipart_parts: Option<Vec<MultipartPart>>,
1057}
1058
1059struct MultipartPart {
1060    wire_name: String,
1061    field_name: String,
1062    is_binary: bool,
1063    required: bool,
1064    content_type: String,
1065    value_encoding: MultipartValueEncoding,
1066}
1067
1068#[derive(Clone, Copy, PartialEq, Eq)]
1069enum BodyEncoding {
1070    Json,
1071    Multipart,
1072    FormUrlEncoded,
1073    Xml,
1074    TextPlain,
1075    OctetStream,
1076    Other,
1077}
1078
1079#[derive(Clone, Copy, PartialEq, Eq)]
1080enum ResponseDecoding {
1081    Json,
1082    Text,
1083    Bytes,
1084}
1085
1086struct TypedResponse {
1087    status: String,
1088    field_name: String,
1089    java_type: String,
1090    decoding: ResponseDecoding,
1091}
1092
1093fn plan_operation<'a>(
1094    op: &'a IrOperation,
1095    ir: &IrSpec,
1096    request_inputs: &RequestInputPlan,
1097) -> OpPlan<'a> {
1098    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
1099    let method_name = op_id.to_lower_camel_case();
1100    let response_type = format!("{}Response", op_id.to_pascal_case());
1101    let error_type = format!("{}Exception", op_id.to_pascal_case());
1102
1103    let mut used_names: HashSet<String> = HashSet::new();
1104
1105    let mut path_params = Vec::new();
1106    let mut query_params = Vec::new();
1107    let mut header_params = Vec::new();
1108    for p in &op.parameters {
1109        let var_name = unique_name(&java_ident(&p.name), &mut used_names);
1110        let java_type = if p.required {
1111            java_type_str(&p.type_expr)
1112        } else {
1113            java_boxed_type_str(&p.type_expr)
1114        };
1115        let binding = ParamBinding {
1116            param: p,
1117            var_name,
1118            java_type,
1119        };
1120        match p.location {
1121            ParameterLocation::Path => path_params.push(binding),
1122            ParameterLocation::Query => query_params.push(binding),
1123            ParameterLocation::Header => header_params.push(binding),
1124            ParameterLocation::Cookie => header_params.push(binding),
1125        }
1126    }
1127
1128    let body = op
1129        .request_body
1130        .as_ref()
1131        .and_then(|b| plan_body(op, b, ir, request_inputs, &mut used_names));
1132
1133    let mut typed_responses: Vec<TypedResponse> = op
1134        .responses
1135        .iter()
1136        .filter(|r| is_success_status(&r.status))
1137        .filter_map(plan_response)
1138        .collect();
1139    typed_responses.sort_by_key(|r| response_match_rank(&r.status));
1140    let error_responses = op
1141        .responses
1142        .iter()
1143        .filter(|r| !is_success_status(&r.status))
1144        .map(plan_error_response)
1145        .collect();
1146
1147    OpPlan {
1148        op,
1149        method_name,
1150        response_type,
1151        error_type,
1152        path_params,
1153        query_params,
1154        header_params,
1155        body,
1156        typed_responses,
1157        error_responses,
1158    }
1159}
1160
1161fn plan_body(
1162    op: &IrOperation,
1163    b: &IrRequestBody,
1164    ir: &IrSpec,
1165    request_inputs: &RequestInputPlan,
1166    used_names: &mut HashSet<String>,
1167) -> Option<BodyBinding> {
1168    let (media_type, t) = pick_body_content(b)?;
1169    let encoding = body_encoding(&media_type);
1170    let java_type = match encoding {
1171        BodyEncoding::TextPlain => "String".to_string(),
1172        BodyEncoding::OctetStream => "byte[]".to_string(),
1173        BodyEncoding::Multipart => request_input_for_operation(request_inputs, op, &media_type)
1174            .map(|input| input.name.to_pascal_case())
1175            .unwrap_or_else(|| java_type_str(&t)),
1176        _ => java_type_str(&t),
1177    };
1178    let var_name = unique_name("body", used_names);
1179    let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
1180        multipart_parts_for(b, &media_type, ir)
1181    } else {
1182        None
1183    };
1184    Some(BodyBinding {
1185        var_name,
1186        java_type,
1187        media_type,
1188        required: b.required,
1189        encoding,
1190        multipart_parts,
1191    })
1192}
1193
1194fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
1195    let (media_type, t) = pick_response_content(r)?;
1196    let decoding = response_decoding(&media_type);
1197    let java_type = match decoding {
1198        ResponseDecoding::Json => java_type_str(&t),
1199        ResponseDecoding::Text => "String".to_string(),
1200        ResponseDecoding::Bytes => "byte[]".to_string(),
1201    };
1202    Some(TypedResponse {
1203        status: r.status.clone(),
1204        field_name: response_field_name(&r.status),
1205        java_type,
1206        decoding,
1207    })
1208}
1209
1210fn plan_error_response(r: &IrResponse) -> TypedResponse {
1211    match pick_response_content(r) {
1212        Some((media_type, t)) => {
1213            let decoding = response_decoding(&media_type);
1214            let java_type = match decoding {
1215                ResponseDecoding::Json => java_type_str(&t),
1216                ResponseDecoding::Text => "String".to_string(),
1217                ResponseDecoding::Bytes => "byte[]".to_string(),
1218            };
1219            TypedResponse {
1220                status: r.status.clone(),
1221                field_name: response_variant_name(&r.status),
1222                java_type,
1223                decoding,
1224            }
1225        }
1226        None => TypedResponse {
1227            status: r.status.clone(),
1228            field_name: response_variant_name(&r.status),
1229            java_type: "byte[]".to_string(),
1230            decoding: ResponseDecoding::Bytes,
1231        },
1232    }
1233}
1234
1235fn is_success_status(status: &str) -> bool {
1236    status
1237        .parse::<u16>()
1238        .is_ok_and(|code| (200..300).contains(&code))
1239        || status.eq_ignore_ascii_case("2XX")
1240}
1241
1242fn response_field_name(status: &str) -> String {
1243    if status == "default" {
1244        "default_".to_string()
1245    } else if let Ok(code) = status.parse::<u16>() {
1246        format!("status{code}")
1247    } else {
1248        format!("status{}", status.to_lowercase())
1249    }
1250}
1251
1252fn wildcard_status_guard_java(status: &str) -> String {
1253    let upper = status.to_uppercase();
1254    if upper == "4XX" {
1255        "response.code() >= 400 && response.code() < 500".to_string()
1256    } else if upper == "5XX" {
1257        "response.code() >= 500 && response.code() < 600".to_string()
1258    } else if upper == "2XX" {
1259        "response.code() >= 200 && response.code() < 300".to_string()
1260    } else {
1261        // "default" or unknown wildcard: match everything (fallback response)
1262        "true".to_string()
1263    }
1264}
1265
1266fn response_status_guard_java(status: &str) -> String {
1267    status
1268        .parse::<u16>()
1269        .map(|code| format!("response.code() == {code}"))
1270        .unwrap_or_else(|_| wildcard_status_guard_java(status))
1271}
1272
1273fn body_encoding(media_type: &str) -> BodyEncoding {
1274    let base = media_type_base(media_type);
1275    if base == "multipart/form-data" {
1276        BodyEncoding::Multipart
1277    } else if is_json_media_type(media_type) {
1278        BodyEncoding::Json
1279    } else if base == "application/x-www-form-urlencoded" {
1280        BodyEncoding::FormUrlEncoded
1281    } else if is_xml_media_type(media_type) {
1282        BodyEncoding::Xml
1283    } else if base == "text/plain" {
1284        BodyEncoding::TextPlain
1285    } else if base == "application/octet-stream" {
1286        BodyEncoding::OctetStream
1287    } else {
1288        BodyEncoding::Other
1289    }
1290}
1291
1292fn response_decoding(media_type: &str) -> ResponseDecoding {
1293    let base = media_type_base(media_type);
1294    if is_json_media_type(media_type) {
1295        ResponseDecoding::Json
1296    } else if base == "text/plain" || is_xml_media_type(media_type) {
1297        ResponseDecoding::Text
1298    } else {
1299        ResponseDecoding::Bytes
1300    }
1301}
1302
1303fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
1304    pick_media_type(&body.content, |media_type| {
1305        media_type_base(media_type) == "application/json"
1306    })
1307    .or_else(|| pick_media_type(&body.content, is_json_media_type))
1308    .or_else(|| {
1309        pick_media_type(&body.content, |media_type| {
1310            media_type_base(media_type) == "multipart/form-data"
1311        })
1312    })
1313    .or_else(|| {
1314        pick_media_type(&body.content, |media_type| {
1315            media_type_base(media_type) == "application/x-www-form-urlencoded"
1316        })
1317    })
1318    .or_else(|| pick_media_type(&body.content, is_xml_media_type))
1319    .or_else(|| {
1320        pick_media_type(&body.content, |media_type| {
1321            media_type_base(media_type) == "text/plain"
1322        })
1323    })
1324    .or_else(|| {
1325        pick_media_type(&body.content, |media_type| {
1326            media_type_base(media_type) == "application/octet-stream"
1327        })
1328    })
1329    .or_else(|| pick_first_content(&body.content))
1330}
1331
1332fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
1333    pick_media_type(&r.content, |media_type| {
1334        media_type_base(media_type) == "application/json"
1335    })
1336    .or_else(|| pick_media_type(&r.content, is_json_media_type))
1337    .or_else(|| {
1338        pick_media_type(&r.content, |media_type| {
1339            media_type_base(media_type) == "application/octet-stream"
1340        })
1341    })
1342    .or_else(|| {
1343        pick_media_type(&r.content, |media_type| {
1344            media_type_base(media_type) == "text/plain"
1345        })
1346    })
1347    .or_else(|| pick_media_type(&r.content, is_xml_media_type))
1348    .or_else(|| pick_first_content(&r.content))
1349}
1350
1351fn pick_media_type(
1352    content: &indexmap::IndexMap<String, IrTypeExpr>,
1353    predicate: impl Fn(&str) -> bool,
1354) -> Option<(String, IrTypeExpr)> {
1355    content
1356        .iter()
1357        .find(|(media_type, _)| predicate(media_type))
1358        .map(|(media_type, t)| (media_type.clone(), t.clone()))
1359}
1360
1361fn pick_first_content(
1362    content: &indexmap::IndexMap<String, IrTypeExpr>,
1363) -> Option<(String, IrTypeExpr)> {
1364    content
1365        .iter()
1366        .next()
1367        .map(|(media_type, t)| (media_type.clone(), t.clone()))
1368}
1369
1370fn media_type_base(media_type: &str) -> String {
1371    media_type
1372        .split(';')
1373        .next()
1374        .unwrap_or(media_type)
1375        .trim()
1376        .to_ascii_lowercase()
1377}
1378
1379fn is_json_media_type(media_type: &str) -> bool {
1380    let base = media_type_base(media_type);
1381    base == "application/json" || base.ends_with("+json")
1382}
1383
1384fn is_xml_media_type(media_type: &str) -> bool {
1385    let base = media_type_base(media_type);
1386    base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
1387}
1388
1389fn multipart_parts_for(
1390    body: &IrRequestBody,
1391    media_type: &str,
1392    ir: &IrSpec,
1393) -> Option<Vec<MultipartPart>> {
1394    multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
1395        parts
1396            .into_iter()
1397            .map(|part| MultipartPart {
1398                field_name: java_field_name(&part.wire_name),
1399                wire_name: part.wire_name,
1400                is_binary: part.is_binary,
1401                required: part.required,
1402                content_type: part.content_type,
1403                value_encoding: part.value_encoding,
1404            })
1405            .collect()
1406    })
1407}