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::ir::types::{
6    IrOperation, IrParameter, IrRequestBody, IrResponse, IrSpec, IrTypeExpr, ParameterLocation,
7};
8use heck::{ToLowerCamelCase, ToPascalCase};
9use sigil_stitch::lang::java::Java;
10use sigil_stitch::prelude::*;
11
12use super::util::{
13    build_java_getter, java_boxed_type_str, java_field_name, java_ident, java_type_str,
14    render_value_as_string, sanitize_operation_id, unique_name,
15};
16
17const RENDER_WIDTH: usize = 100;
18
19pub fn generate_api_files(
20    ir: &IrSpec,
21    package_name: &str,
22    header: &str,
23) -> Result<Vec<FileInfo>, String> {
24    let by_tag = group_by_tag(&ir.operations);
25    let mut files = Vec::with_capacity(by_tag.len());
26    for (tag, ops) in &by_tag {
27        let class_name = format!("{}Api", tag.to_pascal_case());
28        let filename = format!("{class_name}.java");
29        let body = emit_api_file(tag, ops, ir, package_name);
30        let content = format!("{header}{body}");
31        files.push(FileInfo::api(filename, content));
32    }
33    Ok(files)
34}
35
36fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
37    let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
38    for op in operations {
39        let tags: Vec<String> = if op.tags.is_empty() {
40            vec!["default".to_string()]
41        } else {
42            op.tags.clone()
43        };
44        for tag in tags {
45            out.entry(tag).or_default().push(op);
46        }
47    }
48    out
49}
50
51// ---------------------------------------------------------------------------
52// File assembly
53// ---------------------------------------------------------------------------
54
55fn emit_api_file(tag: &str, ops: &[&IrOperation], ir: &IrSpec, package_name: &str) -> String {
56    let class_name = format!("{}Api", tag.to_pascal_case());
57    let plans: Vec<OpPlan> = ops.iter().map(|op| plan_operation(op, ir)).collect();
58
59    let filename = format!("{class_name}.java");
60    let mut fb = FileSpec::builder_with(&filename, Java::new())
61        .header(package_header(package_name))
62        .add_import(ImportSpec::named(&format!("{package_name}.models"), "*"))
63        .add_import(ImportSpec::named(
64            &format!("{package_name}.runtime"),
65            "ApiClient",
66        ))
67        .add_import(ImportSpec::named(
68            &format!("{package_name}.runtime"),
69            "ApiException",
70        ))
71        .add_import(ImportSpec::named("com.google.gson", "Gson"))
72        .add_import(ImportSpec::named("com.google.gson.reflect", "TypeToken"))
73        .add_import(ImportSpec::named("java.io", "IOException"))
74        .add_import(ImportSpec::named("java.nio.charset", "StandardCharsets"))
75        .add_import(ImportSpec::named("java.util", "HashMap"))
76        .add_import(ImportSpec::named("java.util", "List"))
77        .add_import(ImportSpec::named("java.util", "Map"))
78        .add_import(ImportSpec::named("java.util.stream", "Collectors"))
79        .add_import(ImportSpec::named("okhttp3", "Request"))
80        .add_import(ImportSpec::named("okhttp3", "Response"));
81    let has_supported_multipart_body = plans.iter().any(|plan| {
82        plan.body.as_ref().is_some_and(|body| {
83            media_type_base(&body.media_type) == "multipart/form-data"
84                && body.multipart_parts.is_some()
85        })
86    });
87    let has_raw_request_body = plans.iter().any(|plan| plan.body.is_some());
88    if has_supported_multipart_body {
89        fb = fb.add_import(ImportSpec::named("okhttp3", "MultipartBody"));
90    }
91    if has_raw_request_body {
92        fb = fb.add_import(ImportSpec::named("okhttp3", "RequestBody"));
93    }
94    if has_supported_multipart_body || has_raw_request_body {
95        fb = fb.add_import(ImportSpec::named("okhttp3", "MediaType"));
96    }
97
98    // Response classes
99    for plan in &plans {
100        fb = fb.add_type(build_response_class(plan));
101    }
102
103    // API class
104    let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).visibility(Visibility::Public);
105    cls = cls.doc(&format!(
106        "{class_name} groups operations under the {tag} tag."
107    ));
108
109    // Fields
110    cls = cls.add_field(
111        FieldSpec::builder("client", TypeName::primitive("ApiClient"))
112            .visibility(Visibility::Private)
113            .is_readonly()
114            .build()
115            .expect("client field"),
116    );
117    cls = cls.add_field(
118        FieldSpec::builder("gson", TypeName::primitive("Gson"))
119            .visibility(Visibility::Private)
120            .is_readonly()
121            .initializer(CodeBlock::of("new Gson()", ()).expect("gson init"))
122            .build()
123            .expect("gson field"),
124    );
125
126    // Constructor
127    let mut ctor = FunSpec::builder(&class_name);
128    ctor = ctor.visibility(Visibility::Public);
129    ctor = ctor.add_param(
130        ParameterSpec::new("ApiClient client", TypeName::primitive("")).expect("client param"),
131    );
132    let ctor_body = sigil_quote!(Java {
133        this.client = client;
134    })
135    .expect("ctor body");
136    ctor = ctor.body(ctor_body);
137    cls = cls.add_method(ctor.build().expect("constructor"));
138
139    // API methods
140    for plan in &plans {
141        cls = cls.add_method(build_operation_fun(plan));
142    }
143
144    fb = fb.add_type(cls.build().expect("API class builds"));
145
146    let file = fb.build().expect("FileSpec builds for API file");
147    file.render(RENDER_WIDTH)
148        .expect("FileSpec renders for API file")
149}
150
151fn package_header(package_name: &str) -> CodeBlock {
152    sigil_quote!(Java {
153        package $L(format!("{package_name}.apis"));
154    })
155    .expect("package header builds")
156}
157
158// ---------------------------------------------------------------------------
159// Response class
160// ---------------------------------------------------------------------------
161
162fn build_response_class(plan: &OpPlan<'_>) -> TypeSpec {
163    let mut tb =
164        TypeSpec::builder(&plan.response_type, TypeKind::Struct).visibility(Visibility::Public);
165    tb = tb.doc(&format!(
166        "{} carries the response from {}.",
167        plan.response_type, plan.method_name
168    ));
169
170    // Fields
171    tb = tb.add_field(
172        FieldSpec::builder("statusCode", TypeName::primitive("int"))
173            .visibility(Visibility::Private)
174            .is_readonly()
175            .build()
176            .expect("field"),
177    );
178    tb = tb.add_field(
179        FieldSpec::builder("raw", TypeName::primitive("Response"))
180            .visibility(Visibility::Private)
181            .is_readonly()
182            .build()
183            .expect("field"),
184    );
185
186    let mut seen: HashSet<String> = HashSet::new();
187    for tr in &plan.typed_responses {
188        if !seen.insert(tr.field_name.clone()) {
189            continue;
190        }
191        tb = tb.add_field(
192            FieldSpec::builder(&tr.field_name, TypeName::primitive(&tr.java_type))
193                .visibility(Visibility::Private)
194                .is_readonly()
195                .build()
196                .expect("field"),
197        );
198    }
199
200    // Constructor
201    let mut ctor = FunSpec::builder(&plan.response_type);
202    ctor = ctor.visibility(Visibility::Public);
203    ctor = ctor
204        .add_param(ParameterSpec::new("int statusCode", TypeName::primitive("")).expect("param"));
205    ctor =
206        ctor.add_param(ParameterSpec::new("Response raw", TypeName::primitive("")).expect("param"));
207    let mut ctor_seen: HashSet<String> = HashSet::new();
208    for tr in &plan.typed_responses {
209        if !ctor_seen.insert(tr.field_name.clone()) {
210            continue;
211        }
212        ctor = ctor.add_param(
213            ParameterSpec::new(
214                &format!("{} {}", tr.java_type, tr.field_name),
215                TypeName::primitive(""),
216            )
217            .expect("param"),
218        );
219    }
220    let mut field_assignments: Vec<CodeBlock> = vec![
221        sigil_quote!(Java { this.statusCode = statusCode; }).expect("assign"),
222        sigil_quote!(Java { this.raw = raw; }).expect("assign"),
223    ];
224    let mut body_seen: HashSet<String> = HashSet::new();
225    for tr in &plan.typed_responses {
226        if !body_seen.insert(tr.field_name.clone()) {
227            continue;
228        }
229        field_assignments.push(
230            sigil_quote!(Java {
231                this.$L(tr.field_name.as_str()) = $L(tr.field_name.as_str());
232            })
233            .expect("assign"),
234        );
235    }
236    let ctor_body = sigil_quote!(Java {
237        $C_each(field_assignments);
238    })
239    .expect("ctor body");
240    ctor = ctor.body(ctor_body);
241    tb = tb.add_method(ctor.build().expect("response ctor"));
242
243    // Getters
244    tb = tb.add_method(build_java_getter("getStatusCode", "int", "statusCode"));
245    tb = tb.add_method(build_java_getter("getRaw", "Response", "raw"));
246
247    let mut getter_seen: HashSet<String> = HashSet::new();
248    for tr in &plan.typed_responses {
249        if !getter_seen.insert(tr.field_name.clone()) {
250            continue;
251        }
252        let getter_name = format!("get{}", tr.field_name.to_pascal_case());
253        tb = tb.add_method(build_java_getter(
254            &getter_name,
255            &tr.java_type,
256            &tr.field_name,
257        ));
258    }
259
260    tb.build().expect("response class builds")
261}
262
263// ---------------------------------------------------------------------------
264// Operation method
265// ---------------------------------------------------------------------------
266
267fn build_operation_fun(plan: &OpPlan<'_>) -> FunSpec {
268    let mut fb = FunSpec::builder(&plan.method_name);
269    fb = fb.visibility(Visibility::Public);
270
271    if let Some(summary) = &plan.op.summary {
272        fb = fb.doc(summary);
273    } else {
274        fb = fb.doc(&format!(
275            "{} {} {}.",
276            plan.method_name,
277            plan.op.method.to_uppercase(),
278            plan.op.path,
279        ));
280    }
281
282    // Parameters
283    for p in plan
284        .path_params
285        .iter()
286        .chain(&plan.query_params)
287        .chain(&plan.header_params)
288    {
289        fb = fb.add_param(
290            ParameterSpec::new(
291                &format!("{} {}", p.java_type, p.var_name),
292                TypeName::primitive(""),
293            )
294            .expect("param"),
295        );
296    }
297    if let Some(body) = &plan.body {
298        fb = fb.add_param(
299            ParameterSpec::new(
300                &format!("{} {}", body.java_type, body.var_name),
301                TypeName::primitive(""),
302            )
303            .expect("body param"),
304        );
305    }
306
307    fb = fb.returns(TypeName::primitive(&plan.response_type));
308    fb = fb.suffix("throws IOException");
309    fb = fb.body(emit_method_body(plan));
310
311    fb.build().expect("operation FunSpec builds")
312}
313
314// ---------------------------------------------------------------------------
315// Method body
316// ---------------------------------------------------------------------------
317
318fn emit_method_body(plan: &OpPlan<'_>) -> CodeBlock {
319    let mut cb = CodeBlock::builder();
320
321    // Path
322    let mut path_expr = format!("\"{}\"", plan.op.path);
323    for p in &plan.path_params {
324        let placeholder = format!("{{{}}}", p.param.name);
325        let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
326        path_expr = format!("{path_expr}.replace(\"{placeholder}\", {stringified})");
327    }
328    cb.add_statement(&format!("String path = {path_expr}"), ());
329
330    // Query
331    let has_query = !plan.query_params.is_empty();
332    if has_query {
333        cb.add_statement("Map<String, String> query = new HashMap<>()", ());
334        for p in &plan.query_params {
335            let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
336            if p.param.required {
337                cb.add_statement(
338                    &format!("query.put(\"{}\", {})", p.param.name, stringified),
339                    (),
340                );
341            } else {
342                cb.begin_control_flow(&format!("if ({} != null)", p.var_name), ());
343                cb.add_statement(
344                    &format!("query.put(\"{}\", {})", p.param.name, stringified),
345                    (),
346                );
347                cb.end_control_flow();
348            }
349        }
350    }
351
352    // Build request
353    let query_arg = if has_query { "query" } else { "null" };
354    let method = plan.op.method.to_uppercase();
355    if let Some(body) = &plan.body {
356        cb.add_statement("Request request", ());
357        if body.encoding == BodyEncoding::Multipart {
358            if let Some(parts) = &body.multipart_parts {
359                emit_multipart_body(&mut cb, body, parts);
360                cb.add_statement(
361                    &format!(
362                        "request = client.newRequestWithBody(\"{method}\", path, {query_arg}, multipartBody)"
363                    ),
364                    (),
365                );
366            } else {
367                cb.add_statement(
368                    "throw new IllegalArgumentException(\"unsupported multipart request body: schema must be object-shaped\")",
369                    (),
370                );
371            }
372        } else {
373            emit_request_body(&mut cb, body);
374            cb.add_statement(
375                &format!(
376                    "request = client.newRequestWithBody(\"{method}\", path, {query_arg}, requestBody)"
377                ),
378                (),
379            );
380        }
381    } else {
382        cb.add_statement(
383            &format!("Request request = client.newRequest(\"{method}\", path, {query_arg}, null)"),
384            (),
385        );
386    }
387
388    // Headers
389    for p in &plan.header_params {
390        let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
391        if p.param.required {
392            cb.add_statement(
393                &format!(
394                    "request = request.newBuilder().header(\"{}\", {stringified}).build()",
395                    p.param.name
396                ),
397                (),
398            );
399        } else {
400            cb.begin_control_flow(&format!("if ({} != null)", p.var_name), ());
401            cb.add_statement(
402                &format!(
403                    "request = request.newBuilder().header(\"{}\", {stringified}).build()",
404                    p.param.name
405                ),
406                (),
407            );
408            cb.end_control_flow();
409        }
410    }
411
412    // Execute
413    cb.add_statement("Response response = client.execute(request)", ());
414    cb.add_line();
415
416    // Error handling
417    let error_block = sigil_quote!(Java {
418        if (!response.isSuccessful()) {
419            String errorBody = response.body() != null ? response.body().string() : "";
420            throw new ApiException(response.code(), response.message(), errorBody);
421        }
422    })
423    .expect("error block");
424    cb.add_code(error_block);
425
426    // Response parsing
427    if !plan.typed_responses.is_empty() {
428        cb.add_statement(
429            "byte[] responseBytes = response.body() != null ? response.body().bytes() : new byte[0]",
430            (),
431        );
432        cb.add_statement(
433            "String responseText = new String(responseBytes, StandardCharsets.UTF_8)",
434            (),
435        );
436        let mut seen: HashSet<String> = HashSet::new();
437
438        // Numeric status codes
439        for tr in &plan.typed_responses {
440            if !seen.insert(tr.field_name.clone()) {
441                continue;
442            }
443            cb.add_statement(&format!("{} {} = null", tr.java_type, tr.field_name), ());
444            if let Ok(code) = tr.status.parse::<u16>() {
445                cb.begin_control_flow(&format!("if (response.code() == {code})"), ());
446                cb.add_statement(
447                    &format!("{} = {}", tr.field_name, response_decode_expr(tr)),
448                    (),
449                );
450                cb.end_control_flow();
451            } else {
452                // Wildcard status ("4XX", "5XX", "default"): guard by range
453                let guard = wildcard_status_guard_java(&tr.status);
454                cb.begin_control_flow(&format!("if ({guard})"), ());
455                cb.add_statement(
456                    &format!("{} = {}", tr.field_name, response_decode_expr(tr)),
457                    (),
458                );
459                cb.end_control_flow();
460            }
461        }
462
463        // Return with typed fields
464        let args: Vec<String> = std::iter::once("response.code()".to_string())
465            .chain(std::iter::once("response".to_string()))
466            .chain(plan.typed_responses.iter().map(|tr| tr.field_name.clone()))
467            .collect();
468        // deduplicate
469        let mut dedup_args: Vec<String> = Vec::new();
470        let mut args_seen: HashSet<String> = HashSet::new();
471        for a in args {
472            if args_seen.insert(a.clone()) {
473                dedup_args.push(a);
474            }
475        }
476        cb.add_statement(
477            &format!(
478                "return new {}({})",
479                plan.response_type,
480                dedup_args.join(", ")
481            ),
482            (),
483        );
484    } else {
485        cb.add_statement(
486            &format!(
487                "return new {}(response.code(), response)",
488                plan.response_type
489            ),
490            (),
491        );
492    }
493
494    cb.build().expect("method body builds")
495}
496
497fn emit_multipart_body(
498    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
499    body: &BodyBinding,
500    parts: &[MultipartPart],
501) {
502    if !body.required {
503        cb.add_statement(
504            "RequestBody multipartBody = RequestBody.create(new byte[0], null)",
505            (),
506        );
507        cb.begin_control_flow(&format!("if ({} != null)", body.var_name), ());
508    }
509    cb.add_statement("MultipartBody.Builder multipartBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM)", ());
510    for part in parts {
511        let access = format!(
512            "{}.get{}()",
513            body.var_name,
514            part.field_name.to_pascal_case()
515        );
516        if part.required {
517            emit_required_multipart_part(cb, part, &access);
518        } else {
519            cb.begin_control_flow(&format!("if ({access} != null)"), ());
520            emit_required_multipart_part(cb, part, &access);
521            cb.end_control_flow();
522        }
523    }
524    if body.required {
525        cb.add_statement("RequestBody multipartBody = multipartBuilder.build()", ());
526    } else {
527        cb.add_statement("multipartBody = multipartBuilder.build()", ());
528        cb.end_control_flow();
529    }
530}
531
532fn emit_request_body(cb: &mut sigil_stitch::code_block::CodeBlockBuilder, body: &BodyBinding) {
533    if !body.required {
534        cb.add_statement(
535            "RequestBody requestBody = RequestBody.create(new byte[0], null)",
536            (),
537        );
538        cb.begin_control_flow(&format!("if ({} != null)", body.var_name), ());
539    }
540    match body.encoding {
541        BodyEncoding::Json => {
542            cb.add_statement(
543                &format!("String jsonBody = gson.toJson({})", body.var_name),
544                (),
545            );
546            let prefix = if body.required {
547                "RequestBody requestBody ="
548            } else {
549                "requestBody ="
550            };
551            cb.add_statement(
552                &format!(
553                    "{prefix} RequestBody.create(jsonBody, MediaType.get(\"{}\"))",
554                    body.media_type
555                ),
556                (),
557            );
558        }
559        BodyEncoding::TextPlain | BodyEncoding::OctetStream => {
560            let prefix = if body.required {
561                "RequestBody requestBody ="
562            } else {
563                "requestBody ="
564            };
565            cb.add_statement(
566                &format!(
567                    "{prefix} RequestBody.create({}, MediaType.get(\"{}\"))",
568                    body.var_name, body.media_type
569                ),
570                (),
571            );
572        }
573        BodyEncoding::FormUrlEncoded | BodyEncoding::Xml | BodyEncoding::Other => {
574            cb.add_statement(
575                &format!(
576                    "throw new IllegalArgumentException(\"unsupported request body media type: {}\")",
577                    body.media_type
578                ),
579                (),
580            );
581        }
582        BodyEncoding::Multipart => unreachable!("multipart handled separately"),
583    }
584    if !body.required {
585        cb.end_control_flow();
586    }
587}
588
589fn response_decode_expr(tr: &TypedResponse) -> String {
590    match tr.decoding {
591        ResponseDecoding::Json => {
592            let type_token = format!("new TypeToken<{}>() {{}}.getType()", tr.java_type);
593            format!("gson.fromJson(responseText.isEmpty() ? \"null\" : responseText, {type_token})")
594        }
595        ResponseDecoding::Text => "responseText".to_string(),
596        ResponseDecoding::Bytes => "responseBytes".to_string(),
597    }
598}
599
600fn emit_required_multipart_part(
601    cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
602    part: &MultipartPart,
603    access: &str,
604) {
605    let wire_name = part.wire_name.as_str();
606    let content_type = part.content_type.as_str();
607    if part.is_binary {
608        cb.add_code(
609            sigil_quote!(Java {
610                multipartBuilder.addFormDataPart($S(wire_name), $S(wire_name), RequestBody.create($L(access), MediaType.get($S(content_type))));
611            })
612            .expect("binary multipart part block builds"),
613        );
614    } else if part.value_encoding == MultipartValueEncoding::Json {
615        cb.add_code(
616            sigil_quote!(Java {
617                multipartBuilder.addFormDataPart($S(wire_name), null, RequestBody.create(gson.toJson($L(access)), MediaType.get($S(content_type))));
618            })
619            .expect("json multipart part block builds"),
620        );
621    } else if part.value_encoding == MultipartValueEncoding::Unsupported {
622        cb.add_code(
623            sigil_quote!(Java {
624                throw new IllegalArgumentException($S("unsupported multipart part content type"));
625            })
626            .expect("unsupported multipart part block builds"),
627        );
628    } else {
629        cb.add_code(
630            sigil_quote!(Java {
631                multipartBuilder.addFormDataPart($S(wire_name), null, RequestBody.create(String.valueOf($L(access)), MediaType.get($S(content_type))));
632            })
633            .expect("text multipart part block builds"),
634        );
635    }
636}
637
638// ---------------------------------------------------------------------------
639// Planning
640// ---------------------------------------------------------------------------
641
642struct OpPlan<'a> {
643    op: &'a IrOperation,
644    method_name: String,
645    response_type: String,
646    path_params: Vec<ParamBinding<'a>>,
647    query_params: Vec<ParamBinding<'a>>,
648    header_params: Vec<ParamBinding<'a>>,
649    body: Option<BodyBinding>,
650    typed_responses: Vec<TypedResponse>,
651}
652
653struct ParamBinding<'a> {
654    param: &'a IrParameter,
655    var_name: String,
656    java_type: String,
657}
658
659struct BodyBinding {
660    var_name: String,
661    java_type: String,
662    media_type: String,
663    required: bool,
664    encoding: BodyEncoding,
665    multipart_parts: Option<Vec<MultipartPart>>,
666}
667
668struct MultipartPart {
669    wire_name: String,
670    field_name: String,
671    is_binary: bool,
672    required: bool,
673    content_type: String,
674    value_encoding: MultipartValueEncoding,
675}
676
677#[derive(Clone, Copy, PartialEq, Eq)]
678enum BodyEncoding {
679    Json,
680    Multipart,
681    FormUrlEncoded,
682    Xml,
683    TextPlain,
684    OctetStream,
685    Other,
686}
687
688#[derive(Clone, Copy, PartialEq, Eq)]
689enum ResponseDecoding {
690    Json,
691    Text,
692    Bytes,
693}
694
695struct TypedResponse {
696    status: String,
697    field_name: String,
698    java_type: String,
699    decoding: ResponseDecoding,
700}
701
702fn plan_operation<'a>(op: &'a IrOperation, ir: &IrSpec) -> OpPlan<'a> {
703    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
704    let method_name = op_id.to_lower_camel_case();
705    let response_type = format!("{}Response", op_id.to_pascal_case());
706
707    let mut used_names: HashSet<String> = HashSet::new();
708
709    let mut path_params = Vec::new();
710    let mut query_params = Vec::new();
711    let mut header_params = Vec::new();
712    for p in &op.parameters {
713        let var_name = unique_name(&java_ident(&p.name), &mut used_names);
714        let java_type = if p.required {
715            java_type_str(&p.type_expr)
716        } else {
717            java_boxed_type_str(&p.type_expr)
718        };
719        let binding = ParamBinding {
720            param: p,
721            var_name,
722            java_type,
723        };
724        match p.location {
725            ParameterLocation::Path => path_params.push(binding),
726            ParameterLocation::Query => query_params.push(binding),
727            ParameterLocation::Header => header_params.push(binding),
728            ParameterLocation::Cookie => header_params.push(binding),
729        }
730    }
731
732    let body = op
733        .request_body
734        .as_ref()
735        .and_then(|b| plan_body(b, ir, &mut used_names));
736
737    let typed_responses = op.responses.iter().filter_map(plan_response).collect();
738
739    OpPlan {
740        op,
741        method_name,
742        response_type,
743        path_params,
744        query_params,
745        header_params,
746        body,
747        typed_responses,
748    }
749}
750
751fn plan_body(
752    b: &IrRequestBody,
753    ir: &IrSpec,
754    used_names: &mut HashSet<String>,
755) -> Option<BodyBinding> {
756    let (media_type, t) = pick_body_content(b)?;
757    let encoding = body_encoding(&media_type);
758    let java_type = match encoding {
759        BodyEncoding::TextPlain => "String".to_string(),
760        BodyEncoding::OctetStream => "byte[]".to_string(),
761        _ => java_type_str(&t),
762    };
763    let var_name = unique_name("body", used_names);
764    let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
765        multipart_parts_for(b, &media_type, ir)
766    } else {
767        None
768    };
769    Some(BodyBinding {
770        var_name,
771        java_type,
772        media_type,
773        required: b.required,
774        encoding,
775        multipart_parts,
776    })
777}
778
779fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
780    let (media_type, t) = pick_response_content(r)?;
781    let decoding = response_decoding(&media_type);
782    let java_type = match decoding {
783        ResponseDecoding::Json => java_type_str(&t),
784        ResponseDecoding::Text => "String".to_string(),
785        ResponseDecoding::Bytes => "byte[]".to_string(),
786    };
787    Some(TypedResponse {
788        status: r.status.clone(),
789        field_name: response_field_name(&r.status),
790        java_type,
791        decoding,
792    })
793}
794
795fn response_field_name(status: &str) -> String {
796    if status == "default" {
797        "default_".to_string()
798    } else if let Ok(code) = status.parse::<u16>() {
799        format!("status{code}")
800    } else {
801        format!("status{}", status.to_lowercase())
802    }
803}
804
805fn wildcard_status_guard_java(status: &str) -> String {
806    let upper = status.to_uppercase();
807    if upper == "4XX" {
808        "response.code() >= 400 && response.code() < 500".to_string()
809    } else if upper == "5XX" {
810        "response.code() >= 500 && response.code() < 600".to_string()
811    } else {
812        // "default" or unknown wildcard: match everything (fallback response)
813        "true".to_string()
814    }
815}
816
817fn body_encoding(media_type: &str) -> BodyEncoding {
818    let base = media_type_base(media_type);
819    if base == "multipart/form-data" {
820        BodyEncoding::Multipart
821    } else if is_json_media_type(media_type) {
822        BodyEncoding::Json
823    } else if base == "application/x-www-form-urlencoded" {
824        BodyEncoding::FormUrlEncoded
825    } else if is_xml_media_type(media_type) {
826        BodyEncoding::Xml
827    } else if base == "text/plain" {
828        BodyEncoding::TextPlain
829    } else if base == "application/octet-stream" {
830        BodyEncoding::OctetStream
831    } else {
832        BodyEncoding::Other
833    }
834}
835
836fn response_decoding(media_type: &str) -> ResponseDecoding {
837    let base = media_type_base(media_type);
838    if is_json_media_type(media_type) {
839        ResponseDecoding::Json
840    } else if base == "text/plain" || is_xml_media_type(media_type) {
841        ResponseDecoding::Text
842    } else {
843        ResponseDecoding::Bytes
844    }
845}
846
847fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
848    pick_media_type(&body.content, |media_type| {
849        media_type_base(media_type) == "application/json"
850    })
851    .or_else(|| pick_media_type(&body.content, is_json_media_type))
852    .or_else(|| {
853        pick_media_type(&body.content, |media_type| {
854            media_type_base(media_type) == "multipart/form-data"
855        })
856    })
857    .or_else(|| {
858        pick_media_type(&body.content, |media_type| {
859            media_type_base(media_type) == "application/x-www-form-urlencoded"
860        })
861    })
862    .or_else(|| pick_media_type(&body.content, is_xml_media_type))
863    .or_else(|| {
864        pick_media_type(&body.content, |media_type| {
865            media_type_base(media_type) == "text/plain"
866        })
867    })
868    .or_else(|| {
869        pick_media_type(&body.content, |media_type| {
870            media_type_base(media_type) == "application/octet-stream"
871        })
872    })
873    .or_else(|| pick_first_content(&body.content))
874}
875
876fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
877    pick_media_type(&r.content, |media_type| {
878        media_type_base(media_type) == "application/json"
879    })
880    .or_else(|| pick_media_type(&r.content, is_json_media_type))
881    .or_else(|| {
882        pick_media_type(&r.content, |media_type| {
883            media_type_base(media_type) == "application/octet-stream"
884        })
885    })
886    .or_else(|| {
887        pick_media_type(&r.content, |media_type| {
888            media_type_base(media_type) == "text/plain"
889        })
890    })
891    .or_else(|| pick_media_type(&r.content, is_xml_media_type))
892    .or_else(|| pick_first_content(&r.content))
893}
894
895fn pick_media_type(
896    content: &indexmap::IndexMap<String, IrTypeExpr>,
897    predicate: impl Fn(&str) -> bool,
898) -> Option<(String, IrTypeExpr)> {
899    content
900        .iter()
901        .find(|(media_type, _)| predicate(media_type))
902        .map(|(media_type, t)| (media_type.clone(), t.clone()))
903}
904
905fn pick_first_content(
906    content: &indexmap::IndexMap<String, IrTypeExpr>,
907) -> Option<(String, IrTypeExpr)> {
908    content
909        .iter()
910        .next()
911        .map(|(media_type, t)| (media_type.clone(), t.clone()))
912}
913
914fn media_type_base(media_type: &str) -> String {
915    media_type
916        .split(';')
917        .next()
918        .unwrap_or(media_type)
919        .trim()
920        .to_ascii_lowercase()
921}
922
923fn is_json_media_type(media_type: &str) -> bool {
924    let base = media_type_base(media_type);
925    base == "application/json" || base.ends_with("+json")
926}
927
928fn is_xml_media_type(media_type: &str) -> bool {
929    let base = media_type_base(media_type);
930    base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
931}
932
933fn multipart_parts_for(
934    body: &IrRequestBody,
935    media_type: &str,
936    ir: &IrSpec,
937) -> Option<Vec<MultipartPart>> {
938    multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
939        parts
940            .into_iter()
941            .map(|part| MultipartPart {
942                field_name: java_field_name(&part.wire_name),
943                wire_name: part.wire_name,
944                is_binary: part.is_binary,
945                required: part.required,
946                content_type: part.content_type,
947                value_encoding: part.value_encoding,
948            })
949            .collect()
950    })
951}