From 3bab47b78fc6023793134a10c770b9220aeca7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Undheim?= Date: Thu, 22 Sep 2022 12:52:19 +0200 Subject: [PATCH 1/2] Support Java Records when present in JVM. Fixes google/gson#1794 Added a TypeAdapterFactory that deals specifcally with Java 17 Records. It uses reflection to detect if the JVM supports records, and from there accesses the RecordComponent array to serialize and deserialize objects. This new TypeAdapterFactory will only be added when Records are actually supported on the JVM. --- gson/src/main/java/com/google/gson/Gson.java | 9 + .../bind/RecordTypeAdapterFactory.java | 480 ++++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java diff --git a/gson/src/main/java/com/google/gson/Gson.java b/gson/src/main/java/com/google/gson/Gson.java index c3262a6fe0..a18e3b79a3 100644 --- a/gson/src/main/java/com/google/gson/Gson.java +++ b/gson/src/main/java/com/google/gson/Gson.java @@ -31,6 +31,7 @@ import com.google.gson.internal.bind.MapTypeAdapterFactory; import com.google.gson.internal.bind.NumberTypeAdapter; import com.google.gson.internal.bind.ObjectTypeAdapter; +import com.google.gson.internal.bind.RecordTypeAdapterFactory; import com.google.gson.internal.bind.ReflectiveTypeAdapterFactory; import com.google.gson.internal.bind.TypeAdapters; import com.google.gson.internal.sql.SqlTypesSupport; @@ -332,9 +333,17 @@ public Gson() { this.jsonAdapterFactory = new JsonAdapterAnnotationTypeAdapterFactory(constructorConstructor); factories.add(jsonAdapterFactory); factories.add(TypeAdapters.ENUM_FACTORY); + + // If we are on Java 17, we want to include the RecordTypeAdapterFactory before the ReflectiveTypeAdapterFactory, + // so that we intercept all Record types first. + if (RecordTypeAdapterFactory.SUPPORTS_RECORD_TYPES) { + factories.add(new RecordTypeAdapterFactory(this.excluder, constructorConstructor, jsonAdapterFactory)); + } + factories.add(new ReflectiveTypeAdapterFactory( constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory, reflectionFilters)); + this.factories = Collections.unmodifiableList(factories); } diff --git a/gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java new file mode 100644 index 0000000000..e808398ed1 --- /dev/null +++ b/gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java @@ -0,0 +1,480 @@ +package com.google.gson.internal.bind; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.google.gson.internal.ConstructorConstructor; +import com.google.gson.internal.Excluder; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * {@link RecordFieldFactory} to create adapters for Java 17 records. + * + * This class makes the following assumptions about records: + * - every record field is private final + * - for every field there exists a corresponding method with the same name + * - the order of the fields and the constructor arguments match exactly + * This means that this adapter will also be used for classes that are not records, but fulfill these + * requirements. There should be no issues here though, as we will just use constructors to create instances + * rather than the Gson Invoke magic. + */ +@SuppressWarnings({"unchecked", "rawtypes"}) +// unchecked, rawtypes: Unavoidable, as we are dealing with inferred generics. +public class RecordTypeAdapterFactory implements TypeAdapterFactory { + + public static boolean SUPPORTS_RECORD_TYPES; + + // Find the isRecord method from the Class - using we do not need to handle NoSuchMethodException. + private static final RecordHelper RECORD_HELPER; + + static { + Method isRecord; + try { + isRecord = Class.class.getDeclaredMethod("isRecord"); + } + catch (NoSuchMethodException e) { + isRecord = null; + } + RECORD_HELPER = (isRecord == null) ? null : new RecordHelper(isRecord); + SUPPORTS_RECORD_TYPES = RECORD_HELPER != null; + } + + private final Excluder excluder; + private final ConstructorConstructor constructorConstructor; + private final JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory; + + public RecordTypeAdapterFactory(Excluder excluder, + ConstructorConstructor constructorConstructor, + JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory) { + this.excluder = excluder; + this.constructorConstructor = constructorConstructor; + this.jsonAdapterFactory = jsonAdapterFactory; + } + + /** + * Internal helper class to manage access to Class.isRecord, and RecordComponent instances. Since this compiles + * on Java 8, we need to use reflection to handle RecordComponent instances. + */ + private static final class RecordHelper { + private final Method isRecord; + private final Method getRecordComponents; + private final Class recordComponentType; + // RecordComponent methods + private final Method getName; + private final Method getType; + private final Method getGenericType; + // getAccessor returns a method, that in turn can be invoked on the Record to read out its value + private final Method getAccessor; + private final Method getAnnotation; + + /** + * Create a new RecordHelper to handle reflection for RecordComponent on Java 17. + */ + private RecordHelper(Method isRecord) { + this.isRecord = Objects.requireNonNull(isRecord, "isRecord must not be null"); + try { + getRecordComponents = Class.class.getDeclaredMethod("getRecordComponents"); + recordComponentType = getRecordComponents.getReturnType().getComponentType(); + getName = recordComponentType.getDeclaredMethod("getName"); + getType = recordComponentType.getDeclaredMethod("getType"); + getGenericType = recordComponentType.getDeclaredMethod("getGenericType"); + getAccessor = recordComponentType.getDeclaredMethod("getAccessor"); + getAnnotation = recordComponentType.getDeclaredMethod("getAnnotation", Class.class); + + } + catch (NoSuchMethodException e) { + throw new TypeAdapterReflectionException( + "Expected to find method getRecordComponents when the isRecord method is present in Class", e); + } + } + + boolean isRecord(TypeToken type) { + try { + return isRecord != null && Boolean.TRUE.equals(isRecord.invoke(type)); + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException("Unable to create TypeAdapter for [" + type + "]", e); + } + } + + Object[] getRecordComponents(TypeToken type) { + try { + return (Object[]) getRecordComponents.invoke(type.getRawType()); + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException("Unable to invoke getRecordComponents", e); + } + } + + String getName(Object recordComponent) { + try { + return (String) getName.invoke(recordComponent); + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException("Failed to invoke method [getName] on RecordComponent", e); + } + } + Class getType(Object recordComponent) { + try { + return (Class) getType.invoke(recordComponent); + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Failed to invoke method [getGenericType] on RecordComponent", e); + } + } + Type getGenericType(Object recordComponent) { + try { + return (Type) getGenericType.invoke(recordComponent); + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Failed to invoke method [getGenericType] on RecordComponent", e); + } + } + // getAccessor returns a method, that in turn can be invoked on the Record to read out its value + Method getAccessor(Object recordComponent) { + try { + return (Method) getAccessor.invoke(recordComponent); + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException("Failed to invoke method [getAccessor] on RecordComponent", e); + } + } + A getAnnotation(Object recordComponent, Class annotation) { + try { + return (A) getAnnotation.invoke(recordComponent, annotation); + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Failed to invoke method [getAnnotation] on RecordComponent", e); + } + } + + + public Field getField(Object recordComponent, TypeToken type) { + // There is nothing in the RecordComponent class to access the underlying field, so we do this based + // on the component name. + String fieldName = getName(recordComponent); + try { + return type.getRawType().getDeclaredField(fieldName); + } + catch (NoSuchFieldException e) { + throw new TypeAdapterReflectionException( + "Expected to find field [" + fieldName + "] on recordComponent [" + recordComponent + "]" + + " on type [" + type + "]. Somehow there is a discrepancy between the record components" + + " and the fields on the Class.", e); + } + } + + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + // ?: Is this not a record? + if (!RECORD_HELPER.isRecord(type)) { + // Yes -> Not supported, we return null as per the TypeAdapterFactory contract + return null; + } + + try { + // To construct record fields, we actually need to do some reflection on the Gson instance. This + // logic is contained in the RecordFieldFactory. + RecordFieldFactory recordFieldFactory = + new RecordFieldFactory(gson, excluder, jsonAdapterFactory, constructorConstructor); + + Object[] recordComponents = RECORD_HELPER.getRecordComponents(type); + Class[] componentTypes = new Class[recordComponents.length]; + for (int i = 0; i < recordComponents.length; i++) { + componentTypes[i] = RECORD_HELPER.getType(recordComponents[i]); + } + + // Find the canonical constructor on the Record that corresponds to the record components. + // There is no method in the Java API to do the equivialent, instead we rely on the constructor and + // recordComponent order to be the same. This construct matches the StackOverflow answer here: + // https://stackoverflow.com/a/67127067 + Constructor recordConstructor = (Constructor) type.getRawType().getConstructor(componentTypes); + + RecordField[] recordFields = new RecordField[recordComponents.length]; + for (int i = 0; i < recordComponents.length; i++) { + // We need the field for the Gson Excluder, so that we can correctly determine if a field should + // be included or not. + Field field = RECORD_HELPER.getField(recordComponents[i], type); + recordFields[i] = recordFieldFactory.createRecordField(field, recordComponents[i]); + } + return new RecordTypeAdapterImpl<>(recordFields, recordConstructor); + } + catch (NoSuchMethodException e) { + // We hit this either because we do not find the expected constructor, or we do not find a method + // name that matches the field name. In either of these classes, this is not a record, and we + // do not support serialization of it. The contract for TypeAdapterFactory is to return null for + // unsupported types. Gson will then attempt other means of creating a TypeAdapter. If all fails, an + // exception will be created where Gson was invoked to serialize/deserialize a type. + return null; + } + } + + /** + * Factory to construct our own {@link RecordField} instances. This requires some reflection into the internal + * of Gson, which has logic to handle the {@link JsonAdapter} annotation. We also need to reflect into Gson + * to fetch the {@link Excluder}, that handles the {@link com.google.gson.annotations.Expose}, + * {@link com.google.gson.annotations.Since} and {@link com.google.gson.annotations.Until} annotations. This + * allows us to closely follow the Gson mechanisms wrt. serialization/deserialization. + */ + private static final class RecordFieldFactory { + + + private final Gson gson; + private final Excluder excluder; + private final JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory; + private final ConstructorConstructor constructorConstructor; + + public RecordFieldFactory(Gson gson, Excluder excluder, + JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory, + ConstructorConstructor constructorConstructor) { + this.gson = gson; + this.excluder = excluder; + this.jsonAdapterFactory = jsonAdapterFactory; + this.constructorConstructor = constructorConstructor; + } + + private RecordField createRecordField(Field field, Object recordComponent) { + TypeToken fieldType = TypeToken.get(RECORD_HELPER.getGenericType(recordComponent)); + JsonAdapter jsonAdapter = RECORD_HELPER.getAnnotation(recordComponent, JsonAdapter.class); + TypeAdapter typeAdapter; + if (jsonAdapter == null) { + typeAdapter = gson.getAdapter(fieldType); + } + else { + typeAdapter = jsonAdapterFactory.getTypeAdapter( + constructorConstructor, + gson, + fieldType, + jsonAdapter + ); + } + + // Determine the serialized and deserialized names. If there is an SerializedName annotation + // present, we need to respect it. This might also mean that multiple keys acts as aliases + // when reading Json. + SerializedName serializedNameAnnotation = + RECORD_HELPER.getAnnotation(recordComponent, SerializedName.class); + String serializedName; + String[] deserializedNames; + if (serializedNameAnnotation == null) { + serializedName = RECORD_HELPER.getName(recordComponent); + deserializedNames = new String[] { RECORD_HELPER.getName(recordComponent) }; + } + else { + serializedName = serializedNameAnnotation.value(); + deserializedNames = new String[serializedNameAnnotation.alternate().length + 1]; + deserializedNames[0] = serializedName; + for (int i = 0; i < serializedNameAnnotation.alternate().length; i++) { + deserializedNames[i + 1] = serializedNameAnnotation.alternate()[i]; + } + } + + return new RecordField( + gson.serializeNulls(), + excluder.excludeField(field, true), + excluder.excludeField(field, false), + RECORD_HELPER.getAccessor(recordComponent), + serializedName, + deserializedNames, + typeAdapter + ); + } + } + + /** + * Helper class to contain all the rules for a single field, in regard to Gson annotations. + */ + private static final class RecordField { + // Configured on Gson itself, if null values should be included in output + private final boolean serializeNulls; + // Configured via Expose / Since / Until annotations + private final boolean excludeOnSerialize; + private final boolean excludeOnDeSerialize; + // The public record method for reading out values from an instance of the record. + private final Method accessor; + // The name to write when serializing this field on a record + private final String serializedName; + // Which names we expect to find when deserializing this record + private final String[] deSerializedNames; + // Adapter for reading/write the json version of a record field. + private final TypeAdapter typeAdapter; + + private RecordField(boolean serializeNulls, boolean excludeOnSerialize, boolean excludeOnDeSerialize, + Method accessor, + String serializedName, String[] deSerializedNames, TypeAdapter typeAdapter) { + this.serializeNulls = serializeNulls; + this.excludeOnSerialize = excludeOnSerialize; + this.excludeOnDeSerialize = excludeOnDeSerialize; + this.accessor = accessor; + this.serializedName = serializedName; + this.deSerializedNames = deSerializedNames; + this.typeAdapter = typeAdapter; + } + + private void write(JsonWriter out, Object value) throws IOException { + try { + Object fieldValue = accessor.invoke(value); + // Respect the Gson config with regard to nulls. + if (!excludeOnSerialize && fieldValue != null || serializeNulls) { + out.name(serializedName); + typeAdapter.write(out, fieldValue); + } + } + catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException( + "Failed to serialize field [" + accessor + "] on [" + value + "]", e); + } + } + + private Object read(JsonReader in) throws IOException { + if (excludeOnDeSerialize) { + // Since we are always in an object context, this is a safe way to skip values + // we do not wish to de serialize. + in.skipValue(); + return null; + } + else { + return typeAdapter.read(in); + } + } + + @Override + public String toString() { + String classSimpleName = accessor.getDeclaringClass().getSimpleName(); + return "RecordField<" + classSimpleName + "." + accessor.getName() + " to " + serializedName + ">"; + } + } + + private static final class RecordTypeAdapterImpl extends TypeAdapter { + // Each field in the record, as managed by a RecordField instance. + private final RecordField[] recordFields; + // This map holds the index in the above array for each name we expect to find on de-serialization. This + // is used to organize the deserialized values into an Object[] that has the same order as the + // constructor arguments. + private final Map recordFieldIndex = new HashMap<>(); + // The actual record constructor. + private final Constructor constructor; + + private RecordTypeAdapterImpl(RecordField[] recordFields, Constructor constructor) { + this.recordFields = recordFields; + this.constructor = constructor; + for (int i = 0; i < recordFields.length; i++) { + RecordField recordField = recordFields[i]; + for (String name : recordField.deSerializedNames) { + Integer prevIndex = recordFieldIndex.put(name, i); + if (prevIndex != null) { + throw new IllegalArgumentException("Both [" + recordFields[prevIndex] + "]" + + " and [" + recordField + "] can be read from the same name [" + name + "]"); + } + } + } + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + out.beginObject(); + for (RecordField recordField : recordFields) { + recordField.write(out, value); + } + out.endObject(); + } + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + else if (in.peek() != JsonToken.BEGIN_OBJECT) { + throw new DeSerializeException("Expecting null or begin object at [" + in.getPath() + "]"); + } + in.beginObject(); + // This array will hold the value for each json field in the src object that we find, deserialized + // using a RecordField, that will also respect all Gson annotations. For fields that are not deserialized, + // the default null value will be used. + Object[] values = new Object[recordFields.length]; + nextField: + while (in.peek() != JsonToken.END_OBJECT) { + String fieldName = in.nextName(); + // Check if we know which field this name should deserialize to. If we do not find a matching index, + // then this field is one that is not present in our record, and ignored. We do not treat unknown + // object fields as an error, because the version of the class we have might differ from the source. + // -stun. + Integer fieldIndex = recordFieldIndex.get(fieldName); + if (fieldIndex != null) { + values[fieldIndex] = recordFields[fieldIndex].read(in); + } + else { + // Be sure to skip values we do not consume, otherwise the state of the parser will be wrong. + in.skipValue(); + } + } + in.endObject(); + // At this point, we have not verified that all fields are present, we treat absent values as null + // before passing on to the constructor. We could verify that all fields are present, but the src could + // have different rules about serializing null values than we do. So it is better to ignore that we are + // missing some values, rather than throw here. -stun. + try { + return constructor.newInstance(values); + } + catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new DeSerializeAccessException( + "Failed to create an instance of [" + constructor.getDeclaringClass() + "] at [" + in.getPath() + + "]", e); + } + } + } + + // ========= Exceptions ============================================================================================ + + public static class TypeAdapterReflectionException extends RuntimeException { + + private static final long serialVersionUID = -3905048094470359103L; + + public TypeAdapterReflectionException(String message, Throwable cause) { + super(message, cause); + } + } + + public static class DeSerializeAccessException extends RuntimeException { + private static final long serialVersionUID = 5565314742287167598L; + + public DeSerializeAccessException(String message, Throwable cause) { + super(message, cause); + } + } + + public static class DeSerializeException extends RuntimeException { + + private static final long serialVersionUID = 7554437800495514736L; + + public DeSerializeException(String message) { + super(message); + } + } +} From 080fb641c30cd8db453f2d6117330a66ccf9a969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sta=CC=8Ale=20Undheim?= Date: Sun, 25 Sep 2022 11:29:54 +0200 Subject: [PATCH 2/2] Fixed review comments by @eamonnmcmanus Ran Google code format on RecordTypeAdapterFactory so that it now conforms to Google style. Added primitive default values for primitive fields, to avoid NPE in the constructor. --- .../bind/RecordTypeAdapterFactory.java | 874 +++++++++--------- 1 file changed, 454 insertions(+), 420 deletions(-) diff --git a/gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java index e808398ed1..e92b57b18d 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java +++ b/gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java @@ -1,16 +1,5 @@ package com.google.gson.internal.bind; -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - import com.google.gson.Gson; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; @@ -22,459 +11,504 @@ import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; /** * {@link RecordFieldFactory} to create adapters for Java 17 records. * - * This class makes the following assumptions about records: - * - every record field is private final - * - for every field there exists a corresponding method with the same name - * - the order of the fields and the constructor arguments match exactly - * This means that this adapter will also be used for classes that are not records, but fulfill these - * requirements. There should be no issues here though, as we will just use constructors to create instances - * rather than the Gson Invoke magic. + *

