Skip to content

Commit ba32892

Browse files
committed
feat(serialization): Avoid ser/deser roundtripping asymmetry for Object map value type
1 parent ff818b4 commit ba32892

File tree

4 files changed

+157
-16
lines changed

4 files changed

+157
-16
lines changed

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,11 +296,13 @@ public static class Adapter<T> extends TypeAdapter<T> {
296296
private Constructor<?> ctor;
297297
private Map<String, BoundField> boundFields;
298298
private Gson gson;
299+
private TypeAdapter<?> mapValueObjectTypeAdapter;
299300

300301
Adapter(Gson gson, Constructor<?> ctor, Map<String, BoundField> boundFields) {
301302
this.gson = gson;
302303
this.ctor = ctor;
303304
this.boundFields = boundFields;
305+
this.mapValueObjectTypeAdapter = new MapValueObjectTypeAdapter(gson);
304306
}
305307

306308
/*
@@ -326,9 +328,18 @@ public void write(JsonWriter out, T value) throws IOException {
326328
}
327329
}
328330

331+
// Next, we need to dynamically retrieve the additionalPropertyTypeToken field
332+
// from the DynamicModel instance and retrieve its TypeAdapter for deserializing arbitrary properties.
329333
TypeToken<?> mapValueType = getMapValueType(value);
330334
TypeAdapter mapValueTypeAdapter = gson.getAdapter(mapValueType);
331335

336+
// If the map value type is Object, then we need to use our own flavor of the Gson ObjectTypeAdapter.
337+
if (mapValueType.getRawType().equals(Object.class)) {
338+
mapValueTypeAdapter = this.mapValueObjectTypeAdapter;
339+
} else {
340+
mapValueTypeAdapter = gson.getAdapter(mapValueType);
341+
}
342+
332343
// Next, serialize each of the map entries.
333344
for (String key : ((DynamicModel<?>) value).getPropertyNames()) {
334345
out.name(String.valueOf(key));
@@ -364,9 +375,16 @@ public T read(JsonReader in) throws IOException {
364375
}
365376

366377
// Next, we need to dynamically retrieve the additionalPropertyTypeToken field
367-
// from the DynamicModel instance and retrieve its TypeAdapter.
378+
// from the DynamicModel instance and retrieve its TypeAdapter for deserializing arbitrary properties.
368379
TypeToken<?> mapValueType = getMapValueType(instance);
369-
TypeAdapter<?> mapValueTypeAdapter = gson.getAdapter(mapValueType);
380+
TypeAdapter<?> mapValueTypeAdapter;
381+
382+
// If the map value type is Object, then we need to use our own flavor of the Gson ObjectTypeAdapter.
383+
if (mapValueType.getRawType().equals(Object.class)) {
384+
mapValueTypeAdapter = this.mapValueObjectTypeAdapter;
385+
} else {
386+
mapValueTypeAdapter = gson.getAdapter(mapValueType);
387+
}
370388

371389
try {
372390
in.beginObject();
@@ -386,6 +404,8 @@ public T read(JsonReader in) throws IOException {
386404
// Otherwise, it must be an additional property so it belongs in the map.
387405
String key = name;
388406
Object value = mapValueTypeAdapter.read(in);
407+
408+
// Now add the arbitrary property/value to the map.
389409
Object replaced = ((DynamicModel) instance).put(key, value);
390410

391411
// If this new map entry is replacing an existing entry, then we must have a duplicate.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import com.google.gson.Gson;
1818
import com.google.gson.GsonBuilder;
19+
import com.google.gson.internal.LazilyParsedNumber;
20+
import com.google.gson.internal.bind.TypeAdapters;
1921

2022
/**
2123
* Gson singleton to be use when transforming from JSON to Java Objects and vise versa. It handles date formatting and
@@ -52,8 +54,13 @@ private static void registerTypeAdapters(GsonBuilder builder) {
5254
// Date serializer and deserializer
5355
builder.registerTypeAdapter(Date.class, new DateDeserializer());
5456
builder.registerTypeAdapter(Date.class, new DateSerializer());
57+
58+
// Make sure that byte[] ser/deser includes base64 encoding/decoding.
5559
builder.registerTypeAdapter(byte[].class, new ByteArrayTypeAdapter());
5660

61+
// Make sure we serialize LazilyParsedNumber properly to avoid unnecessary decimal places in serialized integers.
62+
builder.registerTypeAdapter(LazilyParsedNumber.class, TypeAdapters.NUMBER);
63+
5764
// Type adapter factory for DynamicModel subclasses.
5865
builder.registerTypeAdapterFactory(new DynamicModelTypeAdapterFactory());
5966
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Copyright 2019 IBM Corp. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
5+
* the License. You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
11+
* specific language governing permissions and limitations under the License.
12+
*/
13+
14+
/*
15+
* Copyright (C) 2011 Google Inc.
16+
*
17+
* Licensed under the Apache License, Version 2.0 (the "License");
18+
* you may not use this file except in compliance with the License.
19+
* You may obtain a copy of the License at
20+
*
21+
* http://www.apache.org/licenses/LICENSE-2.0
22+
*
23+
* Unless required by applicable law or agreed to in writing, software
24+
* distributed under the License is distributed on an "AS IS" BASIS,
25+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
26+
* See the License for the specific language governing permissions and
27+
* limitations under the License.
28+
*/
29+
30+
package com.ibm.cloud.sdk.core.util;
31+
32+
import java.io.IOException;
33+
import java.util.ArrayList;
34+
import java.util.List;
35+
import java.util.Map;
36+
37+
import com.google.gson.Gson;
38+
import com.google.gson.TypeAdapter;
39+
import com.google.gson.internal.LazilyParsedNumber;
40+
import com.google.gson.internal.LinkedTreeMap;
41+
import com.google.gson.internal.bind.ObjectTypeAdapter;
42+
import com.google.gson.stream.JsonReader;
43+
import com.google.gson.stream.JsonToken;
44+
import com.google.gson.stream.JsonWriter;
45+
46+
/**
47+
* This class is adapted from the ObjectTypeAdapter from the GSON project.
48+
* When de-serializing a dynamic model, when we encounter an arbitrary property that is an object,
49+
* we'll use this type adapter instead of GSON's ObjectTypeAdapter.
50+
* This will ensure that the LazilyParsedNumber class is used to represent JSON number fields within
51+
* the object instead of Double.
52+
*
53+
* Adapts types whose static type is only 'Object'. Uses getClass() on
54+
* serialization and a primitive/Map/List on deserialization.
55+
*/
56+
public final class MapValueObjectTypeAdapter extends TypeAdapter<Object> {
57+
private final Gson gson;
58+
59+
MapValueObjectTypeAdapter(Gson gson) {
60+
this.gson = gson;
61+
}
62+
63+
@Override public Object read(JsonReader in) throws IOException {
64+
JsonToken token = in.peek();
65+
switch (token) {
66+
case BEGIN_ARRAY:
67+
List<Object> list = new ArrayList<Object>();
68+
in.beginArray();
69+
while (in.hasNext()) {
70+
list.add(read(in));
71+
}
72+
in.endArray();
73+
return list;
74+
75+
case BEGIN_OBJECT:
76+
Map<String, Object> map = new LinkedTreeMap<String, Object>();
77+
in.beginObject();
78+
while (in.hasNext()) {
79+
map.put(in.nextName(), read(in));
80+
}
81+
in.endObject();
82+
return map;
83+
84+
case STRING:
85+
return in.nextString();
86+
87+
case NUMBER:
88+
// Use LazilyParsedNumber instead of Double for a JSON number value.
89+
return new LazilyParsedNumber(in.nextString());
90+
91+
case BOOLEAN:
92+
return in.nextBoolean();
93+
94+
case NULL:
95+
in.nextNull();
96+
return null;
97+
98+
default:
99+
throw new IllegalStateException();
100+
}
101+
}
102+
103+
@SuppressWarnings("unchecked")
104+
@Override public void write(JsonWriter out, Object value) throws IOException {
105+
if (value == null) {
106+
out.nullValue();
107+
return;
108+
}
109+
110+
TypeAdapter<Object> typeAdapter = (TypeAdapter<Object>) gson.getAdapter(value.getClass());
111+
if (typeAdapter instanceof ObjectTypeAdapter) {
112+
out.beginObject();
113+
out.endObject();
114+
return;
115+
}
116+
117+
typeAdapter.write(out, value);
118+
}
119+
}

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

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ private <T> T deserialize(String json, Class<T> clazz) {
5252
return GsonSingleton.getGson().fromJson(json, clazz);
5353
}
5454

55-
private <T> void testSerDeser(DynamicModel<?> model, Class<T> clazz, boolean trimDecimals) {
55+
private <T> void testSerDeser(DynamicModel<?> model, Class<T> clazz) {
5656
String jsonString = serialize(model);
5757
if (displayOutput) {
5858
System.out.println("serialized " + model.getClass().getSimpleName() + ": " + jsonString);
@@ -61,12 +61,7 @@ private <T> void testSerDeser(DynamicModel<?> model, Class<T> clazz, boolean tri
6161
if (displayOutput) {
6262
System.out.println("de-serialized " + model.getClass().getSimpleName() + ": " + newModel.toString());
6363
}
64-
if (trimDecimals) {
65-
String deserString = newModel.toString().replaceAll(".0", "");
66-
assertEquals(deserString, jsonString);
67-
} else {
68-
assertEquals(newModel, model);
69-
}
64+
assertEquals(newModel, model);
7065
}
7166

7267
private ModelAPFoo createModelAPFoo() {
@@ -164,7 +159,7 @@ private ModelAPObject createModelAPObject() {
164159
public void testModelAPFoo() {
165160
ModelAPFoo model = createModelAPFoo();
166161
// model.put("basketball", "foo");
167-
testSerDeser(model, ModelAPFoo.class, false);
162+
testSerDeser(model, ModelAPFoo.class);
168163
}
169164

170165
@Test
@@ -207,7 +202,7 @@ public void testNullValues() {
207202
ModelAPFoo model = createModelAPFoo();
208203
model.setProp1(null);
209204
// model.put("basketball", "foo");
210-
testSerDeser(model, ModelAPFoo.class, false);
205+
testSerDeser(model, ModelAPFoo.class);
211206
}
212207

213208
@Test(expectedExceptions = {JsonSyntaxException.class})
@@ -242,26 +237,26 @@ public void testCtorExcp() {
242237
public void testModelAPInteger() {
243238
ModelAPInteger model = createModelAPInteger();
244239
// model.put("basketball", "foo");
245-
testSerDeser(model, ModelAPInteger.class, false);
240+
testSerDeser(model, ModelAPInteger.class);
246241
}
247242

248243
@Test
249244
public void testModelAPObject() {
250245
ModelAPObject model = createModelAPObject();
251-
testSerDeser(model, ModelAPObject.class, true);
246+
testSerDeser(model, ModelAPObject.class);
252247
}
253248

254249
@Test
255250
public void testModelAPString() {
256251
ModelAPString model = createModelAPString();
257252
// model.put("basketball", Integer.valueOf(33));
258-
testSerDeser(model, ModelAPString.class, false);
253+
testSerDeser(model, ModelAPString.class);
259254
}
260255

261256
@Test
262257
public void testModelAPFooNullTypeToken() {
263258
ModelAPFooNullTypeToken model = createModelAPFooNullTypeToken();
264-
testSerDeser(model, ModelAPFooNullTypeToken.class, true);
259+
testSerDeser(model, ModelAPFooNullTypeToken.class);
265260
}
266261

267262
@Test
@@ -275,7 +270,7 @@ public void testModelAPFooNull() {
275270
public void testModelAPProtectedCtor() {
276271
ModelAPProtectedCtor model = createModelAPProtectedCtor();
277272
// model.put("basketball", Integer.valueOf(33));
278-
testSerDeser(model, ModelAPProtectedCtor.class, false);
273+
testSerDeser(model, ModelAPProtectedCtor.class);
279274
}
280275

281276
@Test(expectedExceptions = {JsonSyntaxException.class}, expectedExceptionsMessageRegExp="Duplicate key: baseball")

0 commit comments

Comments
 (0)