Skip to main content

openapi_nexus/generators/java/okhttp/
sigil_emit.rs

1use std::collections::HashSet;
2
3use crate::codegen::traits::file_writer::FileInfo;
4use crate::generators::request_inputs::{
5    RequestInputField, RequestInputFieldKind, RequestInputModel, RequestInputPlan,
6};
7use crate::ir::types::{
8    IrEnum, IrEnumValueType, IrIntersection, IrObject, IrSchema, IrSchemaKind, IrSpec,
9    IrTaggedUnion, IrTypeExpr, IrUnion, TaggingStyle,
10};
11use heck::{ToLowerCamelCase, ToPascalCase};
12use sigil_stitch::lang::java::Java;
13use sigil_stitch::prelude::*;
14
15use super::util::{
16    build_java_getter, escape_java_string, java_boxed_type_str, java_field_name, java_type_str,
17    type_uses_list, type_uses_map, unique_name,
18};
19
20const RENDER_WIDTH: usize = 100;
21
22pub fn generate_model_files(
23    ir: &IrSpec,
24    package_name: &str,
25    header: &str,
26    request_inputs: &RequestInputPlan,
27) -> Result<Vec<FileInfo>, String> {
28    let mut files = Vec::new();
29    for (_name, schema) in &ir.schemas {
30        let body = emit_model_body(schema, package_name).ok_or_else(|| {
31            format!(
32                "unsupported schema kind for {}: {:?}",
33                schema.name, schema.kind
34            )
35        })?;
36        let class_name = schema.name.to_pascal_case();
37        let filename = format!("{class_name}.java");
38        let mut content = String::with_capacity(header.len() + body.len());
39        content.push_str(header);
40        content.push_str(&body);
41        files.push(FileInfo::model(filename, content));
42    }
43    for model in request_inputs.models() {
44        files.push(request_input_model_file(model, package_name, header));
45    }
46    Ok(files)
47}
48
49fn request_input_model_file(
50    model: &RequestInputModel,
51    package_name: &str,
52    header: &str,
53) -> FileInfo {
54    let class_name = model.name.to_pascal_case();
55    let needs_upload = model.fields.iter().any(RequestInputField::is_upload);
56    let mut content = String::new();
57    content.push_str(header);
58    content.push_str(&format!("package {package_name}.models;\n\n"));
59    if needs_upload {
60        content.push_str(&format!("import {package_name}.runtime.UploadFile;\n\n"));
61    }
62    content.push_str(&format!("public final class {class_name} {{\n"));
63    for field in &model.fields {
64        content.push_str(&format!(
65            "    private final {} {};\n",
66            request_input_java_type(field),
67            java_field_name(&field.wire_name)
68        ));
69    }
70    content.push('\n');
71    content.push_str(&format!("    public {class_name}("));
72    let params = model
73        .fields
74        .iter()
75        .map(|field| {
76            format!(
77                "{} {}",
78                request_input_java_type(field),
79                java_field_name(&field.wire_name)
80            )
81        })
82        .collect::<Vec<_>>()
83        .join(", ");
84    content.push_str(&params);
85    content.push_str(") {\n");
86    for field in &model.fields {
87        let name = java_field_name(&field.wire_name);
88        content.push_str(&format!("        this.{name} = {name};\n"));
89    }
90    content.push_str("    }\n\n");
91    for field in &model.fields {
92        let field_name = java_field_name(&field.wire_name);
93        let getter = format!("get{}", field.wire_name.to_pascal_case());
94        content.push_str(&format!(
95            "    public {} {}() {{\n        return {};\n    }}\n\n",
96            request_input_java_type(field),
97            getter,
98            field_name
99        ));
100    }
101    content.push_str("}\n");
102
103    FileInfo::model(format!("{class_name}.java"), content)
104}
105
106fn request_input_java_type(field: &RequestInputField) -> String {
107    match field.kind {
108        RequestInputFieldKind::UploadFile { .. } => "UploadFile".to_string(),
109        RequestInputFieldKind::SchemaValue => java_boxed_type_str(&field.type_expr),
110    }
111}
112
113fn emit_model_body(schema: &IrSchema, package_name: &str) -> Option<String> {
114    let file_spec = match &schema.kind {
115        IrSchemaKind::Object(obj) => emit_object(schema, obj, package_name),
116        IrSchemaKind::Enum(en) => emit_enum(schema, en, package_name),
117        IrSchemaKind::Alias(expr) => emit_alias(schema, expr, package_name),
118        IrSchemaKind::Union(u) => emit_union(schema, u, package_name),
119        IrSchemaKind::Intersection(i) => emit_intersection(schema, i, package_name),
120        IrSchemaKind::TaggedUnion(tu) => emit_tagged_union(schema, tu, package_name),
121    }?;
122    file_spec.render(RENDER_WIDTH).ok()
123}
124
125fn package_header(package_name: &str) -> CodeBlock {
126    sigil_quote!(Java {
127        package $L(format!("{package_name}.models"));
128    })
129    .expect("package header builds")
130}
131
132// ---------------------------------------------------------------------------
133// Object -> class with private fields, constructor, getters
134// ---------------------------------------------------------------------------
135
136fn emit_object(schema: &IrSchema, obj: &IrObject, package_name: &str) -> Option<FileSpec> {
137    let name = schema.name.to_pascal_case();
138
139    let mut file =
140        FileSpec::builder_with("model.java", Java::new()).header(package_header(package_name));
141
142    let needs_serialized_name = obj.properties.iter().any(|(json_name, prop)| {
143        let field_name = java_field_name(&prop.name);
144        *json_name != field_name
145    });
146    if needs_serialized_name {
147        file = file.add_import(ImportSpec::named(
148            "com.google.gson.annotations",
149            "SerializedName",
150        ));
151    }
152
153    let needs_list = obj
154        .properties
155        .iter()
156        .any(|(_, prop)| type_uses_list(&prop.type_expr));
157    if needs_list {
158        file = file.add_import(ImportSpec::named("java.util", "List"));
159    }
160
161    let needs_map = obj
162        .properties
163        .iter()
164        .any(|(_, prop)| type_uses_map(&prop.type_expr));
165    if needs_map {
166        file = file.add_import(ImportSpec::named("java.util", "Map"));
167    }
168
169    let mut tb = TypeSpec::builder(&name, TypeKind::Struct).visibility(Visibility::Public);
170    if let Some(doc) = &schema.description {
171        tb = tb.doc(doc);
172    }
173
174    // Fields
175    for (json_name, prop) in &obj.properties {
176        let field_name = java_field_name(&prop.name);
177        let required = prop.required && !prop.nullable;
178        let type_str = if required {
179            java_type_str(&prop.type_expr)
180        } else {
181            java_boxed_type_str(&prop.type_expr)
182        };
183
184        let annotation = if *json_name != field_name {
185            Some(format!(
186                "@SerializedName(\"{}\")",
187                escape_java_string(json_name)
188            ))
189        } else {
190            None
191        };
192
193        let mut fb = FieldSpec::builder(&field_name, TypeName::primitive(&type_str))
194            .visibility(Visibility::Private);
195        if let Some(ann) = annotation {
196            fb = fb.annotation(CodeBlock::of(&ann, ()).expect("annotation"));
197        }
198        tb = tb.add_field(fb.build().expect("field"));
199    }
200
201    // Constructor
202    let mut ctor = FunSpec::builder(&name);
203    ctor = ctor.visibility(Visibility::Public);
204    for (_json_name, prop) in &obj.properties {
205        let field_name = java_field_name(&prop.name);
206        let required = prop.required && !prop.nullable;
207        let type_str = if required {
208            java_type_str(&prop.type_expr)
209        } else {
210            java_boxed_type_str(&prop.type_expr)
211        };
212        ctor = ctor.add_param(
213            ParameterSpec::new(&format!("{type_str} {field_name}"), TypeName::primitive(""))
214                .expect("ctor param"),
215        );
216    }
217    let assignment_fields: Vec<String> = obj
218        .properties
219        .iter()
220        .map(|(_json_name, prop)| java_field_name(&prop.name))
221        .collect();
222    let ctor_body = sigil_quote!(Java {
223        $for(field_name in &assignment_fields) {
224            this.$L(field_name.as_str()) = $L(field_name.as_str());
225        }
226    })
227    .expect("ctor body");
228    ctor = ctor.body(ctor_body);
229    tb = tb.add_method(ctor.build().expect("constructor"));
230
231    // Getters
232    for (_json_name, prop) in &obj.properties {
233        let field_name = java_field_name(&prop.name);
234        let required = prop.required && !prop.nullable;
235        let type_str = if required {
236            java_type_str(&prop.type_expr)
237        } else {
238            java_boxed_type_str(&prop.type_expr)
239        };
240        let getter_name = format!("get{}", prop.name.to_pascal_case());
241        tb = tb.add_method(build_java_getter(&getter_name, &type_str, &field_name));
242    }
243
244    file = file.add_type(tb.build().ok()?);
245    file.build().ok()
246}
247
248// ---------------------------------------------------------------------------
249// Enum -> enum with value field
250// ---------------------------------------------------------------------------
251
252fn emit_enum(schema: &IrSchema, en: &IrEnum, package_name: &str) -> Option<FileSpec> {
253    let name = schema.name.to_pascal_case();
254
255    let mut file =
256        FileSpec::builder_with("model.java", Java::new()).header(package_header(package_name));
257
258    if en.value_type == IrEnumValueType::Mixed {
259        return emit_comment_class(&name, "Object", schema.description.as_deref(), package_name);
260    }
261
262    file = file.add_import(ImportSpec::named(
263        "com.google.gson.annotations",
264        "SerializedName",
265    ));
266
267    let base_type = match en.value_type {
268        IrEnumValueType::String => "String",
269        IrEnumValueType::Integer => "int",
270        IrEnumValueType::Number => "double",
271        IrEnumValueType::Mixed => unreachable!(),
272    };
273
274    let mut tb = TypeSpec::builder(&name, TypeKind::Enum).visibility(Visibility::Public);
275    if let Some(doc) = &schema.description {
276        tb = tb.doc(doc);
277    }
278
279    for v in &en.values {
280        let (variant_name, literal, raw_value) = match en.value_type {
281            IrEnumValueType::String => {
282                let s = v.value.as_str()?;
283                (
284                    enum_variant_name(s),
285                    format!("\"{}\"", escape_java_string(s)),
286                    s.to_string(),
287                )
288            }
289            IrEnumValueType::Integer | IrEnumValueType::Number => {
290                let n = v.value.as_number()?;
291                let variant = format!("N{}", n.to_string().replace(['-', '.'], "_"));
292                let s = n.to_string();
293                (variant, s.clone(), s)
294            }
295            IrEnumValueType::Mixed => unreachable!(),
296        };
297
298        let variant_name = if variant_name.is_empty() {
299            "UNKNOWN".to_string()
300        } else {
301            variant_name
302        };
303
304        let mut ev = EnumVariantSpec::builder(&variant_name);
305        ev = ev.annotation(
306            CodeBlock::of(
307                &format!("@SerializedName(\"{}\")", escape_java_string(&raw_value)),
308                (),
309            )
310            .expect("annotation"),
311        );
312        ev = ev.value(CodeBlock::of(&literal, ()).expect("literal"));
313        tb = tb.add_variant(ev.build().expect("enum variant"));
314    }
315
316    // Field, constructor, and getter (rendered after variants in Java)
317    tb = tb.add_field(
318        FieldSpec::builder("value", TypeName::primitive(base_type))
319            .visibility(Visibility::Private)
320            .is_readonly()
321            .build()
322            .expect("value field"),
323    );
324
325    let mut ctor = FunSpec::builder(&name);
326    ctor = ctor.add_param(
327        ParameterSpec::new("value", TypeName::primitive(base_type)).expect("ctor param"),
328    );
329    let ctor_body = sigil_quote!(Java {
330        this.value = value;
331    })
332    .expect("ctor body");
333    ctor = ctor.body(ctor_body);
334    tb = tb.add_method(ctor.build().expect("enum ctor"));
335
336    tb = tb.add_method(build_java_getter("getValue", base_type, "value"));
337
338    file = file.add_type(tb.build().ok()?);
339    file.build().ok()
340}
341
342// ---------------------------------------------------------------------------
343// Alias -> wrapper class (Java has no typealias)
344// ---------------------------------------------------------------------------
345
346fn emit_alias(schema: &IrSchema, expr: &IrTypeExpr, package_name: &str) -> Option<FileSpec> {
347    let name = schema.name.to_pascal_case();
348    let rhs = java_type_str(expr);
349    emit_comment_class(&name, &rhs, schema.description.as_deref(), package_name)
350}
351
352// ---------------------------------------------------------------------------
353// Union -> Object wrapper class
354// ---------------------------------------------------------------------------
355
356fn emit_union(schema: &IrSchema, _union: &IrUnion, package_name: &str) -> Option<FileSpec> {
357    let name = schema.name.to_pascal_case();
358    emit_comment_class(&name, "Object", schema.description.as_deref(), package_name)
359}
360
361// ---------------------------------------------------------------------------
362// Intersection -> class with all merged properties
363// ---------------------------------------------------------------------------
364
365fn emit_intersection(
366    schema: &IrSchema,
367    inter: &IrIntersection,
368    package_name: &str,
369) -> Option<FileSpec> {
370    let name = schema.name.to_pascal_case();
371    let mut file =
372        FileSpec::builder_with("model.java", Java::new()).header(package_header(package_name));
373
374    let mut tb = TypeSpec::builder(&name, TypeKind::Struct).visibility(Visibility::Public);
375    if let Some(doc) = &schema.description {
376        tb = tb.doc(doc);
377    }
378
379    let mut used_names: HashSet<String> = HashSet::new();
380    let member_bindings: Vec<(String, String)> = inter
381        .members
382        .iter()
383        .map(|member| {
384            let member_type = java_type_str(member);
385            let field_name = unique_name(&member_type.to_lower_camel_case(), &mut used_names);
386            (member_type, field_name)
387        })
388        .collect();
389
390    for (member_type, field_name) in &member_bindings {
391        tb = tb.add_field(
392            FieldSpec::builder(field_name, TypeName::primitive(member_type))
393                .visibility(Visibility::Private)
394                .build()
395                .expect("field"),
396        );
397    }
398
399    // Constructor
400    let mut ctor = FunSpec::builder(&name);
401    ctor = ctor.visibility(Visibility::Public);
402    for (member_type, field_name) in &member_bindings {
403        ctor = ctor.add_param(
404            ParameterSpec::new(
405                &format!("{member_type} {field_name}"),
406                TypeName::primitive(""),
407            )
408            .expect("param"),
409        );
410    }
411    let assignment_fields: Vec<String> = member_bindings
412        .iter()
413        .map(|(_member_type, field_name)| field_name.clone())
414        .collect();
415    let ctor_body = sigil_quote!(Java {
416        $for(field_name in &assignment_fields) {
417            this.$L(field_name.as_str()) = $L(field_name.as_str());
418        }
419    })
420    .expect("ctor body");
421    ctor = ctor.body(ctor_body);
422    tb = tb.add_method(ctor.build().expect("constructor"));
423
424    // Getters
425    for (member_type, field_name) in &member_bindings {
426        let getter_name = format!("get{}", field_name.to_pascal_case());
427        tb = tb.add_method(build_java_getter(&getter_name, member_type, field_name));
428    }
429
430    file = file.add_type(tb.build().ok()?);
431    file.build().ok()
432}
433
434// ---------------------------------------------------------------------------
435// TaggedUnion -> wrapper class (sealed interfaces not well-supported by sigil-stitch)
436// ---------------------------------------------------------------------------
437
438fn emit_tagged_union(
439    schema: &IrSchema,
440    tu: &IrTaggedUnion,
441    package_name: &str,
442) -> Option<FileSpec> {
443    let name = schema.name.to_pascal_case();
444
445    if tu.variants.is_empty() {
446        return emit_comment_class(&name, "Object", schema.description.as_deref(), package_name);
447    }
448
449    let hint = match &tu.tagging {
450        TaggingStyle::Internal => {
451            format!("Discriminator: {} (internal).", tu.discriminator_field)
452        }
453        TaggingStyle::Adjacent { content_field } => format!(
454            "Discriminator: {} / content: {} (adjacent).",
455            tu.discriminator_field, content_field
456        ),
457        TaggingStyle::External => "Discriminator: variant key (external).".to_string(),
458    };
459    let doc = match &schema.description {
460        Some(desc) => format!("{desc}\n\n{hint}"),
461        None => hint,
462    };
463
464    emit_comment_class(&name, "Object", Some(&doc), package_name)
465}
466
467// ---------------------------------------------------------------------------
468// Helpers
469// ---------------------------------------------------------------------------
470
471fn emit_comment_class(
472    name: &str,
473    underlying_type: &str,
474    doc: Option<&str>,
475    package_name: &str,
476) -> Option<FileSpec> {
477    let mut file =
478        FileSpec::builder_with("model.java", Java::new()).header(package_header(package_name));
479
480    let mut tb = TypeSpec::builder(name, TypeKind::Struct).visibility(Visibility::Public);
481    if let Some(d) = doc {
482        tb = tb.doc(d);
483    }
484
485    // Single field wrapping the underlying type
486    tb = tb.add_field(
487        FieldSpec::builder("value", TypeName::primitive(underlying_type))
488            .visibility(Visibility::Private)
489            .build()
490            .expect("value field"),
491    );
492
493    // Constructor
494    let mut ctor = FunSpec::builder(name);
495    ctor = ctor.visibility(Visibility::Public);
496    ctor = ctor.add_param(
497        ParameterSpec::new(&format!("{underlying_type} value"), TypeName::primitive(""))
498            .expect("param"),
499    );
500    let body = sigil_quote!(Java {
501        this.value = value;
502    })
503    .expect("ctor body");
504    ctor = ctor.body(body);
505    tb = tb.add_method(ctor.build().expect("constructor"));
506
507    // Getter
508    tb = tb.add_method(build_java_getter("getValue", underlying_type, "value"));
509
510    file = file.add_type(tb.build().ok()?);
511    file.build().ok()
512}
513
514fn enum_variant_name(s: &str) -> String {
515    let upper: String = s
516        .chars()
517        .map(|c| {
518            if c.is_alphanumeric() {
519                c.to_ascii_uppercase()
520            } else {
521                '_'
522            }
523        })
524        .collect();
525    if upper.is_empty() {
526        "UNKNOWN".to_string()
527    } else if upper.chars().next().unwrap().is_ascii_digit() {
528        format!("N{upper}")
529    } else {
530        upper
531    }
532}