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