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