JOHNZON-300 base to support java 14 records
diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/JohnzonRecord.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/JohnzonRecord.java
new file mode 100644
index 0000000..d57a0b8
--- /dev/null
+++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/JohnzonRecord.java
@@ -0,0 +1,45 @@
+/*
+ * 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.apache.johnzon.mapper;
+
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Forces method named as properties to be used as getters (String foo() will match the attribute foo).
+ * Also enables a constructor with all properties even if not marked as @ConstructorProperties or equivalent.
+ * It simulates java >= 14 record style.
+ */
+@Target({ TYPE })
+@Retention(RUNTIME)
+public @interface JohnzonRecord {
+    /**
+     * When not using -parameters compiler argument, enables to customize parameter names.
+     * It is only real in @JohnzonRecord classes.
+     */
+    @Target(PARAMETER)
+    @Retention(RUNTIME)
+    @interface Name {
+        String value();
+    }
+}
diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingParserImpl.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingParserImpl.java
index 1ae08ff..0ec10bc 100644
--- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingParserImpl.java
+++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/MappingParserImpl.java
@@ -83,6 +83,7 @@
 import static javax.json.JsonValue.ValueType.NUMBER;
 import static javax.json.JsonValue.ValueType.STRING;
 import static javax.json.JsonValue.ValueType.TRUE;
