[JOHNZON-350] record support in example to model mojo
diff --git a/johnzon-maven-plugin/src/main/java/org/apache/johnzon/maven/plugin/ExampleToModelMojo.java b/johnzon-maven-plugin/src/main/java/org/apache/johnzon/maven/plugin/ExampleToModelMojo.java
index bbdb590..2ce7ee7 100644
--- a/johnzon-maven-plugin/src/main/java/org/apache/johnzon/maven/plugin/ExampleToModelMojo.java
+++ b/johnzon-maven-plugin/src/main/java/org/apache/johnzon/maven/plugin/ExampleToModelMojo.java
@@ -20,7 +20,6 @@
 
 import org.apache.maven.plugin.AbstractMojo;
 import org.apache.maven.plugin.MojoExecutionException;
-import org.apache.maven.plugin.MojoFailureException;
 import org.apache.maven.plugins.annotations.Mojo;
 import org.apache.maven.plugins.annotations.Parameter;
 import org.apache.maven.project.MavenProject;
@@ -35,7 +34,6 @@
 import java.io.File;
 import java.io.FileReader;
 import java.io.FileWriter;
-import java.io.FilenameFilter;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.io.Writer;
@@ -53,7 +51,7 @@
 @Mojo(name = "example-to-model", defaultPhase = GENERATE_SOURCES)
 public class ExampleToModelMojo extends AbstractMojo {
     // not strictly forbidden but kind of file to java convertion
-    private static final List<Character> FORBIDDEN_JAVA_NAMES = asList('-', '_', '.');
+    private static final List<Character> FORBIDDEN_JAVA_NAMES = asList('-', '_', '.', '{', '}');
 
     @Parameter(property = "johnzon.source", defaultValue = "${project.basedir}/src/main/johnzon")
     protected File source;
@@ -73,18 +71,22 @@
     @Parameter(property = "johnzon.attach", defaultValue = "true")
     protected boolean attach;
 
+    @Parameter(property = "johnzon.useRecord", defaultValue = "false")
+    protected boolean useRecord;
+
+    @Parameter(property = "johnzon.useJsonb", defaultValue = "false")
+    protected boolean useJsonb;
+
+    @Parameter(property = "johnzon.ignoreNull", defaultValue = "false")
+    protected boolean ignoreNull;
+
     @Override
-    public void execute() throws MojoExecutionException, MojoFailureException {
-        final JsonReaderFactory readerFactory = Json.createReaderFactory(Collections.<String, Object>emptyMap());
+    public void execute() throws MojoExecutionException {
+        final JsonReaderFactory readerFactory = Json.createReaderFactory(Collections.emptyMap());
         if (source.isFile()) {
             generateFile(readerFactory, source);
         } else {
-            final File[] children = source.listFiles(new FilenameFilter() {
-                @Override
-                public boolean accept(final File dir, final String name) {
-                    return name.endsWith(".json");
-                }
-            });
+            final File[] children = source.listFiles((dir, name) -> name.endsWith(".json"));
             if (children == null || children.length == 0) {
                 throw new MojoExecutionException("No json file found in " + source);
             }
@@ -99,16 +101,14 @@
 
     // TODO: unicity of field name, better nested array/object handling
     private void generate(final JsonReaderFactory readerFactory, final File source, final Writer writer, final String javaName) throws MojoExecutionException {
-        JsonReader reader = null;
-        try {
-            reader = readerFactory.createReader(new FileReader(source));
+        try (final JsonReader reader = readerFactory.createReader(new FileReader(source))) {
             final JsonStructure structure = reader.read();
             if (JsonArray.class.isInstance(structure) || !JsonObject.class.isInstance(structure)) { // quite redundant for now but to avoid surprises in future
                 throw new MojoExecutionException("This plugin doesn't support array generation, generate the model (generic) and handle arrays in your code");
             }
 
             final JsonObject object = JsonObject.class.cast(structure);
-            final Collection<String> imports = new TreeSet<String>();
+            final Collection<String> imports = new TreeSet<>();
 
             // while we browse the example tree just store imports as well, avoids a 2 passes processing duplicating imports logic
             final StringWriter memBuffer = new StringWriter();
@@ -128,53 +128,64 @@
                 writer.write('\n');
             }
 
-            writer.write("public class " + javaName + " {\n");
-            writer.write(memBuffer.toString());
+            if (useRecord) {
+                writer.write("public record " + javaName + "(\n");
+                writer.write(memBuffer.toString());
+            } else {
+                writer.write("public class " + javaName + " {\n");
+                writer.write(memBuffer.toString());
+            }
             writer.write("}\n");
         } catch (final IOException e) {
             throw new MojoExecutionException(e.getMessage(), e);
-        } finally {
-            if (reader != null) {
-                reader.close();
-            }
         }
     }
 
-    private void generateFieldsAndMethods(final Writer writer, final JsonObject object, final String prefix,
+    private void generateFieldsAndMethods(final StringWriter writer, final JsonObject object, final String prefix,
                                           final Collection<String> imports) throws IOException {
-        final Map<String, JsonObject> nestedTypes = new TreeMap<String, JsonObject>();
+        final Map<String, JsonObject> nestedTypes = new TreeMap<>();
         {
             final Iterator<Map.Entry<String, JsonValue>> iterator = object.entrySet().iterator();
             while (iterator.hasNext()) {
                 final Map.Entry<String, JsonValue> entry = iterator.next();
                 final String key = entry.getKey();
                 final String fieldName = toJavaFieldName(key);
+                final boolean hasNext = iterator.hasNext();
                 switch (entry.getValue().getValueType()) {
                     case ARRAY:
                         imports.add("java.util.List");
-                        handleArray(writer, prefix, nestedTypes, entry.getValue(), key, fieldName, 1, imports);
+                        handleArray(writer, prefix, nestedTypes, entry.getValue(), key, fieldName, 1, imports, !hasNext);
                         break;
                     case OBJECT:
                         final String type = toJavaName(fieldName);
                         nestedTypes.put(type, JsonObject.class.cast(entry.getValue()));
-                        fieldGetSetMethods(writer, key, fieldName, type, prefix, 0, imports);
+                        fieldGetSetMethods(writer, key, fieldName, type, prefix, 0, imports, !hasNext);
                         break;
                     case TRUE:
                     case FALSE:
-                        fieldGetSetMethods(writer, key, fieldName, "Boolean", prefix, 0, imports);
+                        fieldGetSetMethods(writer, key, fieldName, "Boolean", prefix, 0, imports, !hasNext);
                         break;
                     case NUMBER:
-                        fieldGetSetMethods(writer, key, fieldName, "Double", prefix, 0, imports);
+                        fieldGetSetMethods(writer, key, fieldName, "Double", prefix, 0, imports, !hasNext);
                         break;
                     case STRING:
-                        fieldGetSetMethods(writer, key, fieldName, "String", prefix, 0, imports);
+                        fieldGetSetMethods(writer, key, fieldName, "String", prefix, 0, imports, !hasNext);
                         break;
                     case NULL:
                     default:
-                        throw new UnsupportedOperationException("Unsupported " + entry.getValue() + ".");
+                        if (ignoreNull) {
+                            if (useRecord && writer.getBuffer().length() > 0) {
+                                writer.getBuffer().setLength(writer.getBuffer().length() - 2);
+                                writer.write("\n");
+                            }
+                        } else {
+                            throw new UnsupportedOperationException("Unsupported " + entry.getValue() + ".");
+                        }
                 }
-                if (iterator.hasNext()) {
+                if (hasNext) {
                     writer.write("\n");
+                } else if (useRecord) {
+                    writer.write(") {\n");
                 }
             }
         }
@@ -186,7 +197,11 @@
         final Iterator<Map.Entry<String, JsonObject>> entries = nestedTypes.entrySet().iterator();
         while (entries.hasNext()) {
             final Map.Entry<String, JsonObject> entry = entries.next();
-            writer.write(prefix + "public static class " + entry.getKey() + " {\n");
+            if (useRecord) {
+                writer.write(prefix + "public static record " + entry.getKey() + "(\n");
+            } else {
+                writer.write(prefix + "public static class " + entry.getKey() + " {\n");
+            }
             generateFieldsAndMethods(writer, entry.getValue(), "    " + prefix, imports);
             writer.write(prefix + "}\n");
             if (entries.hasNext()) {
@@ -200,7 +215,8 @@
                              final JsonValue value,
                              final String jsonField, final String fieldName,
                              final int arrayLevel,
-                             final Collection<String> imports) throws IOException {
+                             final Collection<String> imports,
+                             final boolean last) throws IOException {
         final JsonArray array = JsonArray.class.cast(value);
         if (array.size() > 0) { // keep it simple for now - 1 level, we can have an awesome recursive algo later if needed
             final JsonValue jsonValue = array.get(0);
@@ -208,20 +224,20 @@
                 case OBJECT:
                     final String javaName = toJavaName(fieldName);
                     nestedTypes.put(javaName, JsonObject.class.cast(jsonValue));
-                    fieldGetSetMethods(writer, jsonField, fieldName, javaName, prefix, arrayLevel, imports);
+                    fieldGetSetMethods(writer, jsonField, fieldName, javaName, prefix, arrayLevel, imports, last);
                     break;
                 case TRUE:
                 case FALSE:
-                    fieldGetSetMethods(writer, jsonField, fieldName, "Boolean", prefix, arrayLevel, imports);
+                    fieldGetSetMethods(writer, jsonField, fieldName, "Boolean", prefix, arrayLevel, imports, last);
                     break;
                 case NUMBER:
-                    fieldGetSetMethods(writer, jsonField, fieldName, "Double", prefix, arrayLevel, imports);
+                    fieldGetSetMethods(writer, jsonField, fieldName, "Double", prefix, arrayLevel, imports, last);
                     break;
                 case STRING:
-                    fieldGetSetMethods(writer, jsonField, fieldName, "String", prefix, arrayLevel, imports);
+                    fieldGetSetMethods(writer, jsonField, fieldName, "String", prefix, arrayLevel, imports, last);
                     break;
                 case ARRAY:
-                    handleArray(writer, prefix, nestedTypes, jsonValue, jsonField, fieldName, arrayLevel + 1, imports);
+                    handleArray(writer, prefix, nestedTypes, jsonValue, jsonField, fieldName, arrayLevel + 1, imports, last);
                     break;
                 case NULL:
                 default:
@@ -235,23 +251,42 @@
     private void fieldGetSetMethods(final Writer writer,
                                     final String jsonField, final String field,
                                     final String type, final String prefix, final int arrayLevel,
-                                    final Collection<String> imports) throws IOException {
+                                    final Collection<String> imports, final boolean last) throws IOException {
         final String actualType = buildArrayType(arrayLevel, type);
         final String actualField = buildValidFieldName(jsonField);
         final String methodName = capitalize(actualField);
 
-        if (!jsonField.equals(field)) { // TODO: add it to imports in eager visitor
-            imports.add("org.apache.johnzon.mapper.JohnzonProperty");
-            writer.append(prefix).append("@JohnzonProperty(\"").append(jsonField).append("\")\n");
+        if (!jsonField.equals(field)) {
+            if (useJsonb) {
+                imports.add("javax.json.bind.annotation.JsonbProperty");
+                writer.append(prefix).append("@JsonbProperty(\"").append(jsonField).append("\")");
+            } else {
+                imports.add("org.apache.johnzon.mapper.JohnzonProperty");
+                writer.append(prefix).append("@JohnzonProperty(\"").append(jsonField).append("\")");
+            }
+            if (useRecord) {
+                writer.append(" ");
+            } else {
+                writer.append("\n").append(prefix);
+            }
+        } else {
+            writer.append(prefix);
         }
-
-        writer.append(prefix).append("private ").append(actualType).append(" ").append(actualField).append(";\n");
-        writer.append(prefix).append("public ").append(actualType).append(" get").append(methodName).append("() {\n");
-        writer.append(prefix).append("    return ").append(actualField).append(";\n");
-        writer.append(prefix).append("}\n");
-        writer.append(prefix).append("public void set").append(methodName).append("(final ").append(actualType).append(" newValue) {\n");
-        writer.append(prefix).append("    this.").append(actualField).append(" = newValue;\n");
-        writer.append(prefix).append("}\n");
+        if (!useRecord) {
+            writer.append("private ");
+        }
+        writer.append(actualType).append(" ").append(actualField);
+        if (!useRecord) {
+            writer.append(";\n");
+            writer.append(prefix).append("public ").append(actualType).append(" get").append(methodName).append("() {\n");
+            writer.append(prefix).append("    return ").append(actualField).append(";\n");
+            writer.append(prefix).append("}\n");
+            writer.append(prefix).append("public void set").append(methodName).append("(final ").append(actualType).append(" newValue) {\n");
+            writer.append(prefix).append("    this.").append(actualField).append(" = newValue;\n");
+            writer.append(prefix).append("}\n");
+        } else if (!last) {
+            writer.append(",");
+        }
     }
 
     private String capitalize(final String str) {
@@ -296,25 +331,16 @@
         final File outputFile = new File(target, jsonToClass.replace('.', '/') + ".java");
 
         outputFile.getParentFile().mkdirs();
-        FileWriter writer = null;
-        try {
-            writer = new FileWriter(outputFile);
+        try (final FileWriter writer = new FileWriter(outputFile)) {
             generate(readerFactory, source, writer, javaName);
         } catch (IOException e) {
             throw new MojoExecutionException(e.getMessage(), e);
-        } finally {
-            try {
-                if (writer != null) {
-                    writer.close();
-                }
-            } catch (final IOException e) {
-                // no-op
-            }
         }
+        // no-op
     }
 
     private String buildValidFieldName(final String jsonField) {
-        String val = jsonField;
+        String val = toJavaFieldName(jsonField);
         if (Character.isDigit(jsonField.charAt(0))) {
             val = "_" + jsonField;
         }
diff --git a/johnzon-maven-plugin/src/test/java/org/apache/johnzon/maven/plugin/ExampleToModelMojoTest.java b/johnzon-maven-plugin/src/test/java/org/apache/johnzon/maven/plugin/ExampleToModelMojoTest.java
index d0e89e1..5a32199 100644
--- a/johnzon-maven-plugin/src/test/java/org/apache/johnzon/maven/plugin/ExampleToModelMojoTest.java
+++ b/johnzon-maven-plugin/src/test/java/org/apache/johnzon/maven/plugin/ExampleToModelMojoTest.java
@@ -114,4 +114,89 @@
             new String(IOUtil.toByteArray(Thread.currentThread().getContextClassLoader().getResourceAsStream("SomeValue.java"))),
             new String(IOUtil.toByteArray(new FileReader(output))).replace(File.separatorChar, '/'));
     }
+
+    @Test
+    public void generateJsonbRecord() throws MojoFailureException, MojoExecutionException, IOException {
+        final File sourceFolder = new File("target/ExampleToModelMojoTest/generateJsonbRecord_source/");
+        final File targetFolder = new File("target/ExampleToModelMojoTest/generateJsonbRecord_target/");
+        final ExampleToModelMojo mojo = new ExampleToModelMojo() {{
+            source = sourceFolder;
+            target = targetFolder;
+            packageBase = "org.test.apache.johnzon.mojo";
+            useRecord = true;
+            useJsonb = true;
+            header =
+                "/*\n" +
+                " * Licensed to the Apache Software Foundation (ASF) under one\n" +
+                " * or more contributor license agreements. See the NOTICE file\n" +
+                " * distributed with this work for additional information\n" +
+                " * regarding copyright ownership. The ASF licenses this file\n" +
+                " * to you under the Apache License, Version 2.0 (the\n" +
+                " * \"License\"); you may not use this file except in compliance\n" +
+                " * with the License. You may obtain a copy of the License at\n" +
+                " *\n" +
+                " * http://www.apache.org/licenses/LICENSE-2.0\n" +
+                " *\n" +
+                " * Unless required by applicable law or agreed to in writing,\n" +
+                " * software distributed under the License is distributed on an\n" +
+                " * \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n" +
+                " * KIND, either express or implied. See the License for the\n" +
+                " * specific language governing permissions and limitations\n" +
+                " * under the License.\n" +
+                " */";
+        }};
+
+        sourceFolder.mkdirs();
+        final FileWriter writer = new FileWriter(new File(sourceFolder, "some-value.json"));
+        writer.write( // using openjmh as sample data
+                "    {\n" +
+                        "        \"benchmark\" : \"com.sample.Perf.method\",\n" +
+                        "        \"mode\" : \"sample\",\n" +
+                        "        \"threads\" : 32,\n" +
+                        "        \"forks\" : 1,\n" +
+                        "        \"warmupIterations\" : 2,\n" +
+                        "        \"warmupTime\" : \"1 s\",\n" +
+                        "        \"measurementIterations\" : 3,\n" +
+                        "        \"measurementTime\" : \"1 s\",\n" +
+                        "        \"primaryMetric\" : {\n" +
+                        "            \"score\" : 6.951927808,\n" +
+                        "            \"scoreError\" : 0.7251433665600178,\n" +
+                        "            \"scoreConfidence\" : [\n" +
+                        "                6.226784441439982,\n" +
+                        "                7.677071174560018\n" +
+                        "            ],\n" +
+                        "            \"scorePercentiles\" : {\n" +
+                        "                \"0.0\" : 3.9468400640000003,\n" +
+                        "                \"50.0\" : 6.593445888000001,\n" +
+                        "                \"90.0\" : 9.925400985600001,\n" +
+                        "                \"95.0\" : 11.301132697600002,\n" +
+                        "                \"99.0\" : 11.844714496,\n" +
+                        "                \"99.9\" : 11.844714496,\n" +
+                        "                \"99.99\" : 11.844714496,\n" +
+                        "                \"99.999\" : 11.844714496,\n" +
+                        "                \"99.9999\" : 11.844714496,\n" +
+                        "                \"100.0\" : 11.844714496\n" +
+                        "            },\n" +
+                        "            \"scoreUnit\" : \"s/op\",\n" +
+                        "            \"rawData\" : [\n" +
+                        "                [\n" +
+                        "                    6.687817728,\n" +
+                        "                    7.169245184,\n" +
+                        "                    6.998720512\n" +
+                        "                ]\n" +
+                        "            ]\n" +
+                        "        },\n" +
+                        "        \"secondaryMetrics\" : {\n" +
+                        "        }\n" +
+                        "    }\n");
+        writer.close();
+
+        mojo.execute();
+
+        final File output = new File(targetFolder, "org/test/apache/johnzon/mojo/SomeValue.java");
+        assertTrue(output.isFile());
+        assertEquals(
+            new String(IOUtil.toByteArray(Thread.currentThread().getContextClassLoader().getResourceAsStream("SomeValue.record.java"))),
+            new String(IOUtil.toByteArray(new FileReader(output))).replace(File.separatorChar, '/'));
+    }
 }
diff --git a/johnzon-maven-plugin/src/test/resources/SomeValue.record.java b/johnzon-maven-plugin/src/test/resources/SomeValue.record.java
new file mode 100644
index 0000000..120d2bd
--- /dev/null
+++ b/johnzon-maven-plugin/src/test/resources/SomeValue.record.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.test.apache.johnzon.mojo;
+
+import java.util.List;
+import javax.json.bind.annotation.JsonbProperty;
+
+public record SomeValue(
+    String benchmark,
+    String mode,
+    Double threads,
+    Double forks,
+    Double warmupIterations,
+    String warmupTime,
+    Double measurementIterations,
+    String measurementTime,
+    PrimaryMetric primaryMetric,
+    SecondaryMetrics secondaryMetrics) {
+
+    public static record PrimaryMetric(
+        Double score,
+        Double scoreError,
+        List<Double> scoreConfidence,
+        ScorePercentiles scorePercentiles,
+        String scoreUnit,
+        List<List<Double>> rawData) {
+
+        public static record ScorePercentiles(
+            @JsonbProperty("0.0") Double _00,
+            @JsonbProperty("50.0") Double _500,
+            @JsonbProperty("90.0") Double _900,
+            @JsonbProperty("95.0") Double _950,
+            @JsonbProperty("99.0") Double _990,
+            @JsonbProperty("99.9") Double _999,
+            @JsonbProperty("99.99") Double _9999,
+            @JsonbProperty("99.999") Double _99999,
+            @JsonbProperty("99.9999") Double _999999,
+            @JsonbProperty("100.0") Double _1000) {
+        }
+    }
+
+    public static record SecondaryMetrics(
+    }
+}