Russell Stewart
Russell Stewart

Reputation: 1960

Retrofit: how to parse a JSON array that combines an array and an object?

I am working on an Android app that uses Retrofit+OkHttp to connect to a REST API and consume JSON data. I'm fairly new to Retrofit, so I'm still learning how it works, but so far it's been pretty smooth. However, I ran into an unusual problem.

One of the endpoints returns data that looks a bit like this:

{
    "success": true,
    "data": [
        [
            {
                "field": "value1",
                ...
            },
            {
                "field": "value2",
                ...
            },
            ...
        ],
        {
           "info": "value",
           "info2": "value",
           "info3": "value",
        }
    ],
    "message": "This is the message."
}

For the most part, this is a pretty standard JSON response. We have a "status" value, a "message", and a "data" value containing the important data being returned. However, there's a problem with the structure of "data". As you can see, it's an array, but it's not just an array of objects. Its first element is the array of values, and its second element is an object.

Gson doesn't like this. If I want to create a POJO to parse this into using Gson, I would expect to do something like this:

public class JsonResponse {

    @SerializedName("success")
    @Expose
    boolean success;

    @SerializedName("message")
    @Expose
    String message;

    @SerializedName("data")
    @Expose
    private ArrayList<MyObject> data = new ArrayList<>();

    public ArrayList<MyObject> getData() {
        return data;
    }

    public void setData(ArrayList<MyObject> data) {
        this.data = data;
    }
}

Where "MyObject" is a Parcelable like this:

public class MyObject implements Parcelable {
      @SerializedName("field")
      @Expose
      boolean field;

      ...
}

But that doesn't work, because "data" isn't just an array of objects; it's an array containing the array of objects and another top-level object.

I can define "data" as "Array", and it does seem to parse the values. But then they come out as generic LinkedTreeMap objects, so I lose the advantages of Gson's JSON->POJO parsing.

Is there an elegant way to handle a mixed array like this in Retrofit/Gson? I'm not responsible for the data coming from the API, so I don't think changing that will be an option.

Upvotes: 1

Views: 4253

Answers (2)

Lyubomyr Shaydariv
Lyubomyr Shaydariv

Reputation: 21145

You seem to have not use List<MyObject> because you have to create at least two kinds of objects: "single" and "multiple" where the latter must implement List<E> to satisfy the array-like interface.

For example. JsonResponse is simpler than what jsonschema2pojo generates:

final class JsonResponse {

    final boolean success = Boolean.valueOf(false);
    final String message = null;
    final List<MyObject> data = null;

}

Now, MyObject can look like this:

abstract class MyObject
        implements Parcelable {

    private MyObject() {
    }

    static <E> MyObject multiple(final List<E> list) {
        return new MultipleObjects<>(list);
    }

    static final class SingleObject
            extends MyObject {

        private SingleObject() {
        }

        final String info = null;
        final String info2 = null;
        final String info3 = null;

    }

    static final class MultipleObjects<E>
            extends MyObject
            implements List<E> {

        private final List<E> list;

        private MultipleObjects(final List<E> list) {
            this.list = list;
        }

        // @formatter:off
        @Override public int size() { return list.size(); }
        @Override public boolean isEmpty() { return list.isEmpty(); }
        @Override public boolean contains(final Object o) { return list.contains(o); }
        @Override public Iterator<E> iterator() { return list.iterator(); }
        @Override public Object[] toArray() { return list.toArray(); }
        @Override public <T> T[] toArray(final T[] a) { return list.toArray(a); }
        @Override public boolean add(final E e) { return list.add(e); }
        @Override public boolean remove(final Object o) { return list.remove(o); }
        @Override public boolean containsAll(final Collection<?> c) { return list.containsAll(c); }
        @Override public boolean addAll(final Collection<? extends E> c) { return list.addAll(c); }
        @Override public boolean addAll(final int index, final Collection<? extends E> c) { return list.addAll(index, c); }
        @Override public boolean removeAll(final Collection<?> c) { return list.removeAll(c); }
        @Override public boolean retainAll(final Collection<?> c) { return list.retainAll(c); }
        @Override public void clear() { list.clear(); }
        @Override public E get(final int index) { return list.get(index); }
        @Override public E set(final int index, final E element) { return list.set(index, element); }
        @Override public void add(final int index, final E element) { list.add(index, element); }
        @Override public E remove(final int index) { return list.remove(index); }
        @Override public int indexOf(final Object o) { return list.indexOf(o); }
        @Override public int lastIndexOf(final Object o) { return list.lastIndexOf(o); }
        @Override public ListIterator<E> listIterator() { return list.listIterator(); }
        @Override public ListIterator<E> listIterator(final int index) { return list.listIterator(index); }
        @Override public List<E> subList(final int fromIndex, final int toIndex) { return list.subList(fromIndex, toIndex); }
        // @formatter:on

    }

}

