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