+import static org.apache.johnzon.mapper.Mappings.getPrimitiveDefault;
 
 /**
  * This class is not concurrently usable as it contains state.
@@ -1002,16 +1003,19 @@
         final Object[] objects = new Object[length];
 
         for (int i = 0; i < length; i++) {
-
-            String paramName = mapping.factory.getParameterNames()[i];
+            final String paramName = mapping.factory.getParameterNames()[i];
+            final Type parameterType = mapping.factory.getParameterTypes()[i];
             objects[i] = toValue(null,
                     object.get(paramName),
                     mapping.factory.getParameterConverter()[i],
                     mapping.factory.getParameterItemConverter()[i],
-                    mapping.factory.getParameterTypes()[i],
+                    parameterType,
                     mapping.factory.getObjectConverter()[i],
                     isDeduplicateObjects ? new JsonPointerTracker(jsonPointer, paramName) : null,
                     mapping.clazz); //X TODO ObjectConverter in @JohnzonConverter with Constructors!
+            if (objects[i] == null) {
+                objects[i] = getPrimitiveDefault(parameterType);
+            }
         }
 
         return objects;
diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mappings.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mappings.java
index acf5d49..0f623a1 100644
--- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mappings.java
+++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/Mappings.java
@@ -359,6 +359,25 @@
         return false;
     }
 
+    public static Object getPrimitiveDefault(final Type type) {
+        if (type == long.class) {
+            return 0L;
+        } else if (type == int.class) {
+            return 0;
+        } else if (type == short.class) {
+            return (short) 0;
+        } else if (type == byte.class) {
+            return (byte) 0;
+        } else if (type == double.class) {
+            return 0.;
+        } else if (type == float.class) {
+            return 0f;
+        } else if (type == boolean.class) {
+            return false;
+        }
+        return null;
+    }
+
     public ClassMapping getClassMapping(final Type clazz) {
         return classes.get(clazz);
     }
diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/BaseAccessMode.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/BaseAccessMode.java
index 0eba849..818ddec 100644
--- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/BaseAccessMode.java
+++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/BaseAccessMode.java
@@ -20,7 +20,10 @@
 
 import static java.util.Arrays.asList;
 import static java.util.Collections.emptySet;
+import static java.util.Comparator.comparing;
+import static java.util.Optional.ofNullable;
 import static java.util.stream.Collectors.toSet;
+import static org.apache.johnzon.mapper.reflection.Records.isRecord;
 import static org.apache.johnzon.mapper.reflection.Converters.matches;
 
 import java.beans.ConstructorProperties;
@@ -34,11 +37,13 @@
 import java.util.Comparator;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.stream.Stream;
 
 import org.apache.johnzon.mapper.Adapter;
 import org.apache.johnzon.mapper.Converter;
 import org.apache.johnzon.mapper.JohnzonAny;
 import org.apache.johnzon.mapper.JohnzonConverter;
+import org.apache.johnzon.mapper.JohnzonRecord;
 import org.apache.johnzon.mapper.MapperConverter;
 import org.apache.johnzon.mapper.ObjectConverter;
 import org.apache.johnzon.mapper.internal.ConverterAdapter;
@@ -48,6 +53,7 @@
     private static final Type[] NO_PARAMS = new Type[0];
 
     private FieldFilteringStrategy fieldFilteringStrategy = new SingleEntryFieldFilteringStrategy();
+
     private final boolean acceptHiddenConstructor;
     private final boolean useConstructor;
 
@@ -105,25 +111,29 @@
     @Override
     public Factory findFactory(final Class<?> clazz) {
         Constructor<?> constructor = null;
-        for (final Constructor<?> c : clazz.getDeclaredConstructors()) {
-            if (c.getParameterTypes().length == 0) {
-                if (!Modifier.isPublic(c.getModifiers()) && acceptHiddenConstructor) {
-                    c.setAccessible(true);
-                }
-                constructor = c;
-                if (!useConstructor) {
+        if (isRecord(clazz) || Meta.getAnnotation(clazz, JohnzonRecord.class) != null) {
+            constructor = findRecordConstructor(clazz);
+        } else {
+            for (final Constructor<?> c : clazz.getDeclaredConstructors()) {
+                if (c.getParameterTypes().length == 0) {
+                    if (!Modifier.isPublic(c.getModifiers()) && acceptHiddenConstructor) {
+                        c.setAccessible(true);
+                    }
+                    constructor = c;
+                    if (!useConstructor) {
+                        break;
+                    }
+                } else if (c.getAnnotation(ConstructorProperties.class) != null) {
+                    constructor = c;
                     break;
                 }
-            } else if (c.getAnnotation(ConstructorProperties.class) != null) {
-                constructor = c;
-                break;
             }
-        }
-        if (constructor == null) {
-            try {
-                constructor = clazz.getConstructor();
-            } catch (final NoSuchMethodException e) {
-                return null; // readOnly class
+            if (constructor == null) {
+                try {
+                    constructor = clazz.getConstructor();
+                } catch (final NoSuchMethodException e) {
+                    return null; // readOnly class
+                }
             }
         }
 
@@ -137,8 +147,16 @@
             factoryParameterTypes = constructor.getGenericParameterTypes();
 
             constructorParameters = new String[constructor.getGenericParameterTypes().length];
-            final ConstructorProperties constructorProperties = constructor.getAnnotation(ConstructorProperties.class);
-            System.arraycopy(constructorProperties.value(), 0, constructorParameters, 0, constructorParameters.length);
+
+            final Constructor<?> fc = constructor;
+            final String[] constructorProperties = ofNullable(constructor.getAnnotation(ConstructorProperties.class))
+                    .map(ConstructorProperties::value)
+                    .orElseGet(() -> Stream.of(fc.getParameters())
+                            .map(p -> ofNullable(p.getAnnotation(JohnzonRecord.Name.class))
+                                            .map(JohnzonRecord.Name::value)
+                                            .orElseGet(p::getName))
+                            .toArray(String[]::new));
+            System.arraycopy(constructorProperties, 0, constructorParameters, 0, constructorParameters.length);
 
             constructorParameterConverters = new Adapter<?, ?>[constructor.getGenericParameterTypes().length];
             constructorItemParameterConverters = new Adapter<?, ?>[constructorParameterConverters.length];
@@ -220,6 +238,18 @@
         };
     }
 
+    private Constructor<?> findRecordConstructor(Class<?> clazz) {
+        return Stream.of(clazz.getConstructors())
+                .max(comparing(Constructor::getParameterCount))
+                .map(c -> {
+                    if (!c.isAccessible()) {
+                        c.setAccessible(true);
+                    }
+                    return c;
+                })
+                .orElse(null);
+    }
+
     @Override
     public Method findAnyGetter(final Class<?> clazz) {
         Method m = null;
diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/FieldAndMethodAccessMode.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/FieldAndMethodAccessMode.java
index 87f416d..9b04452 100644
--- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/FieldAndMethodAccessMode.java
+++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/FieldAndMethodAccessMode.java
@@ -43,11 +43,13 @@
                                     final boolean useGettersAsWriter, final boolean alwaysPreferMethodVisibility) {
         super(useConstructor, acceptHiddenConstructor);
         this.fields = new FieldAccessMode(useConstructor, acceptHiddenConstructor);
-        this.methods = new MethodAccessMode(useConstructor, acceptHiddenConstructor, useGettersAsWriter);
+        this.methods = new MethodAccessMode(
+                useConstructor, acceptHiddenConstructor, useGettersAsWriter);
         this.alwaysPreferMethodVisibility = alwaysPreferMethodVisibility;
     }
 
     // backward compatibility, don't delete since it can be used from user code in jsonb delegate access mode property
+    @Deprecated
     public FieldAndMethodAccessMode(final boolean useConstructor, final boolean acceptHiddenConstructor,
                                     final boolean useGettersAsWriter) {
         this(useConstructor, acceptHiddenConstructor, useGettersAsWriter, true);
diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/MethodAccessMode.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/MethodAccessMode.java
index c43ef3b..67fab32 100644
--- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/MethodAccessMode.java
+++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/MethodAccessMode.java
@@ -18,6 +18,9 @@
  */
 package org.apache.johnzon.mapper.access;
 
