1use std::collections::{BTreeMap, HashSet};
2
3use crate::codegen::traits::file_writer::FileInfo;
4use crate::ir::types::{
5 IrOperation, IrParameter, IrRequestBody, IrResponse, IrSpec, IrTypeExpr, ParameterLocation,
6};
7use heck::{ToLowerCamelCase, ToPascalCase};
8use sigil_stitch::lang::java_lang::JavaLang;
9use sigil_stitch::prelude::*;
10
11use super::util::{
12 build_java_getter, java_boxed_type_str, java_ident, java_type_str, render_value_as_string,
13 sanitize_operation_id, unique_name,
14};
15
16const RENDER_WIDTH: usize = 100;
17
18pub fn generate_api_files(
19 ir: &IrSpec,
20 package_name: &str,
21 header: &str,
22) -> Result<Vec<FileInfo>, String> {
23 let by_tag = group_by_tag(&ir.operations);
24 let mut files = Vec::with_capacity(by_tag.len());
25 for (tag, ops) in &by_tag {
26 let class_name = format!("{}Api", tag.to_pascal_case());
27 let filename = format!("{class_name}.java");
28 let body = emit_api_file(tag, ops, package_name);
29 let content = format!("{header}{body}");
30 files.push(FileInfo::api(filename, content));
31 }
32 Ok(files)
33}
34
35fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
36 let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
37 for op in operations {
38 let tags: Vec<String> = if op.tags.is_empty() {
39 vec!["default".to_string()]
40 } else {
41 op.tags.clone()
42 };
43 for tag in tags {
44 out.entry(tag).or_default().push(op);
45 }
46 }
47 out
48}
49
50fn emit_api_file(tag: &str, ops: &[&IrOperation], package_name: &str) -> String {
55 let class_name = format!("{}Api", tag.to_pascal_case());
56 let plans: Vec<OpPlan> = ops.iter().map(|op| plan_operation(op)).collect();
57
58 let filename = format!("{class_name}.java");
59 let mut fb = FileSpec::builder_with(&filename, JavaLang::new())
60 .header(package_header(package_name))
61 .add_import(ImportSpec::named(&format!("{package_name}.models"), "*"))
62 .add_import(ImportSpec::named(
63 &format!("{package_name}.runtime"),
64 "ApiClient",
65 ))
66 .add_import(ImportSpec::named(
67 &format!("{package_name}.runtime"),
68 "ApiException",
69 ))
70 .add_import(ImportSpec::named("com.google.gson", "Gson"))
71 .add_import(ImportSpec::named("com.google.gson.reflect", "TypeToken"))
72 .add_import(ImportSpec::named("java.io", "IOException"))
73 .add_import(ImportSpec::named("java.util", "HashMap"))
74 .add_import(ImportSpec::named("java.util", "List"))
75 .add_import(ImportSpec::named("java.util", "Map"))
76 .add_import(ImportSpec::named("okhttp3", "Request"))
77 .add_import(ImportSpec::named("okhttp3", "Response"));
78
79 for plan in &plans {
81 fb = fb.add_type(build_response_class(plan));
82 }
83
84 let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).visibility(Visibility::Public);
86 cls = cls.doc(&format!(
87 "{class_name} groups operations under the {tag} tag."
88 ));
89
90 cls = cls.add_field(
92 FieldSpec::builder("client", TypeName::primitive("ApiClient"))
93 .visibility(Visibility::Private)
94 .is_readonly()
95 .build()
96 .expect("client field"),
97 );
98 cls = cls.add_field(
99 FieldSpec::builder("gson", TypeName::primitive("Gson"))
100 .visibility(Visibility::Private)
101 .is_readonly()
102 .initializer(CodeBlock::of("new Gson()", ()).expect("gson init"))
103 .build()
104 .expect("gson field"),
105 );
106
107 let mut ctor = FunSpec::builder(&class_name);
109 ctor = ctor.visibility(Visibility::Public);
110 ctor = ctor.add_param(
111 ParameterSpec::new("ApiClient client", TypeName::primitive("")).expect("client param"),
112 );
113 let ctor_body = sigil_quote!(JavaLang {
114 this.client = client;
115 })
116 .expect("ctor body");
117 ctor = ctor.body(ctor_body);
118 cls = cls.add_method(ctor.build().expect("constructor"));
119
120 for plan in &plans {
122 cls = cls.add_method(build_operation_fun(plan));
123 }
124
125 fb = fb.add_type(cls.build().expect("API class builds"));
126
127 let file = fb.build().expect("FileSpec builds for API file");
128 file.render(RENDER_WIDTH)
129 .expect("FileSpec renders for API file")
130}
131
132fn package_header(package_name: &str) -> CodeBlock {
133 sigil_quote!(JavaLang {
134 package $L(format!("{package_name}.apis"));
135 })
136 .expect("package header builds")
137}
138
139fn build_response_class(plan: &OpPlan<'_>) -> TypeSpec {
144 let mut tb =
145 TypeSpec::builder(&plan.response_type, TypeKind::Struct).visibility(Visibility::Public);
146 tb = tb.doc(&format!(
147 "{} carries the response from {}.",
148 plan.response_type, plan.method_name
149 ));
150
151 tb = tb.add_field(
153 FieldSpec::builder("statusCode", TypeName::primitive("int"))
154 .visibility(Visibility::Private)
155 .is_readonly()
156 .build()
157 .expect("field"),
158 );
159 tb = tb.add_field(
160 FieldSpec::builder("raw", TypeName::primitive("Response"))
161 .visibility(Visibility::Private)
162 .is_readonly()
163 .build()
164 .expect("field"),
165 );
166
167 let mut seen: HashSet<String> = HashSet::new();
168 for tr in &plan.typed_responses {
169 if !seen.insert(tr.field_name.clone()) {
170 continue;
171 }
172 tb = tb.add_field(
173 FieldSpec::builder(&tr.field_name, TypeName::primitive(&tr.java_type))
174 .visibility(Visibility::Private)
175 .is_readonly()
176 .build()
177 .expect("field"),
178 );
179 }
180
181 let mut ctor = FunSpec::builder(&plan.response_type);
183 ctor = ctor.visibility(Visibility::Public);
184 ctor = ctor
185 .add_param(ParameterSpec::new("int statusCode", TypeName::primitive("")).expect("param"));
186 ctor =
187 ctor.add_param(ParameterSpec::new("Response raw", TypeName::primitive("")).expect("param"));
188 let mut ctor_seen: HashSet<String> = HashSet::new();
189 for tr in &plan.typed_responses {
190 if !ctor_seen.insert(tr.field_name.clone()) {
191 continue;
192 }
193 ctor = ctor.add_param(
194 ParameterSpec::new(
195 &format!("{} {}", tr.java_type, tr.field_name),
196 TypeName::primitive(""),
197 )
198 .expect("param"),
199 );
200 }
201 let mut field_assignments: Vec<CodeBlock> = vec![
202 sigil_quote!(JavaLang { this.statusCode = statusCode; }).expect("assign"),
203 sigil_quote!(JavaLang { this.raw = raw; }).expect("assign"),
204 ];
205 let mut body_seen: HashSet<String> = HashSet::new();
206 for tr in &plan.typed_responses {
207 if !body_seen.insert(tr.field_name.clone()) {
208 continue;
209 }
210 field_assignments.push(
211 sigil_quote!(JavaLang {
212 this.$L(tr.field_name.as_str()) = $L(tr.field_name.as_str());
213 })
214 .expect("assign"),
215 );
216 }
217 let ctor_body = sigil_quote!(JavaLang {
218 $C_each(field_assignments);
219 })
220 .expect("ctor body");
221 ctor = ctor.body(ctor_body);
222 tb = tb.add_method(ctor.build().expect("response ctor"));
223
224 tb = tb.add_method(build_java_getter("getStatusCode", "int", "statusCode"));
226 tb = tb.add_method(build_java_getter("getRaw", "Response", "raw"));
227
228 let mut getter_seen: HashSet<String> = HashSet::new();
229 for tr in &plan.typed_responses {
230 if !getter_seen.insert(tr.field_name.clone()) {
231 continue;
232 }
233 let getter_name = format!("get{}", tr.field_name.to_pascal_case());
234 tb = tb.add_method(build_java_getter(
235 &getter_name,
236 &tr.java_type,
237 &tr.field_name,
238 ));
239 }
240
241 tb.build().expect("response class builds")
242}
243
244fn build_operation_fun(plan: &OpPlan<'_>) -> FunSpec {
249 let mut fb = FunSpec::builder(&plan.method_name);
250 fb = fb.visibility(Visibility::Public);
251
252 if let Some(summary) = &plan.op.summary {
253 fb = fb.doc(summary);
254 } else {
255 fb = fb.doc(&format!(
256 "{} {} {}.",
257 plan.method_name,
258 plan.op.method.to_uppercase(),
259 plan.op.path,
260 ));
261 }
262
263 for p in plan
265 .path_params
266 .iter()
267 .chain(&plan.query_params)
268 .chain(&plan.header_params)
269 {
270 fb = fb.add_param(
271 ParameterSpec::new(
272 &format!("{} {}", p.java_type, p.var_name),
273 TypeName::primitive(""),
274 )
275 .expect("param"),
276 );
277 }
278 if let Some(body) = &plan.body {
279 fb = fb.add_param(
280 ParameterSpec::new(
281 &format!("{} {}", body.java_type, body.var_name),
282 TypeName::primitive(""),
283 )
284 .expect("body param"),
285 );
286 }
287
288 fb = fb.returns(TypeName::primitive(&plan.response_type));
289 fb = fb.suffix("throws IOException");
290 fb = fb.body(emit_method_body(plan));
291
292 fb.build().expect("operation FunSpec builds")
293}
294
295fn emit_method_body(plan: &OpPlan<'_>) -> CodeBlock {
300 let mut cb = CodeBlock::builder();
301
302 let mut path_expr = format!("\"{}\"", plan.op.path);
304 for p in &plan.path_params {
305 let placeholder = format!("{{{}}}", p.param.name);
306 let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
307 path_expr = format!("{path_expr}.replace(\"{placeholder}\", {stringified})");
308 }
309 cb.add_statement(&format!("String path = {path_expr}"), ());
310
311 let has_query = !plan.query_params.is_empty();
313 if has_query {
314 cb.add_statement("Map<String, String> query = new HashMap<>()", ());
315 for p in &plan.query_params {
316 let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
317 if p.param.required {
318 cb.add_statement(
319 &format!("query.put(\"{}\", {})", p.param.name, stringified),
320 (),
321 );
322 } else {
323 cb.begin_control_flow(&format!("if ({} != null)", p.var_name), ());
324 cb.add_statement(
325 &format!("query.put(\"{}\", {})", p.param.name, stringified),
326 (),
327 );
328 cb.end_control_flow();
329 }
330 }
331 }
332
333 let body_arg = if let Some(body) = &plan.body {
335 cb.add_statement(
336 &format!("String jsonBody = gson.toJson({})", body.var_name),
337 (),
338 );
339 "jsonBody"
340 } else {
341 "null"
342 };
343
344 let query_arg = if has_query { "query" } else { "null" };
346 cb.add_statement(
347 &format!(
348 "Request request = client.newRequest(\"{}\", path, {query_arg}, {body_arg})",
349 plan.op.method.to_uppercase(),
350 ),
351 (),
352 );
353
354 for p in &plan.header_params {
356 let stringified = render_value_as_string(&p.var_name, &p.param.type_expr);
357 if p.param.required {
358 cb.add_statement(
359 &format!(
360 "request = request.newBuilder().header(\"{}\", {stringified}).build()",
361 p.param.name
362 ),
363 (),
364 );
365 } else {
366 cb.begin_control_flow(&format!("if ({} != null)", p.var_name), ());
367 cb.add_statement(
368 &format!(
369 "request = request.newBuilder().header(\"{}\", {stringified}).build()",
370 p.param.name
371 ),
372 (),
373 );
374 cb.end_control_flow();
375 }
376 }
377
378 cb.add_statement("Response response = client.execute(request)", ());
380 cb.add_line();
381
382 let error_block = sigil_quote!(JavaLang {
384 if (!response.isSuccessful()) {
385 String errorBody = response.body() != null ? response.body().string() : "";
386 throw new ApiException(response.code(), response.message(), errorBody);
387 }
388 })
389 .expect("error block");
390 cb.add_code(error_block);
391
392 if !plan.typed_responses.is_empty() {
394 cb.add_statement(
395 "String responseBody = response.body() != null ? response.body().string() : \"null\"",
396 (),
397 );
398 let mut seen: HashSet<String> = HashSet::new();
399
400 for tr in &plan.typed_responses {
402 if !seen.insert(tr.field_name.clone()) {
403 continue;
404 }
405 let type_token = format!("new TypeToken<{}>() {{}}.getType()", tr.java_type);
406 cb.add_statement(&format!("{} {} = null", tr.java_type, tr.field_name), ());
407 if let Ok(code) = tr.status.parse::<u16>() {
408 cb.begin_control_flow(&format!("if (response.code() == {code})"), ());
409 cb.add_statement(
410 &format!(
411 "{} = gson.fromJson(responseBody, {})",
412 tr.field_name, type_token
413 ),
414 (),
415 );
416 cb.end_control_flow();
417 } else {
418 cb.add_statement(
420 &format!(
421 "{} = gson.fromJson(responseBody, {})",
422 tr.field_name, type_token
423 ),
424 (),
425 );
426 }
427 }
428
429 let args: Vec<String> = std::iter::once("response.code()".to_string())
431 .chain(std::iter::once("response".to_string()))
432 .chain(plan.typed_responses.iter().map(|tr| tr.field_name.clone()))
433 .collect();
434 let mut dedup_args: Vec<String> = Vec::new();
436 let mut args_seen: HashSet<String> = HashSet::new();
437 for a in args {
438 if args_seen.insert(a.clone()) {
439 dedup_args.push(a);
440 }
441 }
442 cb.add_statement(
443 &format!(
444 "return new {}({})",
445 plan.response_type,
446 dedup_args.join(", ")
447 ),
448 (),
449 );
450 } else {
451 cb.add_statement(
452 &format!(
453 "return new {}(response.code(), response)",
454 plan.response_type
455 ),
456 (),
457 );
458 }
459
460 cb.build().expect("method body builds")
461}
462
463struct OpPlan<'a> {
468 op: &'a IrOperation,
469 method_name: String,
470 response_type: String,
471 path_params: Vec<ParamBinding<'a>>,
472 query_params: Vec<ParamBinding<'a>>,
473 header_params: Vec<ParamBinding<'a>>,
474 body: Option<BodyBinding>,
475 typed_responses: Vec<TypedResponse>,
476}
477
478struct ParamBinding<'a> {
479 param: &'a IrParameter,
480 var_name: String,
481 java_type: String,
482}
483
484struct BodyBinding {
485 var_name: String,
486 java_type: String,
487}
488
489struct TypedResponse {
490 status: String,
491 field_name: String,
492 java_type: String,
493}
494
495fn plan_operation<'a>(op: &'a IrOperation) -> OpPlan<'a> {
496 let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
497 let method_name = op_id.to_lower_camel_case();
498 let response_type = format!("{}Response", op_id.to_pascal_case());
499
500 let mut used_names: HashSet<String> = HashSet::new();
501
502 let mut path_params = Vec::new();
503 let mut query_params = Vec::new();
504 let mut header_params = Vec::new();
505 for p in &op.parameters {
506 let var_name = unique_name(&java_ident(&p.name), &mut used_names);
507 let java_type = if p.required {
508 java_type_str(&p.type_expr)
509 } else {
510 java_boxed_type_str(&p.type_expr)
511 };
512 let binding = ParamBinding {
513 param: p,
514 var_name,
515 java_type,
516 };
517 match p.location {
518 ParameterLocation::Path => path_params.push(binding),
519 ParameterLocation::Query => query_params.push(binding),
520 ParameterLocation::Header => header_params.push(binding),
521 ParameterLocation::Cookie => header_params.push(binding),
522 }
523 }
524
525 let body = op
526 .request_body
527 .as_ref()
528 .and_then(|b| plan_body(b, &mut used_names));
529
530 let typed_responses = op.responses.iter().filter_map(plan_response).collect();
531
532 OpPlan {
533 op,
534 method_name,
535 response_type,
536 path_params,
537 query_params,
538 header_params,
539 body,
540 typed_responses,
541 }
542}
543
544fn plan_body(b: &IrRequestBody, used_names: &mut HashSet<String>) -> Option<BodyBinding> {
545 let t = pick_body_type(b)?;
546 let java_type = java_type_str(&t);
547 let var_name = unique_name("body", used_names);
548 Some(BodyBinding {
549 var_name,
550 java_type,
551 })
552}
553
554fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
555 let t = pick_response_type(r)?;
556 let java_type = java_type_str(&t);
557 Some(TypedResponse {
558 status: r.status.clone(),
559 field_name: response_field_name(&r.status),
560 java_type,
561 })
562}
563
564fn response_field_name(status: &str) -> String {
565 if status == "default" {
566 "default_".to_string()
567 } else if let Ok(code) = status.parse::<u16>() {
568 format!("status{code}")
569 } else {
570 format!("status{}", status.to_lowercase())
571 }
572}
573
574fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
575 r.content
576 .get("application/json")
577 .cloned()
578 .or_else(|| r.content.values().next().cloned())
579}
580
581fn pick_body_type(body: &IrRequestBody) -> Option<IrTypeExpr> {
582 body.content
583 .get("application/json")
584 .cloned()
585 .or_else(|| body.content.values().next().cloned())
586}