The class above implements an abstract class that can be implemented in two ways. Note that no public constructors are exposed by design: SingleObject can be deserialized using Gson really easy using the reflective strategy, whilst MultipleObjects is an array-like object that requires some manual construction.

The deserialization part:

final class MyObjectJsonDeserializer
        implements JsonDeserializer<MyObject> {

    private static final JsonDeserializer<MyObject> myObjectJsonDeserializer = new MyObjectJsonDeserializer();

    // You have to detect it more accurately yourself   
    private static final Type genericListType = new TypeToken<List<Object>>() {
    }.getType();

    private MyObjectJsonDeserializer() {
    }

    static JsonDeserializer<MyObject> getMyObjectJsonDeserializer() {
        return myObjectJsonDeserializer;
    }

    @Override
    public MyObject deserialize(final JsonElement jsonElement, final Type type, final JsonDeserializationContext context)
            throws JsonParseException {
        if ( jsonElement.isJsonNull() ) {
            return null;
        }
        if ( jsonElement.isJsonObject() ) {
            // Note that the deserialization is implemented using context,
            // because it makes sure that you are using the Gson instance configuration
            // Simply speaking: do not create gson instances in 99,9% cases
            return context.deserialize(jsonElement, MyObject.SingleObject.class);
        }
        if ( jsonElement.isJsonArray() ) {
            return multiple(context.deserialize(jsonElement, genericListType));
        }
        // Or create a more sophisticated detector... Or redesign your mappigns
        if ( jsonElement.isJsonPrimitive() ) {
            throw new JsonParseException("Cannot parse primitives");
        }
        throw new AssertionError(jsonElement);
    }

}

Example use:

private static final Gson gson = new GsonBuilder()
        .registerTypeAdapter(MyObject.class, getMyObjectJsonDeserializer())
        .create();

public static void main(final String... args)
        throws IOException {
    try ( final JsonReader jsonReader = getPackageResourceJsonReader(Q43946453.class, "polymorphic.json") ) {
        final JsonResponse response = gson.fromJson(jsonReader, JsonResponse.class);
        for ( final MyObject datum : response.data ) {
            System.out.println(datum.getClass().getSimpleName());
        }
    }
}

Output:

MultipleObjects
SingleObject

Upvotes: 1

LukeJanyga
LukeJanyga

Reputation: 1135

There is a way.

Add a custom JsonDeserializer and handle that field manually.

Examples

public static class Deserializer implements JsonDeserializer<JsonResponse> {

    private final Gson gson = new GsonBuilder().create();

    @Override
    public JsonResponse deserialize(JsonElement json, Type typeOfT,
                                      JsonDeserializationContext context)
            throws JsonParseException {
        JsonResponse response = gson.fromJson(json, typeOfT);

        JsonObject jObject = json.getAsJsonObject();
        //Handle jObject here, and parse each object of data array 
        //accordingly to its type - JsonObject/JsonArray
        return response;
    }
}

You need to register it in your global Gson instance, like this:

new GsonBuilder()
            .registerTypeAdapter(JsonResponse.class, new JsonResponse.Deserializer())
            .create();

Upvotes: 2

Related Questions