+import static java.util.stream.Collectors.toMap;
+import static org.apache.johnzon.mapper.reflection.Records.isRecord;
+
 import java.beans.IntrospectionException;
 import java.beans.Introspector;
 import java.beans.PropertyDescriptor;
@@ -27,10 +30,12 @@
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.stream.Stream;
 
 import org.apache.johnzon.mapper.Adapter;
 import org.apache.johnzon.mapper.JohnzonAny;
 import org.apache.johnzon.mapper.JohnzonProperty;
+import org.apache.johnzon.mapper.JohnzonRecord;
 import org.apache.johnzon.mapper.MapperException;
 import org.apache.johnzon.mapper.ObjectConverter;
 
@@ -45,23 +50,30 @@
     @Override
     public Map<String, Reader> doFindReaders(final Class<?> clazz) {
         final Map<String, Reader> readers = new HashMap<String, Reader>();
-        final PropertyDescriptor[] propertyDescriptors = getPropertyDescriptors(clazz);
-        for (final PropertyDescriptor descriptor : propertyDescriptors) {
-            final Method readMethod = descriptor.getReadMethod();
-            final String name = descriptor.getName();
-            if (readMethod != null && readMethod.getDeclaringClass() != Object.class) {
-                if (isIgnored(name) || Meta.getAnnotation(readMethod, JohnzonAny.class) != null) {
-                    continue;
-                }
-                readers.put(extractKey(name, readMethod, null), new MethodReader(readMethod, readMethod.getGenericReturnType()));
-            } else if (readMethod == null && descriptor.getWriteMethod() != null && // isXXX, not supported by javabeans
-                    (descriptor.getPropertyType() == Boolean.class || descriptor.getPropertyType() == boolean.class)) {
-                try {
-                    final Method method = clazz.getMethod(
-                            "is" + Character.toUpperCase(name.charAt(0)) + (name.length() > 1 ? name.substring(1) : ""));
-                    readers.put(extractKey(name, method, null), new MethodReader(method, method.getGenericReturnType()));
-                } catch (final NoSuchMethodException e) {
-                    // no-op
+        if (isRecord(clazz) || Meta.getAnnotation(clazz, JohnzonRecord.class) != null) {
+            readers.putAll(Stream.of(clazz.getMethods())
+                .filter(it -> it.getDeclaringClass() != Object.class && it.getParameterCount() == 0)
+                .filter(it -> !"toString".equals(it.getName()) && !"hashCode".equals(it.getName()))
+                .collect(toMap(Method::getName, it -> new MethodReader(it, it.getGenericReturnType()))));
+        } else {
+            final PropertyDescriptor[] propertyDescriptors = getPropertyDescriptors(clazz);
+            for (final PropertyDescriptor descriptor : propertyDescriptors) {
+                final Method readMethod = descriptor.getReadMethod();
+                final String name = descriptor.getName();
+                if (readMethod != null && readMethod.getDeclaringClass() != Object.class) {
+                    if (isIgnored(name) || Meta.getAnnotation(readMethod, JohnzonAny.class) != null) {
+                        continue;
+                    }
+                    readers.put(extractKey(name, readMethod, null), new MethodReader(readMethod, readMethod.getGenericReturnType()));
+                } else if (readMethod == null && descriptor.getWriteMethod() != null && // isXXX, not supported by javabeans
+                        (descriptor.getPropertyType() == Boolean.class || descriptor.getPropertyType() == boolean.class)) {
+                    try {
+                        final Method method = clazz.getMethod(
+                                "is" + Character.toUpperCase(name.charAt(0)) + (name.length() > 1 ? name.substring(1) : ""));
+                        readers.put(extractKey(name, method, null), new MethodReader(method, method.getGenericReturnType()));
+                    } catch (final NoSuchMethodException e) {
+                        // no-op
+                    }
                 }
             }
         }
diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/reflection/Records.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/reflection/Records.java
new file mode 100644
index 0000000..7d8d415
--- /dev/null
+++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/reflection/Records.java
@@ -0,0 +1,48 @@
+/*
+ * 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.apache.johnzon.mapper.reflection;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public final class Records {
+    private static final Method IS_RECORD;
+
+    static {
+        Method isRecord = null;
+        try {
+            isRecord = Class.class.getMethod("isRecord");
+        } catch (final NoSuchMethodException e) {
+            // no-op
+        }
+        IS_RECORD = isRecord;
+    }
+
+    private Records() {
+        // no-op
+    }
+
+    public static boolean isRecord(final Class<?> clazz) {
+        try {
+            return IS_RECORD != null && Boolean.class.cast(IS_RECORD.invoke(clazz));
+        } catch (final InvocationTargetException | IllegalAccessException e) {
+            return false;
+        }
+    }
+}
diff --git a/johnzon-mapper/src/test/java/org/apache/johnzon/mapper/RecordTest.java b/johnzon-mapper/src/test/java/org/apache/johnzon/mapper/RecordTest.java
new file mode 100644
index 0000000..32a8738
--- /dev/null
+++ b/johnzon-mapper/src/test/java/org/apache/johnzon/mapper/RecordTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.apache.johnzon.mapper;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.Objects;
+
+import org.junit.Test;
+
+public class RecordTest {
+    @Test
+    public void roundTrip() {
+        final Record ref = new Record(119, "Santa");
+        try (final Mapper mapper = new MapperBuilder().setAttributeOrder(String.CASE_INSENSITIVE_ORDER).build()) {
+            final String expectedJson = "{\"age\":119,\"name\":\"Santa\"}";
+            assertEquals(expectedJson, mapper.writeObjectAsString(ref));
+            assertEquals(ref, mapper.readObject(expectedJson, Record.class));
+        }
+    }
+
+    @JohnzonRecord
+    public static class Record {
+        private final int age;
+        private final String name;
+
+        public Record() { // simulate custom constructor
+            this.age = -1;
+            this.name = "failed";
+        }
+
+        public Record(final int age) { // simulate custom constructor
+            this.age = age;
+            this.name = "failed";
+        }
+
+        public Record(@JohnzonRecord.Name("age") final int age,
+                      @JohnzonRecord.Name("name") final String name) {
+            this.age = age;
+            this.name = name;
+        }
+
+        @Override
+        public String toString() {
+            return "Record{" +
+                    "age=" + age +
+                    ", name='" + name + '\'' +
+                    '}';
+        }
+
+        @Override
+        public boolean equals(final Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+            final Record record = Record.class.cast(o);
+            return age == record.age && Objects.equals(name, record.name);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(age, name);
+        }
+    }
+}