Skip to content

Commit 467d635

Browse files
committed
fix: support serialization of models w/lists of discriminated subclasses
Fixes arf/planning-sdk-squad#2011 We initially did not implement the "write()" method within the DiscriminatorBasedTypeAdapterFactory because we should never encounter an actual instance of a discriminated base class (they are implemented as abstract base classes). However, in some scenarios Gson will use a type adapter that reflects the DECLARED type rather than the actual RUNTIME type associated with an object. One such scenario is when a class contains a field which is defined as a List of some type X, where X is a discriminated base class (oneOf parent). For this reason, it's possible that the write() method is called. This commit implements the write() method so that it simply delegates to the TypeAdapter associated with the object's RUNTIME type.
1 parent 61a857d commit 467d635

File tree

3 files changed

+184
-15
lines changed

3 files changed

+184
-15
lines changed

src/main/java/com/ibm/cloud/sdk/core/util/DiscriminatorBasedTypeAdapterFactory.java

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,39 @@ public static class Adapter<T> extends TypeAdapter<T> {
177177
}
178178

179179
/**
180-
* We don't actually need to support the serialization of a class that contains the discriminator metadata
181-
* because we should encounter only instances of its subclasses, as opposed to instances of the base class
182-
* itself.
180+
* We wouldn't normally expect Gson to call our write() method because we should never actually have
181+
* instances of a "oneOf" base class (they are implemented as abstract base classes).
182+
* However, in some serialization scenarios, Gson will use the DECLARED type (rather than an object's
183+
* actual RUNTIME type) when trying to find a suitable TypeAdapter. One scenario where this occurs
184+
* is where a class contains a field of type List of X, where X is a oneOf base class with a discriminator.
185+
*
186+
* So in those cases where we're asked to serialize something, we'll just delegate to a TypeAdapter
187+
* that's associated with the object's specific runtime type (i.e. the subclass).
188+
*
183189
*/
190+
@SuppressWarnings({ "unchecked", "rawtypes" })
184191
@Override
185192
public void write(JsonWriter out, T value) throws IOException {
186-
// We should never be asked to serialize a generated class that contains
187-
// the discriminator metadata, but just in case, just throw an exception.
188-
throw new IOException("Serialization of discriminator base classes is not supported");
193+
194+
if (value == null) {
195+
out.nullValue();
196+
return;
197+
}
198+
199+
// Ask Gson for a TypeAdapter for value's runtime type.
200+
TypeAdapter adapter = gson.getAdapter(value.getClass());
201+
202+
// Check to make sure Gson didn't just hand us back "this" as the adapter.
203+
// This shouldn't happen, but some bullet-proofing never hurts.
204+
if (adapter == null || this.getClass().equals(adapter.getClass())) {
205+
// We should never be asked to serialize a generated class that contains
206+
// the discriminator metadata, but just in case, just throw an exception.
207+
throw new IOException(String.format("Serialization of discriminator base class %s is not supported",
208+
value.getClass().getName()));
209+
}
210+
211+
// Delegate to the runtime type's adapter.
212+
adapter.write(out, value);
189213
}
190214

