The design of the input JSON is terrible and really hard to use.
Let me say that:
- it mixes elements and collections for the
answerData
attributes with dozens of cons against it;
- answer elements lack the type discriminator field so the deserialize must analyze each JSON tree to produce a valid deserialized object with another dozen of cons against it (including "there is no way to determine the exact type precisely" and "it may require too much memory because of JSON trees");
- Some tools like OpenAPI/Swagger use the discriminator field to deserialize to a dedicated type without doing any heuristics.
Any
won't work for you of course, as Gson has no even a single idea what those payloads are supposed to be deserialized to.
Since you didn't provide your mappings, I'll provide mine demonstrating an example idea of how such terrible JSON documents can be deserialized.
This also includes:
- using Java 11 and Lombok instead of Kotlin (as it does not really matter as you stated in the notice);
- mapping an answer with a list of answers even if the incoming JSON node contains an object instead of an array to unify all of that;
- creating a deducing deserializer that naively does some "magic" to get rid of the bad JSON design.
To resolve the first issue, elements vs arrays/lists, I've found a ready-to-use solution right here at S.O.:
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class AlwaysListTypeAdapterFactory<E> implements TypeAdapterFactory {
@Nullable
@Override
public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
if (!List.class.isAssignableFrom(typeToken.getRawType())) {
return null;
}
final Type elementType = resolveTypeArgument(typeToken.getType());
@SuppressWarnings("unchecked")
final TypeAdapter<E> elementTypeAdapter = (TypeAdapter<E>) gson.getAdapter(TypeToken.get(elementType));
@SuppressWarnings("unchecked")
final TypeAdapter<T> alwaysListTypeAdapter = (TypeAdapter<T>) new AlwaysListTypeAdapter<>(elementTypeAdapter).nullSafe();
return alwaysListTypeAdapter;
}
private static Type resolveTypeArgument(final Type type) {
if (!(type instanceof ParameterizedType)) {
return Object.class;
}
final ParameterizedType parameterizedType = (ParameterizedType) type;
return parameterizedType.getActualTypeArguments()[0];
}
private static final class AlwaysListTypeAdapter<E> extends TypeAdapter<List<E>> {
private final TypeAdapter<E> elementTypeAdapter;
private AlwaysListTypeAdapter(final TypeAdapter<E> elementTypeAdapter) {
this.elementTypeAdapter = elementTypeAdapter;
}
@Override
public void write(final JsonWriter out, final List<E> list) {
throw new UnsupportedOperationException();
}
@Override
public List<E> read(final JsonReader in) throws IOException {
final List<E> list = new ArrayList<>();
final JsonToken token = in.peek();
switch ( token ) {
case BEGIN_ARRAY:
in.beginArray();
while ( in.hasNext() ) {
list.add(elementTypeAdapter.read(in));
}
in.endArray();
break;
case BEGIN_OBJECT:
case STRING:
case NUMBER:
case BOOLEAN:
list.add(elementTypeAdapter.read(in));
break;
case NULL:
throw new AssertionError("Must never happen: check if the type adapter configured with .nullSafe()");
case NAME:
case END_ARRAY:
case END_OBJECT:
case END_DOCUMENT:
throw new MalformedJsonException("Unexpected token: " + token);
default:
throw new AssertionError("Must never happen: " + token);
}
return list;
}
}
}
Next, for the item no. 2, a deducing type adapter factory might be implemented like this:
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class DeducingTypeAdapterFactory<V> implements TypeAdapterFactory {
public interface TypeAdapterProvider {
@Nonnull
<T> TypeAdapter<T> provide(@Nonnull TypeToken<T> typeToken);
}
private final Predicate<? super TypeToken<?>> isSupported;
private final BiFunction<? super JsonElement, ? super TypeAdapterProvider, ? extends V> deduce;
public static <V> TypeAdapterFactory create(final Predicate<? super TypeToken<?>> isSupported,
final BiFunction<? super JsonElement, ? super TypeAdapterProvider, ? extends V> deduce) {
return new DeducingTypeAdapterFactory<>(isSupported, deduce);
}
@Override
@Nullable
public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
if (!isSupported.test(typeToken)) {
return null;
}
final Map<TypeToken<?>, TypeAdapter<?>> cache = new ConcurrentHashMap<>();
final TypeAdapter<V> deducedTypeAdapter = new TypeAdapter<V>() {
@Override
public void write(final JsonWriter jsonWriter, final V value) {
throw new UnsupportedOperationException();
}
@Override
public V read(final JsonReader jsonReader) {
final JsonElement jsonElement = Streams.parse(jsonReader);
return deduce.apply(jsonElement, new TypeAdapterProvider() {
@Nonnull
@Override
public <TT> TypeAdapter<TT> provide(@Nonnull final TypeToken<TT> typeToken) {
final TypeAdapter<?> cachedTypeAdapter = cache.computeIfAbsent(typeToken, tt -> gson.getDelegateAdapter(DeducingTypeAdapterFactory.this, tt));
@SuppressWarnings("unchecked")
final TypeAdapter<TT> typeAdapter = (TypeAdapter<TT>) cachedTypeAdapter;
return typeAdapter;
}
});
}
}
.nullSafe();
@SuppressWarnings("unchecked")
final TypeAdapter<T> typeAdapter = (TypeAdapter<T>) deducedTypeAdapter;
return typeAdapter;
}
}
Basically, it does no deducing itself, and only delegates the filter and deducing jobs elsewhere using the Strategy design pattern.
Now let's assume your mappings are "general" enough (including using @JsonAdapter
for Answer
to coerce single elements to become lists):
@RequiredArgsConstructor(access = AccessLevel.PACKAGE, staticName = "of")
@Getter
@EqualsAndHashCode
@ToString
final class Response<T> {
@Nullable
@SerializedName("status")
private final String status;
@Nullable
@SerializedName("data")
private final T data;
}
@RequiredArgsConstructor(access = AccessLevel.PACKAGE, staticName = "of")
@Getter
@EqualsAndHashCode
@ToString
final class Answer {
@SerializedName("id")
private final int id;
@Nullable
@SerializedName("answerData")
@JsonAdapter(AlwaysListTypeAdapterFactory.class)
private final List<AnswerDatum> answerData;
}
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
abstract class AnswerDatum {
interface Visitor<R> {
R visit(@Nonnull Type1 answerDatum);
R visit(@Nonnull Type2 answerDatum);
R visit(@Nonnull Type3 answerDatum);
R visit(@Nonnull Type4 answerDatum);
}
abstract <R> R accept(@Nonnull Visitor<? extends R> visitor);
@RequiredArgsConstructor(access = AccessLevel.PACKAGE, staticName = "of")
@Getter
@EqualsAndHashCode(callSuper = false)
@ToString(callSuper = false)
static final class Type1 extends AnswerDatum {
@SerializedName("selectionOptionId")
private final int selectionOptionId;
@Nullable
@SerializedName("selectionOptionText")
private final String selectionOptionText;
@Override
<R> R accept(@Nonnull final Visitor<? extends R> visitor) {
return visitor.visit(this);
}
}
@RequiredArgsConstructor(access = AccessLevel.PACKAGE, staticName = "of")
@Getter
@EqualsAndHashCode(callSuper = false)
@ToString(callSuper = false)
static final class Type2 extends AnswerDatum {
@SerializedName("IsAffiliatedWithSeller")
private final boolean isAffiliatedWithSeller;
@Nullable
@SerializedName("AffiliationDescription")
private final String affiliationDescription;
@Override
<R> R accept(@Nonnull final Visitor<? extends R> visitor) {
return visitor.visit(this);
}
}
@RequiredArgsConstructor(access = AccessLevel.PACKAGE, staticName = "of")
@Getter
@EqualsAndHashCode(callSuper = false)
@ToString(callSuper = false)
static final class Type3 extends AnswerDatum {
@Nonnull
private final String key;
@Nullable
private final String value;
@Override
<R> R accept(@Nonnull final Visitor<? extends R> visitor) {
return visitor.visit(this);
}
}
@RequiredArgsConstructor(access = AccessLevel.PACKAGE, staticName = "of")
@Getter
@EqualsAndHashCode(callSuper = false)
@ToString(callSuper = false)
static final class Type4 extends AnswerDatum {
@SerializedName("liabilityTypeId")
private final int liabilityTypeId;
@SerializedName("monthlyPayment")
private final int monthlyPayment;
@SerializedName("remainingMonth")
private final int remainingMonth;
@Nullable
@SerializedName("liabilityName")
private final String liabilityName;
@Nullable
@SerializedName("name")
private final String name;
@Override
<R> R accept(@Nonnull final Visitor<? extends R> visitor) {
return visitor.visit(this);
}
}
}
Note how AnswerDatum
uses the Visitor design pattern to avoid explicit type casting.
I'm not sure how it is leveraged in Java when using sealed classes.
public final class DeducingTypeAdapterFactoryTest {
private static final Pattern digitsPattern = Pattern.compile("^\\d+$");
private static final TypeToken<String> stringTypeToken = new TypeToken<>() {};
private static final TypeToken<AnswerDatum.Type1> answerDatumType1TypeToken = new TypeToken<>() {};
private static final TypeToken<AnswerDatum.Type2> answerDatumType2TypeToken = new TypeToken<>() {};
private static final TypeToken<AnswerDatum.Type4> answerDatumType4TypeToken = new TypeToken<>() {};
private static final Gson gson = new GsonBuilder()
.disableInnerClassSerialization()
.disableHtmlEscaping()
.registerTypeAdapterFactory(DeducingTypeAdapterFactory.create(
typeToken -> AnswerDatum.class.isAssignableFrom(typeToken.getRawType()),
(jsonElement, getTypeAdapter) -> {
if ( jsonElement.isJsonObject() ) {
final JsonObject jsonObject = jsonElement.getAsJsonObject();
// type-1? hopefully...
if ( jsonObject.has("selectionOptionId") ) {
return getTypeAdapter.provide(answerDatumType1TypeToken)
.fromJsonTree(jsonElement);
}
// type-2? hopefully...
if ( jsonObject.has("IsAffiliatedWithSeller") ) {
return getTypeAdapter.provide(answerDatumType2TypeToken)
.fromJsonTree(jsonElement);
}
// type-3? hopefully...
if ( jsonObject.size() == 1 ) {
final Map.Entry<String, JsonElement> onlyEntry = jsonObject.entrySet().iterator().next();
final String key = onlyEntry.getKey();
if ( digitsPattern.matcher(key).matches() ) {
final String value = getTypeAdapter.provide(stringTypeToken)
.fromJsonTree(onlyEntry.getValue());
return AnswerDatum.Type3.of(key, value);
}
}
// type-4? hopefully...
if ( jsonObject.has("liabilityTypeId") ) {
return getTypeAdapter.provide(answerDatumType4TypeToken)
.fromJsonTree(jsonElement);
}
}
throw new UnsupportedOperationException("can't parse: " + jsonElement);
}
))
.create();
private static final TypeToken<Response<List<Answer>>> listOfAnswerResponseType = new TypeToken<>() {};
@Test
public void testEqualsAndHashCode() throws IOException {
final Object expected = Response.of(
"OK",
List.of(
Answer.of(
10,
null
),
Answer.of(
21,
List.of(
AnswerDatum.Type1.of(0, null)
)
),
Answer.of(
45,
List.of(
AnswerDatum.Type2.of(false, null)
)
),
Answer.of(
131,
List.of(
AnswerDatum.Type3.of("2", "Chapter 11"),
AnswerDatum.Type3.of("3", "Chapter 12"),
AnswerDatum.Type3.of("1", "Chapter 7")
)
),
Answer.of(
140,
List.of(
AnswerDatum.Type4.of(2, 200, 2, "Separate Maintenance", "Two"),
AnswerDatum.Type4.of(1, 300, 1, "Child Support", "Three")
)
)
)
);
try (final JsonReader jsonReader = openJsonInput()) {
final Object actual = gson.fromJson(jsonReader, listOfAnswerResponseType.getType());
Assertions.assertEquals(expected, actual);
}
}
@Test
public void testVisitor() throws IOException {
final Object expected = List.of(
"21:0",
"45:false",
"131:2:Chapter 11",
"131:3:Chapter 12",
"131:1:Chapter 7",
"140:Two",
"140:Three"
);
try (final JsonReader jsonReader = openJsonInput()) {
final Response<List<Answer>> response = gson.fromJson(jsonReader, listOfAnswerResponseType.getType());
final List<Answer> data = response.getData();
assert data != null;
final Object actual = data.stream()
.flatMap(answer -> Optional.ofNullable(answer.getAnswerData())
.map(answerData -> answerData.stream()
.map(answerDatum -> answerDatum.accept(new AnswerDatum.Visitor<String>() {
@Override
public String visit(@Nonnull final AnswerDatum.Type1 answerDatum) {
return answer.getId() + ":" + answerDatum.getSelectionOptionId();
}
@Override
public String visit(@Nonnull final AnswerDatum.Type2 answerDatum) {
return answer.getId() + ":" + answerDatum.isAffiliatedWithSeller();
}
@Override
public String visit(@Nonnull final AnswerDatum.Type3 answerDatum) {
return answer.getId() + ":" + answerDatum.getKey() + ':' + answerDatum.getValue();
}
@Override
public String visit(@Nonnull final AnswerDatum.Type4 answerDatum) {
return answer.getId() + ":" + answerDatum.getName();
}
})
)
)
.orElse(Stream.empty())
)
.collect(Collectors.toUnmodifiableList());
Assertions.assertEquals(expected, actual);
}
}
private static JsonReader openJsonInput() throws IOException {
return // ... your code code here ...
}
}
That's it.
I find it pretty difficult and unnecessarily complicated.
Please ask your server-side mates to fix their design for good (note how the current situation makes deserializing harder than it might be when designed well).