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