Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How To Deserialize Generic Types with Moshi?

Tags:

moshi

Suppose we have this JSON:

[
  {
    "__typename": "Car",
    "id": "123",
    "name": "Toyota Prius",
    "numDoors": 4
  },
  {
    "__typename": "Boat",
    "id": "4567",
    "name": "U.S.S. Constitution",
    "propulsion": "SAIL"
  }
]

(there could be many more elements to the list; this just shows two)

I have Car and Boat POJOs that use a Vehicle base class for the common fields:

public abstract class Vehicle {
  public final String id;
  public final String name;
}

public class Car extends Vehicle {
  public final Integer numDoors;
}

public class Boat extends Vehicle {
  public final String propulsion;
}

The result of parsing this JSON should be a List<Vehicle>. The problem is that no JSON parser is going to know, out of the box, that __typename is how to distinguish a Boat from a Car.

With Gson, I can create a JsonDeserializer<Vehicle> that can examine the __typename field, identify whether this is a Car or Boat, then use deserialize() on the supplied JsonDeserializationContext to parse the particular JSON object into the appropriate type. This works fine.

However, the particular thing that I am building ought to support pluggable JSON parsers, and I thought that I would try Moshi as an alternative parser. However, this particular problem is not covered well in the Moshi documentation at the present time, and I am having difficulty figuring out how best to address it.

The closest analogue to JsonDeserializer<T> is JsonAdapter<T>. However, fromJson() gets passed a JsonReader, which has a destructive API. To find out what the __typename is, I would have to be able to parse everything by hand from the JsonReader events. While I could call adapter() on the Moshi instance to try to invoke existing Moshi parsing logic once I know the proper concrete type, I will have consumed data off of the JsonReader and broken its ability to provide the complete object description anymore.

Another analogue of JsonDeserializer<Vehicle> would be a @FromJson-annotated method that returns a Vehicle. However, I cannot identify a simple thing to pass into the method. The only thing that I can think of is to create yet another POJO representing the union of all possible fields:

public class SemiParsedKindOfVehicle {
  public final String id;
  public final String name;
  public final Integer numDoors;
  public final String propulsion;
  public final String __typename;
}

Then, in theory, if I have @FromJson Vehicle rideLikeTheWind(SemiParsedKindOfVehicle rawVehicle) on a class that I register as a type adapter with Moshi, Moshi might be able to parse my JSON objects into SemiParsedKindOfVehicle instances and call rideLikeTheWind(). In there, I would look up the __typename, identify the type, and completely build the Car or Boat myself, returning that object.

While doable, this is a fair bit more complex than the Gson approach, and my Car/Boat scenario is on the simple end of the possible data structures that I will need to deal with.

Is there another approach to handling this with Moshi that I am missing?

like image 343
CommonsWare Avatar asked Dec 03 '16 17:12

CommonsWare


1 Answers

UPDATE 2019-05-25: The newer answer is your best bet. I am leaving my original solution here for historical reasons.


One thing that I had not taken into account is that you can create a type adapter using a generic type, like Map<String, Object>. Given that, you can create a VehicleAdapter that looks up __typename. It will be responsible for completely populating the Car and Boat instances (or, optionally, delegate that to constructors on Car and Boat that take the Map<String, Object> as input). Hence, this is still not quite as convenient as Gson's approach. Plus, you have to have a do-nothing @ToJson method, as otherwise Moshi rejects your type adapter. But, otherwise, it works, as is demonstrated by this JUnit4 test class:

import com.squareup.moshi.FromJson;
import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
import com.squareup.moshi.ToJson;
import com.squareup.moshi.Types;
import org.junit.Assert;
import org.junit.Test;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;

public class Foo {
  static abstract class Vehicle {
    public String id;
    public String name;
  }

  static class Car extends Vehicle {
    public Integer numDoors;
  }

  static class Boat extends Vehicle {
    public String propulsion;
  }

  static class VehicleAdapter {
    @FromJson
    Vehicle fromJson(Map<String, Object> raw) {
      String typename=raw.get("__typename").toString();
      Vehicle result;

      if (typename.equals("Car")) {
        Car car=new Car();

        car.numDoors=((Double)raw.get("numDoors")).intValue();
        result=car;
      }
      else if (typename.equals("Boat")) {
        Boat boat=new Boat();

        boat.propulsion=raw.get("propulsion").toString();
        result=boat;
      }
      else {
        throw new IllegalStateException("Could not identify __typename: "+typename);
      }

      result.id=raw.get("id").toString();
      result.name=raw.get("name").toString();

      return(result);
    }

    @ToJson
    String toJson(Vehicle vehicle) {
      throw new UnsupportedOperationException("Um, why is this required?");
    }
  }

  static final String JSON="[\n"+
    "  {\n"+
    "    \"__typename\": \"Car\",\n"+
    "    \"id\": \"123\",\n"+
    "    \"name\": \"Toyota Prius\",\n"+
    "    \"numDoors\": 4\n"+
    "  },\n"+
    "  {\n"+
    "    \"__typename\": \"Boat\",\n"+
    "    \"id\": \"4567\",\n"+
    "    \"name\": \"U.S.S. Constitution\",\n"+
    "    \"propulsion\": \"SAIL\"\n"+
    "  }\n"+
    "]";

  @Test
  public void deserializeGeneric() throws IOException {
    Moshi moshi=new Moshi.Builder().add(new VehicleAdapter()).build();
    Type payloadType=Types.newParameterizedType(List.class, Vehicle.class);
    JsonAdapter<List<Vehicle>> jsonAdapter=moshi.adapter(payloadType);
    List<Vehicle> result=jsonAdapter.fromJson(JSON);

    assertEquals(2, result.size());

    assertEquals(Car.class, result.get(0).getClass());

    Car car=(Car)result.get(0);

    assertEquals("123", car.id);
    assertEquals("Toyota Prius", car.name);
    assertEquals((long)4, (long)car.numDoors);

    assertEquals(Boat.class, result.get(1).getClass());

    Boat boat=(Boat)result.get(1);

    assertEquals("4567", boat.id);
    assertEquals("U.S.S. Constitution", boat.name);
    assertEquals("SAIL", boat.propulsion);
  }
}
like image 53
CommonsWare Avatar answered Oct 23 '22 23:10

CommonsWare