This class makes the following assumptions about records: + * + *

    + *
  • >The name in the RecordComponent corresponds to a field in the class + *
  • >The order of RecordComponents is the same as for the Record canonical constructor + *
  • >When the isRecord method is present on the Class, RecordComponent will also exist in the + * same JVM. + *
*/ -@SuppressWarnings({"unchecked", "rawtypes"}) -// unchecked, rawtypes: Unavoidable, as we are dealing with inferred generics. public class RecordTypeAdapterFactory implements TypeAdapterFactory { - public static boolean SUPPORTS_RECORD_TYPES; + public static final boolean SUPPORTS_RECORD_TYPES; - // Find the isRecord method from the Class - using we do not need to handle NoSuchMethodException. - private static final RecordHelper RECORD_HELPER; + private static final RecordHelper RECORD_HELPER; - static { - Method isRecord; - try { - isRecord = Class.class.getDeclaredMethod("isRecord"); - } - catch (NoSuchMethodException e) { - isRecord = null; - } - RECORD_HELPER = (isRecord == null) ? null : new RecordHelper(isRecord); - SUPPORTS_RECORD_TYPES = RECORD_HELPER != null; + static { + Method isRecord; + try { + isRecord = Class.class.getDeclaredMethod("isRecord"); + } catch (NoSuchMethodException e) { + // If the isRecord is not defined, then we assume we are not on Java 17 or later, and there is + // no record support. + isRecord = null; } - - private final Excluder excluder; - private final ConstructorConstructor constructorConstructor; - private final JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory; - - public RecordTypeAdapterFactory(Excluder excluder, - ConstructorConstructor constructorConstructor, - JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory) { - this.excluder = excluder; - this.constructorConstructor = constructorConstructor; - this.jsonAdapterFactory = jsonAdapterFactory; + RECORD_HELPER = (isRecord == null) ? null : new RecordHelper(isRecord); + SUPPORTS_RECORD_TYPES = RECORD_HELPER != null; + } + + private final Excluder excluder; + private final ConstructorConstructor constructorConstructor; + private final JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory; + + public RecordTypeAdapterFactory( + Excluder excluder, + ConstructorConstructor constructorConstructor, + JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory) { + this.excluder = excluder; + this.constructorConstructor = constructorConstructor; + this.jsonAdapterFactory = jsonAdapterFactory; + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + // ?: Is this not a record? + if (!RECORD_HELPER.isRecord(type)) { + // Yes -> Not supported, we return null as per the TypeAdapterFactory contract + return null; } - /** - * Internal helper class to manage access to Class.isRecord, and RecordComponent instances. Since this compiles - * on Java 8, we need to use reflection to handle RecordComponent instances. - */ - private static final class RecordHelper { - private final Method isRecord; - private final Method getRecordComponents; - private final Class recordComponentType; - // RecordComponent methods - private final Method getName; - private final Method getType; - private final Method getGenericType; - // getAccessor returns a method, that in turn can be invoked on the Record to read out its value - private final Method getAccessor; - private final Method getAnnotation; - - /** - * Create a new RecordHelper to handle reflection for RecordComponent on Java 17. - */ - private RecordHelper(Method isRecord) { - this.isRecord = Objects.requireNonNull(isRecord, "isRecord must not be null"); - try { - getRecordComponents = Class.class.getDeclaredMethod("getRecordComponents"); - recordComponentType = getRecordComponents.getReturnType().getComponentType(); - getName = recordComponentType.getDeclaredMethod("getName"); - getType = recordComponentType.getDeclaredMethod("getType"); - getGenericType = recordComponentType.getDeclaredMethod("getGenericType"); - getAccessor = recordComponentType.getDeclaredMethod("getAccessor"); - getAnnotation = recordComponentType.getDeclaredMethod("getAnnotation", Class.class); - - } - catch (NoSuchMethodException e) { - throw new TypeAdapterReflectionException( - "Expected to find method getRecordComponents when the isRecord method is present in Class", e); - } - } - - boolean isRecord(TypeToken type) { - try { - return isRecord != null && Boolean.TRUE.equals(isRecord.invoke(type)); - } - catch (IllegalAccessException | InvocationTargetException e) { - throw new TypeAdapterReflectionException("Unable to create TypeAdapter for [" + type + "]", e); - } - } - - Object[] getRecordComponents(TypeToken type) { - try { - return (Object[]) getRecordComponents.invoke(type.getRawType()); - } - catch (IllegalAccessException | InvocationTargetException e) { - throw new TypeAdapterReflectionException("Unable to invoke getRecordComponents", e); - } - } - - String getName(Object recordComponent) { - try { - return (String) getName.invoke(recordComponent); - } - catch (IllegalAccessException | InvocationTargetException e) { - throw new TypeAdapterReflectionException("Failed to invoke method [getName] on RecordComponent", e); - } - } - Class getType(Object recordComponent) { - try { - return (Class) getType.invoke(recordComponent); - } - catch (IllegalAccessException | InvocationTargetException e) { - throw new TypeAdapterReflectionException( - "Failed to invoke method [getGenericType] on RecordComponent", e); - } - } - Type getGenericType(Object recordComponent) { - try { - return (Type) getGenericType.invoke(recordComponent); - } - catch (IllegalAccessException | InvocationTargetException e) { - throw new TypeAdapterReflectionException( - "Failed to invoke method [getGenericType] on RecordComponent", e); - } - } - // getAccessor returns a method, that in turn can be invoked on the Record to read out its value - Method getAccessor(Object recordComponent) { - try { - return (Method) getAccessor.invoke(recordComponent); - } - catch (IllegalAccessException | InvocationTargetException e) { - throw new TypeAdapterReflectionException("Failed to invoke method [getAccessor] on RecordComponent", e); - } - } -
A getAnnotation(Object recordComponent, Class annotation) { - try { - return (A) getAnnotation.invoke(recordComponent, annotation); - } - catch (IllegalAccessException | InvocationTargetException e) { - throw new TypeAdapterReflectionException( - "Failed to invoke method [getAnnotation] on RecordComponent", e); - } - } + try { + // To construct record fields, we actually need to do some reflection on the Gson instance. + // This + // logic is contained in the RecordFieldFactory. + RecordFieldFactory recordFieldFactory = + new RecordFieldFactory(gson, excluder, jsonAdapterFactory, constructorConstructor); + + Object[] recordComponents = RECORD_HELPER.getRecordComponents(type); + Class[] componentTypes = new Class[recordComponents.length]; + for (int i = 0; i < recordComponents.length; i++) { + componentTypes[i] = RECORD_HELPER.getType(recordComponents[i]); + } + + // Find the canonical constructor on the Record that corresponds to the record components. + // There is no method in the Java API to do the equivalent, instead we rely on the constructor + // and recordComponent order to be the same. This construct matches the StackOverflow answer + // here: https://stackoverflow.com/a/67127067 + @SuppressWarnings("unchecked") + Constructor recordConstructor = + (Constructor) type.getRawType().getConstructor(componentTypes); + + RecordField[] recordFields = new RecordField[recordComponents.length]; + for (int i = 0; i < recordComponents.length; i++) { + // We need the field for the Gson Excluder, so that we can correctly determine if a field + // should be included or not. + Field field = RECORD_HELPER.getField(recordComponents[i], type); + recordFields[i] = recordFieldFactory.createRecordField(field, recordComponents[i]); + } + return new RecordTypeAdapterImpl<>(recordFields, recordConstructor); + } catch (NoSuchMethodException e) { + // We hit this either because we do not find the expected constructor, or we do not find a + // method name that matches the field name. In either of these classes, this is not a record, + // and we do not support serialization of it. The contract for TypeAdapterFactory is to return + // null for unsupported types. Gson will then attempt other means of creating a TypeAdapter. + // If all fails, an exception will be created where Gson was invoked to serialize/deserialize + // a type. + return null; + } + } + + /** + * Internal helper class to manage access to Class.isRecord, and RecordComponent instances. Since + * this compiles on Java 8, we need to use reflection to handle RecordComponent instances. + */ + private static final class RecordHelper { + private final Method isRecord; + private final Method getRecordComponents; + // RecordComponent methods + private final Method getName; + private final Method getType; + private final Method getGenericType; + // getAccessor returns a method, that in turn can be invoked on the Record to read out its value + private final Method getAccessor; + private final Method getAnnotation; + + /** Create a new RecordHelper to handle reflection for RecordComponent on Java 17. */ + private RecordHelper(Method isRecord) { + this.isRecord = Objects.requireNonNull(isRecord, "isRecord must not be null"); + try { + getRecordComponents = Class.class.getDeclaredMethod("getRecordComponents"); + Class recordComponentType = getRecordComponents.getReturnType().getComponentType(); + getName = recordComponentType.getDeclaredMethod("getName"); + getType = recordComponentType.getDeclaredMethod("getType"); + getGenericType = recordComponentType.getDeclaredMethod("getGenericType"); + getAccessor = recordComponentType.getDeclaredMethod("getAccessor"); + getAnnotation = recordComponentType.getDeclaredMethod("getAnnotation", Class.class); + + } catch (NoSuchMethodException e) { + throw new TypeAdapterReflectionException( + "Expected to find method getRecordComponents when the isRecord method is present in" + + " Class", + e); + } + } + boolean isRecord(TypeToken type) { + try { + return isRecord != null && Boolean.TRUE.equals(isRecord.invoke(type)); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Unable to create TypeAdapter for [" + type + "]", e); + } + } - public Field getField(Object recordComponent, TypeToken type) { - // There is nothing in the RecordComponent class to access the underlying field, so we do this based - // on the component name. - String fieldName = getName(recordComponent); - try { - return type.getRawType().getDeclaredField(fieldName); - } - catch (NoSuchFieldException e) { - throw new TypeAdapterReflectionException( - "Expected to find field [" + fieldName + "] on recordComponent [" + recordComponent + "]" - + " on type [" + type + "]. Somehow there is a discrepancy between the record components" - + " and the fields on the Class.", e); - } - } + Object[] getRecordComponents(TypeToken type) { + try { + return (Object[]) getRecordComponents.invoke(type.getRawType()); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException("Unable to invoke getRecordComponents", e); + } + } + String getName(Object recordComponent) { + try { + return (String) getName.invoke(recordComponent); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Failed to invoke method [getName] on RecordComponent", e); + } } - @Override - public TypeAdapter create(Gson gson, TypeToken type) { - // ?: Is this not a record? - if (!RECORD_HELPER.isRecord(type)) { - // Yes -> Not supported, we return null as per the TypeAdapterFactory contract - return null; - } + Class getType(Object recordComponent) { + try { + return (Class) getType.invoke(recordComponent); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Failed to invoke method [getGenericType] on RecordComponent", e); + } + } - try { - // To construct record fields, we actually need to do some reflection on the Gson instance. This - // logic is contained in the RecordFieldFactory. - RecordFieldFactory recordFieldFactory = - new RecordFieldFactory(gson, excluder, jsonAdapterFactory, constructorConstructor); - - Object[] recordComponents = RECORD_HELPER.getRecordComponents(type); - Class[] componentTypes = new Class[recordComponents.length]; - for (int i = 0; i < recordComponents.length; i++) { - componentTypes[i] = RECORD_HELPER.getType(recordComponents[i]); - } - - // Find the canonical constructor on the Record that corresponds to the record components. - // There is no method in the Java API to do the equivialent, instead we rely on the constructor and - // recordComponent order to be the same. This construct matches the StackOverflow answer here: - // https://stackoverflow.com/a/67127067 - Constructor recordConstructor = (Constructor) type.getRawType().getConstructor(componentTypes); - - RecordField[] recordFields = new RecordField[recordComponents.length]; - for (int i = 0; i < recordComponents.length; i++) { - // We need the field for the Gson Excluder, so that we can correctly determine if a field should - // be included or not. - Field field = RECORD_HELPER.getField(recordComponents[i], type); - recordFields[i] = recordFieldFactory.createRecordField(field, recordComponents[i]); - } - return new RecordTypeAdapterImpl<>(recordFields, recordConstructor); - } - catch (NoSuchMethodException e) { - // We hit this either because we do not find the expected constructor, or we do not find a method - // name that matches the field name. In either of these classes, this is not a record, and we - // do not support serialization of it. The contract for TypeAdapterFactory is to return null for - // unsupported types. Gson will then attempt other means of creating a TypeAdapter. If all fails, an - // exception will be created where Gson was invoked to serialize/deserialize a type. - return null; - } + Type getGenericType(Object recordComponent) { + try { + return (Type) getGenericType.invoke(recordComponent); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Failed to invoke method [getGenericType] on RecordComponent", e); + } + } + // getAccessor returns a method, that in turn can be invoked on the Record to read out its value + Method getAccessor(Object recordComponent) { + try { + return (Method) getAccessor.invoke(recordComponent); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Failed to invoke method [getAccessor] on RecordComponent", e); + } } - /** - * Factory to construct our own {@link RecordField} instances. This requires some reflection into the internal - * of Gson, which has logic to handle the {@link JsonAdapter} annotation. We also need to reflect into Gson - * to fetch the {@link Excluder}, that handles the {@link com.google.gson.annotations.Expose}, - * {@link com.google.gson.annotations.Since} and {@link com.google.gson.annotations.Until} annotations. This - * allows us to closely follow the Gson mechanisms wrt. serialization/deserialization. - */ - private static final class RecordFieldFactory { - - - private final Gson gson; - private final Excluder excluder; - private final JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory; - private final ConstructorConstructor constructorConstructor; - - public RecordFieldFactory(Gson gson, Excluder excluder, - JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory, - ConstructorConstructor constructorConstructor) { - this.gson = gson; - this.excluder = excluder; - this.jsonAdapterFactory = jsonAdapterFactory; - this.constructorConstructor = constructorConstructor; - } + A getAnnotation(Object recordComponent, Class annotation) { + try { + return annotation.cast(getAnnotation.invoke(recordComponent, annotation)); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new TypeAdapterReflectionException( + "Failed to invoke method [getAnnotation] on RecordComponent", e); + } + } - private RecordField createRecordField(Field field, Object recordComponent) { - TypeToken fieldType = TypeToken.get(RECORD_HELPER.getGenericType(recordComponent)); - JsonAdapter jsonAdapter = RECORD_HELPER.getAnnotation(recordComponent, JsonAdapter.class); - TypeAdapter typeAdapter; - if (jsonAdapter == null) { - typeAdapter = gson.getAdapter(fieldType); - } - else { - typeAdapter = jsonAdapterFactory.getTypeAdapter( - constructorConstructor, - gson, - fieldType, - jsonAdapter - ); - } - - // Determine the serialized and deserialized names. If there is an SerializedName annotation - // present, we need to respect it. This might also mean that multiple keys acts as aliases - // when reading Json. - SerializedName serializedNameAnnotation = - RECORD_HELPER.getAnnotation(recordComponent, SerializedName.class); - String serializedName; - String[] deserializedNames; - if (serializedNameAnnotation == null) { - serializedName = RECORD_HELPER.getName(recordComponent); - deserializedNames = new String[] { RECORD_HELPER.getName(recordComponent) }; - } - else { - serializedName = serializedNameAnnotation.value(); - deserializedNames = new String[serializedNameAnnotation.alternate().length + 1]; - deserializedNames[0] = serializedName; - for (int i = 0; i < serializedNameAnnotation.alternate().length; i++) { - deserializedNames[i + 1] = serializedNameAnnotation.alternate()[i]; - } - } - - return new RecordField( - gson.serializeNulls(), - excluder.excludeField(field, true), - excluder.excludeField(field, false), - RECORD_HELPER.getAccessor(recordComponent), - serializedName, - deserializedNames, - typeAdapter - ); - } + public Field getField(Object recordComponent, TypeToken type) { + // There is nothing in the RecordComponent class to access the underlying field, so we do this + // based + // on the component name. + String fieldName = getName(recordComponent); + try { + return type.getRawType().getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + throw new TypeAdapterReflectionException( + "Expected to find field [" + + fieldName + + "] on recordComponent [" + + recordComponent + + "]" + + " on type [" + + type + + "]. Somehow there is a discrepancy between the record components" + + " and the fields on the Class.", + e); + } } + } + + /** + * Factory to construct our own {@link RecordField} instances. This requires some reflection into + * the internal of Gson, which has logic to handle the {@link JsonAdapter} annotation. We also + * need to reflect into Gson to fetch the {@link Excluder}, that handles the {@link + * com.google.gson.annotations.Expose}, {@link com.google.gson.annotations.Since} and {@link + * com.google.gson.annotations.Until} annotations. This allows us to closely follow the Gson + * mechanisms wrt. serialization/deserialization. + */ + private static final class RecordFieldFactory { + + private final Gson gson; + private final Excluder excluder; + private final JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory; + private final ConstructorConstructor constructorConstructor; - /** - * Helper class to contain all the rules for a single field, in regard to Gson annotations. - */ - private static final class RecordField { - // Configured on Gson itself, if null values should be included in output - private final boolean serializeNulls; - // Configured via Expose / Since / Until annotations - private final boolean excludeOnSerialize; - private final boolean excludeOnDeSerialize; - // The public record method for reading out values from an instance of the record. - private final Method accessor; - // The name to write when serializing this field on a record - private final String serializedName; - // Which names we expect to find when deserializing this record - private final String[] deSerializedNames; - // Adapter for reading/write the json version of a record field. - private final TypeAdapter typeAdapter; - - private RecordField(boolean serializeNulls, boolean excludeOnSerialize, boolean excludeOnDeSerialize, - Method accessor, - String serializedName, String[] deSerializedNames, TypeAdapter typeAdapter) { - this.serializeNulls = serializeNulls; - this.excludeOnSerialize = excludeOnSerialize; - this.excludeOnDeSerialize = excludeOnDeSerialize; - this.accessor = accessor; - this.serializedName = serializedName; - this.deSerializedNames = deSerializedNames; - this.typeAdapter = typeAdapter; - } + public RecordFieldFactory( + Gson gson, + Excluder excluder, + JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory, + ConstructorConstructor constructorConstructor) { + this.gson = gson; + this.excluder = excluder; + this.jsonAdapterFactory = jsonAdapterFactory; + this.constructorConstructor = constructorConstructor; + } - private void write(JsonWriter out, Object value) throws IOException { - try { - Object fieldValue = accessor.invoke(value); - // Respect the Gson config with regard to nulls. - if (!excludeOnSerialize && fieldValue != null || serializeNulls) { - out.name(serializedName); - typeAdapter.write(out, fieldValue); - } - } - catch (IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException( - "Failed to serialize field [" + accessor + "] on [" + value + "]", e); - } - } + private RecordField createRecordField(Field field, Object recordComponent) { + TypeToken fieldType = TypeToken.get(RECORD_HELPER.getGenericType(recordComponent)); + JsonAdapter jsonAdapter = RECORD_HELPER.getAnnotation(recordComponent, JsonAdapter.class); + TypeAdapter typeAdapter; + if (jsonAdapter == null) { + typeAdapter = gson.getAdapter(fieldType); + } else { + typeAdapter = + jsonAdapterFactory.getTypeAdapter(constructorConstructor, gson, fieldType, jsonAdapter); + } + + // Determine the serialized and deserialized names. If there is an SerializedName annotation + // present, we need to respect it. This might also mean that multiple keys acts as aliases + // when reading Json. + SerializedName serializedNameAnnotation = + RECORD_HELPER.getAnnotation(recordComponent, SerializedName.class); + String serializedName; + String[] deserializedNames; + if (serializedNameAnnotation == null) { + serializedName = RECORD_HELPER.getName(recordComponent); + deserializedNames = new String[] {RECORD_HELPER.getName(recordComponent)}; + } else { + serializedName = serializedNameAnnotation.value(); + deserializedNames = new String[serializedNameAnnotation.alternate().length + 1]; + deserializedNames[0] = serializedName; + System.arraycopy( + serializedNameAnnotation.alternate(), + 0, + // Start at offset 1, as the default value is stored at index 0 + deserializedNames, + 1, + serializedNameAnnotation.alternate().length); + } + + return new RecordField( + gson.serializeNulls(), + excluder.excludeField(field, true), + excluder.excludeField(field, false), + RECORD_HELPER.getAccessor(recordComponent), + serializedName, + deserializedNames, + typeAdapter); + } + } + + /** Helper class to contain all the rules for a single field, in regard to Gson annotations. */ + private static final class RecordField { + // Configured on Gson itself, if null values should be included in output + private final boolean serializeNulls; + // Configured via Expose / Since / Until annotations + private final boolean excludeOnSerialize; + private final boolean excludeOnDeSerialize; + // The public record method for reading out values from an instance of the record. + private final Method accessor; + // The name to write when serializing this field on a record + private final String serializedName; + // Which names we expect to find when deserializing this record + private final String[] deSerializedNames; + // Adapter for reading/write the json version of a record field. + @SuppressWarnings("rawtypes") + private final TypeAdapter typeAdapter; + + private final Object defaultValue; + + private RecordField( + boolean serializeNulls, + boolean excludeOnSerialize, + boolean excludeOnDeSerialize, + Method accessor, + String serializedName, + String[] deSerializedNames, + TypeAdapter typeAdapter) { + this.serializeNulls = serializeNulls; + this.excludeOnSerialize = excludeOnSerialize; + this.excludeOnDeSerialize = excludeOnDeSerialize; + this.accessor = accessor; + this.serializedName = serializedName; + this.deSerializedNames = deSerializedNames; + this.typeAdapter = typeAdapter; + if (accessor.getReturnType().isPrimitive()) { + // To initialize primitives, we use reflection to create an array of size 1, and get the + // first element. + defaultValue = Array.get(Array.newInstance(accessor.getReturnType(), 1), 0); + } else { + defaultValue = null; + } + } - private Object read(JsonReader in) throws IOException { - if (excludeOnDeSerialize) { - // Since we are always in an object context, this is a safe way to skip values - // we do not wish to de serialize. - in.skipValue(); - return null; - } - else { - return typeAdapter.read(in); - } + @SuppressWarnings("unchecked") + private void appendNameAndValue(JsonWriter out, Object value) throws IOException { + try { + Object fieldValue = accessor.invoke(value); + // Respect the Gson config with regard to nulls. + if (!excludeOnSerialize && fieldValue != null || serializeNulls) { + out.name(serializedName); + typeAdapter.write(out, fieldValue); } + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException( + "Failed to serialize field [" + accessor + "] on [" + value + "]", e); + } + } - @Override - public String toString() { - String classSimpleName = accessor.getDeclaringClass().getSimpleName(); - return "RecordField<" + classSimpleName + "." + accessor.getName() + " to " + serializedName + ">"; - } + private Object readValueForKey(JsonReader in) throws IOException { + if (excludeOnDeSerialize) { + // Since we are always in an object context, this is a safe way to skip values + // we do not wish to deserialize. + in.skipValue(); + return null; + } else { + return typeAdapter.read(in); + } } - private static final class RecordTypeAdapterImpl extends TypeAdapter { - // Each field in the record, as managed by a RecordField instance. - private final RecordField[] recordFields; - // This map holds the index in the above array for each name we expect to find on de-serialization. This - // is used to organize the deserialized values into an Object[] that has the same order as the - // constructor arguments. - private final Map recordFieldIndex = new HashMap<>(); - // The actual record constructor. - private final Constructor constructor; - - private RecordTypeAdapterImpl(RecordField[] recordFields, Constructor constructor) { - this.recordFields = recordFields; - this.constructor = constructor; - for (int i = 0; i < recordFields.length; i++) { - RecordField recordField = recordFields[i]; - for (String name : recordField.deSerializedNames) { - Integer prevIndex = recordFieldIndex.put(name, i); - if (prevIndex != null) { - throw new IllegalArgumentException("Both [" + recordFields[prevIndex] + "]" - + " and [" + recordField + "] can be read from the same name [" + name + "]"); - } - } - } + @Override + public String toString() { + String classSimpleName = accessor.getDeclaringClass().getSimpleName(); + return "RecordField<" + + classSimpleName + + "." + + accessor.getName() + + " to " + + serializedName + + ">"; + } + } + + private static final class RecordTypeAdapterImpl extends TypeAdapter { + // Each field in the record, as managed by a RecordField instance. + private final RecordField[] recordFields; + // This map holds the index in the above array for each name we expect to find on + // de-serialization. This + // is used to organize the deserialized values into an Object[] that has the same order as the + // constructor arguments. + private final Map recordFieldIndex = new HashMap<>(); + // The actual record constructor. + private final Constructor constructor; + private final Object[] constuctorDefaultValues; + + private RecordTypeAdapterImpl(RecordField[] recordFields, Constructor constructor) { + this.recordFields = recordFields; + this.constructor = constructor; + for (int i = 0; i < recordFields.length; i++) { + RecordField recordField = recordFields[i]; + for (String name : recordField.deSerializedNames) { + Integer prevIndex = recordFieldIndex.put(name, i); + if (prevIndex != null) { + throw new IllegalArgumentException( + "Both [" + + recordFields[prevIndex] + + "]" + + " and [" + + recordField + + "] can be read from the same name [" + + name + + "]"); + } } + } + constuctorDefaultValues = new Object[recordFields.length]; + for (int i = 0; i < recordFields.length; i++) { + constuctorDefaultValues[i] = recordFields[i].defaultValue; + } + } - @Override - public void write(JsonWriter out, T value) throws IOException { - if (value == null) { - out.nullValue(); - return; - } - out.beginObject(); - for (RecordField recordField : recordFields) { - recordField.write(out, value); - } - out.endObject(); - } + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + out.beginObject(); + for (RecordField recordField : recordFields) { + recordField.appendNameAndValue(out, value); + } + out.endObject(); + } - @Override - public T read(JsonReader in) throws IOException { - if (in.peek() == JsonToken.NULL) { - in.nextNull(); - return null; - } - else if (in.peek() != JsonToken.BEGIN_OBJECT) { - throw new DeSerializeException("Expecting null or begin object at [" + in.getPath() + "]"); - } - in.beginObject(); - // This array will hold the value for each json field in the src object that we find, deserialized - // using a RecordField, that will also respect all Gson annotations. For fields that are not deserialized, - // the default null value will be used. - Object[] values = new Object[recordFields.length]; - nextField: - while (in.peek() != JsonToken.END_OBJECT) { - String fieldName = in.nextName(); - // Check if we know which field this name should deserialize to. If we do not find a matching index, - // then this field is one that is not present in our record, and ignored. We do not treat unknown - // object fields as an error, because the version of the class we have might differ from the source. - // -stun. - Integer fieldIndex = recordFieldIndex.get(fieldName); - if (fieldIndex != null) { - values[fieldIndex] = recordFields[fieldIndex].read(in); - } - else { - // Be sure to skip values we do not consume, otherwise the state of the parser will be wrong. - in.skipValue(); - } - } - in.endObject(); - // At this point, we have not verified that all fields are present, we treat absent values as null - // before passing on to the constructor. We could verify that all fields are present, but the src could - // have different rules about serializing null values than we do. So it is better to ignore that we are - // missing some values, rather than throw here. -stun. - try { - return constructor.newInstance(values); - } - catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { - throw new DeSerializeAccessException( - "Failed to create an instance of [" + constructor.getDeclaringClass() + "] at [" + in.getPath() - + "]", e); - } + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } else if (in.peek() != JsonToken.BEGIN_OBJECT) { + throw new DeserializeException("Expecting null or begin object at [" + in.getPath() + "]"); + } + in.beginObject(); + // This array will hold the value for each json field in the src object that we find, + // deserialized using a RecordField, that will also respect all Gson annotations. For fields + // that are not deserialized, the default null value will be used. + Object[] values = new Object[recordFields.length]; + // Copy the default values, this will ensure that primitives are non-null. + System.arraycopy(constuctorDefaultValues, 0, values, 0, values.length); + + while (in.peek() != JsonToken.END_OBJECT) { + String fieldName = in.nextName(); + // Check if we know which field this name should deserialize to. If we do not find a + // matching index, then this field is one that is not present in our record, and ignored. We + // do not treat unknown object fields as an error, because the version of the class we have + // might differ from the source. + Integer fieldIndex = recordFieldIndex.get(fieldName); + if (fieldIndex != null) { + values[fieldIndex] = recordFields[fieldIndex].readValueForKey(in); + } else { + // Be sure to skip values we do not consume, otherwise the state of the parser will be + // wrong. + in.skipValue(); } + } + in.endObject(); + // At this point, we have not verified that all fields are present, we treat absent values as + // null before passing on to the constructor. We could verify that all fields are present, but + // the src could have different rules about serializing null values than we do. So it is + // better to ignore that we are missing some values, rather than throw here. -stun. + try { + return constructor.newInstance(values); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new DeserializeAccessException( + "Failed to create an instance of [" + + constructor.getDeclaringClass() + + "] at [" + + in.getPath() + + "]", + e); + } } + } - // ========= Exceptions ============================================================================================ + // ========= Exceptions ========================================================================= - public static class TypeAdapterReflectionException extends RuntimeException { + public static class TypeAdapterReflectionException extends RuntimeException { - private static final long serialVersionUID = -3905048094470359103L; + private static final long serialVersionUID = -3905048094470359103L; - public TypeAdapterReflectionException(String message, Throwable cause) { - super(message, cause); - } + public TypeAdapterReflectionException(String message, Throwable cause) { + super(message, cause); } + } - public static class DeSerializeAccessException extends RuntimeException { - private static final long serialVersionUID = 5565314742287167598L; + public static class DeserializeAccessException extends RuntimeException { + private static final long serialVersionUID = 5565314742287167598L; - public DeSerializeAccessException(String message, Throwable cause) { - super(message, cause); - } + public DeserializeAccessException(String message, Throwable cause) { + super(message, cause); } + } - public static class DeSerializeException extends RuntimeException { + public static class DeserializeException extends RuntimeException { - private static final long serialVersionUID = 7554437800495514736L; + private static final long serialVersionUID = 7554437800495514736L; - public DeSerializeException(String message) { - super(message); - } + public DeserializeException(String message) { + super(message); } + } }