191215
/**

src/main/java/com/ibm/cloud/sdk/core/util/GsonSingleton.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,17 @@ private GsonSingleton() {
3939
* @param prettyPrint if true the JSON will be pretty printed
4040
* @return the {@link Gson}
4141
*/
42-
private static Gson createGson(Boolean prettyPrint) {
42+
private static Gson createGson(boolean prettyPrint, boolean serializeNulls) {
4343
GsonBuilder builder = new GsonBuilder();
4444

4545
registerTypeAdapters(builder);
4646

4747
if (prettyPrint) {
4848
builder.setPrettyPrinting();
4949
}
50+
if (serializeNulls) {
51+
builder.serializeNulls();
52+
}
5053
builder.disableHtmlEscaping();
5154
return builder.create();
5255
}
@@ -76,7 +79,7 @@ private static void registerTypeAdapters(GsonBuilder builder) {
7679
*/
7780
public static synchronized Gson getGson() {
7881
if (gson == null) {
79-
gson = createGson(true);
82+
gson = createGson(true, false);
8083
}
8184
return gson;
8285
}
@@ -88,8 +91,16 @@ public static synchronized Gson getGson() {
8891
*/
8992
public static synchronized Gson getGsonWithoutPrettyPrinting() {
9093
if (gsonWithoutPrinting == null) {
91-
gsonWithoutPrinting = createGson(false);
94+
gsonWithoutPrinting = createGson(false, false);
9295
}
9396
return gsonWithoutPrinting;
9497
}
98+
99+
/**
100+
* Returns an instance of Gson with the "serialize nulls" config option enabled.
101+
* @return
102+
*/
103+
public static Gson getGsonWithSerializeNulls() {
104+
return createGson(false, true);
105+
}
95106
}

src/test/java/com/ibm/cloud/sdk/core/test/model/DiscriminatorSerializationTest.java

Lines changed: 140 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
import static org.testng.Assert.assertNotNull;
1818
import static org.testng.Assert.assertTrue;
1919

20+
import java.util.ArrayList;
21+
import java.util.HashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
2025
import org.testng.annotations.Test;
2126

2227
import com.google.gson.JsonSyntaxException;
@@ -41,6 +46,12 @@
4146
public class DiscriminatorSerializationTest {
4247
private boolean displayOutput = false;
4348

49+
private void log(String msg) {
50+
if (displayOutput) {
51+
System.out.println(msg);
52+
}
53+
}
54+
4455
private String serialize(Object obj) {
4556
return GsonSingleton.getGson().toJson(obj);
4657
}
@@ -51,13 +62,11 @@ private <T> T deserialize(String json, Class<T> clazz) {
5162

5263
private <T> void testSerDeser(Object model, Class<T> baseClass, Class<? extends T> subClass) {
5364
String jsonString = serialize(model);
54-
if (displayOutput) {
55-
System.out.println("serialized " + model.getClass().getSimpleName() + ": " + jsonString);
56-
}
65+
log("serialized " + model.getClass().getSimpleName() + ": " + jsonString);
66+
5767
T newModel = deserialize(jsonString, baseClass);
58-
if (displayOutput) {
59-
System.out.println("de-serialized " + model.getClass().getSimpleName() + ": " + newModel.toString());
60-
}
68+
log("de-serialized " + model.getClass().getSimpleName() + ": " + newModel.toString());
69+
6170
assertEquals(newModel.toString(), model.toString());
6271
assertEquals(newModel.getClass().getName(), subClass.getName());
6372
}
@@ -108,6 +117,131 @@ public void testTruck() {
108117
testSerDeser(model, Vehicle.class, Truck.class);
109118
}
110119

120+
// These classes simulate generated model classes that contain a list/map of discriminated oneOf parents.
121+
public class VehicleHolder {
122+
int size;
123+
List<Vehicle> vehicles;
124+
125+
public VehicleHolder(List<Vehicle> vehicles) {
126+
this.vehicles = vehicles;
127+
this.size = vehicles != null ? vehicles.size() : 0;
128+
}
129+
}
130+
131+
public class AnimalHolder {
132+
int size;
133+
Map<String, Animal> animals;
134+
135+
public AnimalHolder(Map<String, Animal> animals) {
136+
this.animals = animals;
137+
this.size = animals != null ? animals.size() : 0;
138+
}
139+
}
140+
141+
@Test
142+
public void testVehicleList() {
143+
144+
// Create an instance of VehicleHolder that contains a list of Vehicle instances.
145+
List<Vehicle> vehicleList = new ArrayList<>();
146+
vehicleList.add(createTruck("truck"));
147+
vehicleList.add(createCar("Car"));
148+
VehicleHolder expected = new VehicleHolder(vehicleList);
149+
150+
// Make sure we can serialize the model instance containing the list of oneOf parents.
151+
String json = serialize(expected);
152+
assertNotNull(json);
153+
log("Vehicle holder (json): " + json);
154+
155+
VehicleHolder actual = GsonSingleton.getGson().fromJson(json, VehicleHolder.class);
156+
assertNotNull(actual);
157+
assertEquals(actual.size, expected.size);
158+
assertEquals(actual.vehicles, expected.vehicles);
159+
}
160+
161+
@Test
162+
public void testVehiclesNullList() {
163+
VehicleHolder expected = new VehicleHolder(null);
164+
165+
String json = serialize(expected);
166+
assertNotNull(json);
167+
log("Vehicle holder (json): " + json);
168+
169+
VehicleHolder actual = GsonSingleton.getGson().fromJson(json, VehicleHolder.class);
170+
assertNotNull(actual);
171+
assertEquals(actual.size, expected.size);
172+
assertEquals(actual.vehicles, expected.vehicles);
173+
}
174+
175+
@Test
176+
public void testVehiclesNullElement() {
177+
List<Vehicle> vehicles = new ArrayList<>();
178+
vehicles.add(null);
179+
180+
VehicleHolder expected = new VehicleHolder(vehicles);
181+
182+
String json = serialize(expected);
183+
assertNotNull(json);
184+
log("Vehicle holder (json): " + json);
185+
186+
VehicleHolder actual = GsonSingleton.getGson().fromJson(json, VehicleHolder.class);
187+
assertNotNull(actual);
188+
assertEquals(actual.size, expected.size);
189+
assertEquals(actual.vehicles, expected.vehicles);
190+
}
191+
192+
@Test
193+
public void testAnimals() {
194+
195+
// Create an instance of AnimalHolder that contains a map of Animal instances.
196+
Map<String, Animal> animals = new HashMap<>();
197+
animals.put("Fred", createCat("feline"));
198+
animals.put("Elvis", createDog("dog"));
199+
animals.put("Tito", createDog("canine"));
200+
animals.put("Alfred", createIguana("Iguana"));
201+
AnimalHolder expected = new AnimalHolder(animals);
202+
203+
String json = serialize(expected);
204+
assertNotNull(json);
205+
log("Animal holder (json): " + json);
206+
207+
AnimalHolder actual = GsonSingleton.getGson().fromJson(json, AnimalHolder.class);
208+
assertNotNull(actual);
209+
assertEquals(actual.size, expected.size);
210+
assertEquals(actual.animals, expected.animals);
211+
}
212+
213+
@Test
214+
public void testAnimalsNullMap() {
215+
AnimalHolder expected = new AnimalHolder(null);
216+
217+
String json = serialize(expected);
218+
assertNotNull(json);
219+
log("Animal holder (json): " + json);
220+
221+
AnimalHolder actual = GsonSingleton.getGson().fromJson(json, AnimalHolder.class);
222+
assertNotNull(actual);
223+
assertEquals(actual.size, expected.size);
224+
assertEquals(actual.animals, expected.animals);
225+
}
226+
227+
@Test
228+
public void testAnimalsNullElement() {
229+
Map<String, Animal> animals = new HashMap<>();
230+
animals.put("missing_dog", null);
231+
AnimalHolder expected = new AnimalHolder(animals);
232+
233+
// We have to enable "serialize nulls" because Gson's handling of maps seems to be inconsistent with their
234+
// support of lists.
235+
String json = GsonSingleton.getGsonWithSerializeNulls().toJson(expected);
236+
assertNotNull(json);
237+
log("Animal holder (json): " + json);
238+
239+
AnimalHolder actual = GsonSingleton.getGson().fromJson(json, AnimalHolder.class);
240+
assertNotNull(actual);
241+
assertEquals(actual.size, expected.size);
242+
assertEquals(actual.animals, expected.animals);
243+
}
244+
111245
@Test(expectedExceptions = {JsonSyntaxException.class})
112246
void testTruckDiscPropMissing() {
113247
Truck model = createTruck(null);

0 commit comments

Comments
 (0)