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::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
50// ---------------------------------------------------------------------------
51// File assembly
52// ---------------------------------------------------------------------------
53
54fn 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    // Response classes
80    for plan in &plans {
81        fb = fb.add_type(build_response_class(plan));
82    }
83
84    // API class
85    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    // Fields
91    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    // Constructor
108    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    // API methods
121    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
139// ---------------------------------------------------------------------------
140// Response class
141// ---------------------------------------------------------------------------
142
143fn 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    // Fields
152    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    // Constructor
182    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    // Getters
225    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
244// ---------------------------------------------------------------------------
245// Operation method
246// ---------------------------------------------------------------------------
247
248fn 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    // Parameters
264    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
295// ---------------------------------------------------------------------------
296// Method body
297// ---------------------------------------------------------------------------
298
299fn emit_method_body(plan: &OpPlan<'_>) -> CodeBlock {
300    let mut cb = CodeBlock::builder();
301
302    // Path
303    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    // Query
312    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    // Body serialization
334    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    // Build request
345    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    // Headers
355    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    // Execute
379    cb.add_statement("Response response = client.execute(request)", ());
380    cb.add_line();
381
382    // Error handling
383    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    // Response parsing
393    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        // Numeric status codes
401        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                // "default" or wildcard status: populate as fallback
419                cb.add_statement(
420                    &format!(
421                        "{} = gson.fromJson(responseBody, {})",
422                        tr.field_name, type_token
423                    ),
424                    (),
425                );
426            }
427        }
428
429        // Return with typed fields
430        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        // deduplicate
435        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
463// ---------------------------------------------------------------------------
464// Planning
465// ---------------------------------------------------------------------------
466
467struct 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}