From 920366e7845f79604a115c56525f000c3e3105b9 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Wed, 26 Jul 2023 19:00:05 +0200 Subject: [PATCH] Throw exception when using classes from other JSON libraries with Gson --- Troubleshooting.md | 253 +++++++- gson/pom.xml | 8 + gson/src/main/java/com/google/gson/Gson.java | 4 + ...upportedJsonLibraryTypeAdapterFactory.java | 69 +++ .../gson/functional/JsonOrgInteropTest.java | 568 ++++++++++++++++++ 5 files changed, 901 insertions(+), 1 deletion(-) create mode 100644 gson/src/main/java/com/google/gson/internal/bind/UnsupportedJsonLibraryTypeAdapterFactory.java create mode 100644 gson/src/test/java/com/google/gson/functional/JsonOrgInteropTest.java diff --git a/Troubleshooting.md b/Troubleshooting.md index 184f19166e..29835e5b21 100644 --- a/Troubleshooting.md +++ b/Troubleshooting.md @@ -52,7 +52,258 @@ module mymodule { } ``` -Or in case this occurs for a field in one of your classes which you did not actually want to serialize or deserialize in the first place, you can exclude that field, see the [user guide](UserGuide.md#excluding-fields-from-serialization-and-deserialization). +## `RuntimeException`: 'Unsupported class from other JSON library: ...' + +**Symptom:** An exception with a message in the form 'Unsupported class from other JSON library: ...' is thrown + +**Reason:** You are using classes from a different JSON library with Gson, and because Gson does not support those classes it throws an exception to avoid unexpected serialization or deserialization results + +**Solution:** The easiest solution is to avoid mixing multiple JSON libraries; Gson provides the classes [`com.google.gson.JsonArray`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/JsonArray.html) and [`com.google.gson.JsonObject`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/JsonObject.html) which you can use instead of the classes from the other JSON library. + +If you cannot switch the classes you are using, see the library-specific solutions below: + +- `org.json.JSONArray`, `org.json.JSONObject` ([JSON-java](https://github.com/stleary/JSON-java), Android) +
+ + (Click to show) + + If you cannot switch to the Gson classes, but the structure of the JSON data does not have to remain the same, you can use the following Gson `TypeAdapterFactory` which you have to [register on a `GsonBuilder`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/GsonBuilder.html#registerTypeAdapterFactory(com.google.gson.TypeAdapterFactory)): + + + ```java + /** + * {@code TypeAdapterFactory} for {@link JSONArray} and {@link JSONObject}. + * + *

This factory is mainly intended for applications which cannot switch to + * Gson's own {@link JsonArray} and {@link JsonObject} classes. + */ + public class JsonOrgAdapterFactory implements TypeAdapterFactory { + private abstract static class JsonOrgAdapter extends TypeAdapter { + private final TypeAdapter jsonElementAdapter; + + public JsonOrgAdapter(TypeAdapter jsonElementAdapter) { + this.jsonElementAdapter = jsonElementAdapter; + } + + protected abstract T readJsonOrgValue(String json); + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + // For correctness convert JSON data to string, then let JSON-java parse it; + // this is pretty inefficient, but makes sure it gets all the corner cases + // of JSON-java correct + // However, unlike JSONObject this will not prevent duplicate member names + JsonElement jsonElement = jsonElementAdapter.read(in); + String json = jsonElementAdapter.toJson(jsonElement); + return readJsonOrgValue(json); + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + + // For correctness let JSON-java perform JSON conversion, then parse again and write + // with Gson; this is pretty inefficient, but makes sure it gets all the corner cases + // of JSON-java correct + String json = value.toString(); + JsonElement jsonElement = jsonElementAdapter.fromJson(json); + jsonElementAdapter.write(out, jsonElement); + } + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + if (rawType != JSONArray.class && rawType != JSONObject.class) { + return null; + } + + TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); + + TypeAdapter adapter; + if (rawType == JSONArray.class) { + adapter = new JsonOrgAdapter(jsonElementAdapter) { + @Override + protected JSONArray readJsonOrgValue(String json) { + return new JSONArray(json); + } + }; + } else { + adapter = new JsonOrgAdapter(jsonElementAdapter) { + @Override + protected JSONObject readJsonOrgValue(String json) { + return new JSONObject(json); + } + }; + } + + // Safe due to type check at beginning of method + @SuppressWarnings("unchecked") + TypeAdapter t = (TypeAdapter) adapter; + return t; + } + } + ``` + + Otherwise, if for backward compatibility you also have to preserve the existing JSON structure which was previously produced by Gson's reflection-based adapter, you can use the following factory: + + + ```java + /** + * Custom {@code TypeAdapterFactory} for {@link JSONArray} and {@link JSONObject}, + * which uses a format similar to what Gson's reflection-based adapter would have + * used. + * + *

This factory is mainly intended for applications which in the past by accident + * relied on Gson's reflection-based adapter for {@code JSONArray} and {@code JSONObject} + * and now have to keep this format for backward compatibility. + */ + public class JsonOrgBackwardCompatibleAdapterFactory implements TypeAdapterFactory { + private abstract static class JsonOrgBackwardCompatibleAdapter extends TypeAdapter { + /** Internal field name used by JSON-java for the respective JSON value class */ + private final String fieldName; + private final TypeAdapter wrappedTypeAdapter; + + public JsonOrgBackwardCompatibleAdapter(String fieldName, TypeAdapter wrappedTypeAdapter) { + this.fieldName = fieldName; + this.wrappedTypeAdapter = wrappedTypeAdapter; + } + + protected abstract T createJsonOrgValue(W wrapped); + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + in.beginObject(); + String name = in.nextName(); + if (!name.equals(fieldName)) { + throw new IllegalArgumentException("Unexpected name '" + name + "', expected '" + fieldName + "' at " + in.getPath()); + } + T value = createJsonOrgValue(wrappedTypeAdapter.read(in)); + in.endObject(); + + return value; + } + + protected abstract W getWrapped(T value); + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + + out.beginObject(); + out.name(fieldName); + wrappedTypeAdapter.write(out, getWrapped(value)); + out.endObject(); + } + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + + // Note: This handling for JSONObject.NULL is not the same as the previous Gson reflection-based + // behavior which would have written `{}`, but this implementation here probably makes more sense + if (rawType == JSONObject.NULL.getClass()) { + return new TypeAdapter() { + @Override + public T read(JsonReader in) throws IOException { + in.nextNull(); + return null; + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + out.nullValue(); + } + }; + } + + if (rawType != JSONArray.class && rawType != JSONObject.class) { + return null; + } + + TypeAdapter adapter; + if (rawType == JSONArray.class) { + TypeAdapter> wrappedAdapter = gson.getAdapter(new TypeToken> () {}); + adapter = new JsonOrgBackwardCompatibleAdapter, JSONArray>("myArrayList", wrappedAdapter) { + @Override + protected JSONArray createJsonOrgValue(List wrapped) { + JSONArray jsonArray = new JSONArray(wrapped.size()); + // Unlike JSONArray(Collection) constructor, putAll does not wrap elements and is therefore closer + // to original Gson reflection-based behavior + jsonArray.putAll(wrapped); + + return jsonArray; + } + + @Override + protected List getWrapped(JSONArray jsonArray) { + // Cannot use JSONArray.toList() because that converts elements + List list = new ArrayList<>(jsonArray.length()); + for (Object element : jsonArray) { + list.add(element); + } + + return list; + } + }; + } else { + TypeAdapter> wrappedAdapter = gson.getAdapter(new TypeToken> () {}); + adapter = new JsonOrgBackwardCompatibleAdapter, JSONObject>("map", wrappedAdapter) { + @Override + protected JSONObject createJsonOrgValue(Map map) { + // JSONObject(Map) constructor wraps elements, so instead put elements separately to be closer + // to original Gson reflection-based behavior + JSONObject jsonObject = new JSONObject(); + for (Entry entry : map.entrySet()) { + jsonObject.put(entry.getKey(), entry.getValue()); + } + + return jsonObject; + } + + @Override + protected Map getWrapped(JSONObject jsonObject) { + // Cannot use JSONObject.toMap() because that converts elements + Map map = new LinkedHashMap<>(jsonObject.length()); + for (String name : jsonObject.keySet()) { + // Use opt(String) because get(String) cannot handle null values + // Most likely null values cannot occur normally though; they would be JSONObject.NULL + map.put(name, jsonObject.opt(name)); + } + + return map; + } + }; + } + + // Safe due to type check at beginning of method + @SuppressWarnings("unchecked") + TypeAdapter t = (TypeAdapter) adapter; + return t; + } + } + ``` + + **Important:** Verify carefully that these `TypeAdapterFactory` classes work as expected for your use case and produce the desired JSON data or parse the JSON data without issues. There might be corner cases where they behave slightly differently than Gson's reflection-based adapter, respectively behave differently than the other JSON library would behave. + + ## Android app not working in Release mode; random property names diff --git a/gson/pom.xml b/gson/pom.xml index 9a48ef4ded..4e4708c0d9 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -49,6 +49,14 @@ 2.20.0 + + + org.json + json + 20230618 + test + + junit junit diff --git a/gson/src/main/java/com/google/gson/Gson.java b/gson/src/main/java/com/google/gson/Gson.java index c6f8508ef1..2d6daa3fa5 100644 --- a/gson/src/main/java/com/google/gson/Gson.java +++ b/gson/src/main/java/com/google/gson/Gson.java @@ -25,6 +25,7 @@ import com.google.gson.internal.bind.ArrayTypeAdapter; import com.google.gson.internal.bind.CollectionTypeAdapterFactory; import com.google.gson.internal.bind.DateTypeAdapter; +import com.google.gson.internal.bind.UnsupportedJsonLibraryTypeAdapterFactory; import com.google.gson.internal.bind.JsonAdapterAnnotationTypeAdapterFactory; import com.google.gson.internal.bind.JsonTreeReader; import com.google.gson.internal.bind.JsonTreeWriter; @@ -340,6 +341,9 @@ public Gson() { this.jsonAdapterFactory = new JsonAdapterAnnotationTypeAdapterFactory(constructorConstructor); factories.add(jsonAdapterFactory); factories.add(TypeAdapters.ENUM_FACTORY); + // Register this right before reflection-based adapter to allow other adapters to handle these + // types (if possible) and to let users specify their own custom adapters + factories.add(UnsupportedJsonLibraryTypeAdapterFactory.INSTANCE); factories.add(new ReflectiveTypeAdapterFactory( constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory, reflectionFilters)); diff --git a/gson/src/main/java/com/google/gson/internal/bind/UnsupportedJsonLibraryTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/UnsupportedJsonLibraryTypeAdapterFactory.java new file mode 100644 index 0000000000..d6a1382c17 --- /dev/null +++ b/gson/src/main/java/com/google/gson/internal/bind/UnsupportedJsonLibraryTypeAdapterFactory.java @@ -0,0 +1,69 @@ +package com.google.gson.internal.bind; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.internal.TroubleshootingGuide; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * {@code TypeAdapterFactory} which throws an exception when trying to serialize or + * deserialize unsupported classes from third-party JSON libraries. + * + *

This is mainly intended as help for users who accidentally mix Gson and non-Gson + * code and are then surprised by unexpected JSON data or issues when trying to + * deserialize the JSON data. + */ +public class UnsupportedJsonLibraryTypeAdapterFactory implements TypeAdapterFactory { + public static final UnsupportedJsonLibraryTypeAdapterFactory INSTANCE = new UnsupportedJsonLibraryTypeAdapterFactory(); + + private UnsupportedJsonLibraryTypeAdapterFactory() { + } + + // Cover JSON classes from popular libraries which might be used by accident with Gson + // Don't have to cover classes which implement `Collection` / `List` or `Map` because + // Gson's built-in adapters for these types should be able to handle them just fine + private static final Set UNSUPPORTED_CLASS_NAMES = new HashSet<>(Arrays.asList( + // https://github.com/stleary/JSON-java and Android + "org.json.JSONArray", + "org.json.JSONObject", + // https://github.com/eclipse-vertx/vert.x + "io.vertx.core.json.JsonArray", + "io.vertx.core.json.JsonObject" + )); + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + final String className = type.getRawType().getName(); + if (!UNSUPPORTED_CLASS_NAMES.contains(className)) { + return null; + } + + // Don't directly throw exception here in case no instance of the class is every serialized + // or deserialized, instead only thrown when actual serialization or deserialization attempt + // occurs + return new TypeAdapter() { + private RuntimeException createException() { + // TODO: Use more specific exception type; also adjust Troubleshooting.md entry then + return new RuntimeException("Unsupported class from other JSON library: " + className + + "\nSee " + TroubleshootingGuide.createUrl("unsupported-json-library-class")); + } + + @Override + public T read(JsonReader in) throws IOException { + throw createException(); + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + throw createException(); + } + }; + } +} diff --git a/gson/src/test/java/com/google/gson/functional/JsonOrgInteropTest.java b/gson/src/test/java/com/google/gson/functional/JsonOrgInteropTest.java new file mode 100644 index 0000000000..bc0cc943a8 --- /dev/null +++ b/gson/src/test/java/com/google/gson/functional/JsonOrgInteropTest.java @@ -0,0 +1,568 @@ +package com.google.gson.functional; + +import static com.google.common.truth.Truth.assertAbout; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.truth.FailureMetadata; +import com.google.common.truth.StandardSubjectBuilder; +import com.google.common.truth.Subject; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONString; +import org.junit.Test; + +/** + * Tests interoperability with https://github.com/stleary/JSON-java ({@code org.json} package). + */ +public class JsonOrgInteropTest { + @Test + public void testNoCustomAdapter() { + Gson gson = new Gson(); + String expectedMessageArray = "Unsupported class from other JSON library: org.json.JSONArray" + + "\nSee https://github.com/google/gson/blob/main/Troubleshooting.md#unsupported-json-library-class"; + String expectedMessageObject = "Unsupported class from other JSON library: org.json.JSONObject" + + "\nSee https://github.com/google/gson/blob/main/Troubleshooting.md#unsupported-json-library-class"; + + // TODO: Adjust these once more specific exception type than RuntimeException is thrown + Exception e = assertThrows(RuntimeException.class, () -> gson.toJson(new JSONArray())); + assertThat(e).hasMessageThat().isEqualTo(expectedMessageArray); + + e = assertThrows(RuntimeException.class, () -> gson.toJson(new JSONObject())); + assertThat(e).hasMessageThat().isEqualTo(expectedMessageObject); + + e = assertThrows(RuntimeException.class, () -> gson.fromJson("[]", JSONArray.class)); + assertThat(e).hasMessageThat().isEqualTo(expectedMessageArray); + + e = assertThrows(RuntimeException.class, () -> gson.fromJson("{}", JSONObject.class)); + assertThat(e).hasMessageThat().isEqualTo(expectedMessageObject); + } + + // Custom classes for equality assertions to avoid using directly JSONArray and JSONObject + // which perform element wrapping and conversion, and because their `toList()` and `toMap()` + // methods also recursively convert values + private static class ExpectedJSONArray { + public final List elements; + + public ExpectedJSONArray(List elements) { + this.elements = elements; + } + + @Override + public String toString() { + return "JSONArray" + elements; + } + } + + private static class ExpectedJSONObject { + public final Map entries; + + public ExpectedJSONObject(Map entries) { + this.entries = entries; + } + + @Override + public String toString() { + return "JSONObject" + entries; + } + } + + private abstract static class JsonOrgBaseSubject extends Subject { + + protected JsonOrgBaseSubject(FailureMetadata metadata, @Nullable Object actual) { + super(metadata, actual); + } + + protected void checkElementValues(String message, Object expected, Object actual) { + StandardSubjectBuilder builder = check(message); + + if (actual instanceof JSONArray) { + builder.about(JSONArraySubject.jsonArrays()).that((JSONArray) actual).isEqualTo(expected); + } else if (actual instanceof JSONObject) { + builder.about(JSONObjectSubject.jsonObjects()).that((JSONObject) actual).isEqualTo(expected); + } else { + builder.that(actual).isEqualTo(expected); + } + } + } + + private static class JSONArraySubject extends JsonOrgBaseSubject { + private final @Nullable JSONArray actual; + + private JSONArraySubject(FailureMetadata failureMetadata, @Nullable JSONArray subject) { + super(failureMetadata, subject); + this.actual = subject; + } + + public static Factory jsonArrays() { + return JSONArraySubject::new; + } + + public static JSONArraySubject assertThat(@Nullable JSONArray actual) { + return assertAbout(JSONArraySubject.jsonArrays()).that(actual); + } + + @Override + public void isEqualTo(Object expected) { + if (!(expected instanceof ExpectedJSONArray)) { + failWithActual("did not expect to be", "a JSONArray"); + } + isNotNull(); + + List expectedElements = ((ExpectedJSONArray) expected).elements; + check("length()").that(actual.length()).isEqualTo(expectedElements.size()); + + for (int i = 0; i < expectedElements.size(); i++) { + Object actualElement = actual.opt(i); + Object expectedElement = expectedElements.get(i); + + checkElementValues("elements[" + i + "]", expectedElement, actualElement); + } + } + } + + private static class JSONObjectSubject extends JsonOrgBaseSubject { + private final @Nullable JSONObject actual; + + private JSONObjectSubject(FailureMetadata failureMetadata, @Nullable JSONObject subject) { + super(failureMetadata, subject); + this.actual = subject; + } + + public static Factory jsonObjects() { + return JSONObjectSubject::new; + } + + public static JSONObjectSubject assertThat(@Nullable JSONObject actual) { + return assertAbout(JSONObjectSubject.jsonObjects()).that(actual); + } + + @Override + public void isEqualTo(Object expected) { + if (!(expected instanceof ExpectedJSONObject)) { + failWithActual("did not expect to be", "a JSONObject"); + } + isNotNull(); + + Map expectedEntries = ((ExpectedJSONObject) expected).entries; + check("length()").that(actual.length()).isEqualTo(expectedEntries.size()); + + for (Entry expectedEntry : expectedEntries.entrySet()) { + String expectedKey = expectedEntry.getKey(); + Object actualValue = actual.opt(expectedKey); + + checkElementValues("entries[" + expectedKey + "]", expectedEntry.getValue(), actualValue); + } + } + } + + + + private static class CustomClass { + @SuppressWarnings("unused") + int i = 1; + + @Override + public String toString() { + return "custom-toString"; + } + } + + private static class CustomJsonStringClass implements JSONString { + @Override + public String toJSONString() { + return "\"custom\""; + } + } + + // Important: Make sure this class is in-sync with the code in Troubleshooting.md + /** + * {@code TypeAdapterFactory} for {@link JSONArray} and {@link JSONObject}. + * + *

This factory is mainly intended for applications which cannot switch to + * Gson's own {@link JsonArray} and {@link JsonObject} classes. + */ + private static class JsonOrgAdapterFactory implements TypeAdapterFactory { + private abstract static class JsonOrgAdapter extends TypeAdapter { + private final TypeAdapter jsonElementAdapter; + + public JsonOrgAdapter(TypeAdapter jsonElementAdapter) { + this.jsonElementAdapter = jsonElementAdapter; + } + + protected abstract T readJsonOrgValue(String json); + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + // For correctness convert JSON data to string, then let JSON-java parse it; + // this is pretty inefficient, but makes sure it gets all the corner cases + // of JSON-java correct + // However, unlike JSONObject this will not prevent duplicate member names + JsonElement jsonElement = jsonElementAdapter.read(in); + String json = jsonElementAdapter.toJson(jsonElement); + return readJsonOrgValue(json); + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + + // For correctness let JSON-java perform JSON conversion, then parse again and write + // with Gson; this is pretty inefficient, but makes sure it gets all the corner cases + // of JSON-java correct + String json = value.toString(); + JsonElement jsonElement = jsonElementAdapter.fromJson(json); + jsonElementAdapter.write(out, jsonElement); + } + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + if (rawType != JSONArray.class && rawType != JSONObject.class) { + return null; + } + + TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); + + TypeAdapter adapter; + if (rawType == JSONArray.class) { + adapter = new JsonOrgAdapter(jsonElementAdapter) { + @Override + protected JSONArray readJsonOrgValue(String json) { + return new JSONArray(json); + } + }; + } else { + adapter = new JsonOrgAdapter(jsonElementAdapter) { + @Override + protected JSONObject readJsonOrgValue(String json) { + return new JSONObject(json); + } + }; + } + + // Safe due to type check at beginning of method + @SuppressWarnings("unchecked") + TypeAdapter t = (TypeAdapter) adapter; + return t; + } + } + + /** + * Tests usage of custom adapters for {@link JSONArray} and {@link JSONObject}. + * + *

This test also verifies that the code shown in {@code Troubleshooting.md} works + * as expected. + */ + @Test + public void testCustomAdapters() { + Gson gson = new GsonBuilder() + .serializeNulls() + .registerTypeAdapterFactory(new JsonOrgAdapterFactory()) + .create(); + + JSONArray array = new JSONArray(Arrays.asList( + null, + JSONObject.NULL, + new CustomClass(), + new CustomJsonStringClass(), + new BigDecimal("123.4"), + true, + new JSONObject(Collections.singletonMap("key", 1)), + new JSONArray(Arrays.asList(2)), + Collections.singletonMap("key", 3), + Arrays.asList(4), + new boolean[] {false} + )); + assertThat(gson.toJson(array)).isEqualTo( + "[null,null,{},\"custom\",123.4,true,{\"key\":1},[2],{\"key\":3},[4],[false]]"); + assertThat(gson.toJson(null, JSONArray.class)).isEqualTo("null"); + + JSONObject object = new JSONObject(); + object.put("1", JSONObject.NULL); + object.put("2", new CustomClass()); + object.put("3", new CustomJsonStringClass()); + object.put("4", new BigDecimal("123.4")); + object.put("5", true); + object.put("6", new JSONObject(Collections.singletonMap("key", 1))); + object.put("7", new JSONArray(Arrays.asList(2))); + object.put("8", Collections.singletonMap("key", 3)); + object.put("9", Arrays.asList(4)); + object.put("10", new boolean[] {false}); + assertThat(gson.toJson(object)).isEqualTo( + "{\"1\":null,\"2\":\"custom-toString\",\"3\":\"custom\",\"4\":123.4,\"5\":true,\"6\":{\"key\":1},\"7\":[2],\"8\":{\"key\":3},\"9\":[4],\"10\":[false]}"); + assertThat(gson.toJson(null, JSONObject.class)).isEqualTo("null"); + + ExpectedJSONArray expectedArray = new ExpectedJSONArray(Arrays.asList( + JSONObject.NULL, + true, + 12, + "string", + new ExpectedJSONObject(Collections.singletonMap("key", 1)), + new ExpectedJSONArray(Arrays.asList(2)) + )); + String json = "[null, true, 12, \"string\", {\"key\": 1}, [2]]"; + JSONArraySubject.assertThat(gson.fromJson(json, JSONArray.class)).isEqualTo(expectedArray); + assertThat(gson.fromJson("null", JSONArray.class)).isNull(); + + Map expectedObject = new HashMap<>(); + expectedObject.put("1", JSONObject.NULL); + expectedObject.put("2", true); + expectedObject.put("3", 12); + expectedObject.put("4", "string"); + expectedObject.put("5", new ExpectedJSONObject(Collections.singletonMap("key", 1))); + expectedObject.put("6", new ExpectedJSONArray(Arrays.asList(2))); + json = "{\"1\": null, \"2\": true, \"3\": 12, \"4\": \"string\", \"5\": {\"key\": 1}, \"6\": [2]}"; + JSONObjectSubject.assertThat(gson.fromJson(json, JSONObject.class)).isEqualTo(new ExpectedJSONObject(expectedObject)); + assertThat(gson.fromJson("null", JSONObject.class)).isNull(); + } + + // Important: Make sure this class is in-sync with the code in Troubleshooting.md + /** + * Custom {@code TypeAdapterFactory} for {@link JSONArray} and {@link JSONObject}, + * which uses a format similar to what Gson's reflection-based adapter would have + * used. + * + *

This factory is mainly intended for applications which in the past by accident + * relied on Gson's reflection-based adapter for {@code JSONArray} and {@code JSONObject} + * and now have to keep this format for backward compatibility. + */ + private static class JsonOrgBackwardCompatibleAdapterFactory implements TypeAdapterFactory { + private abstract static class JsonOrgBackwardCompatibleAdapter extends TypeAdapter { + /** Internal field name used by JSON-java for the respective JSON value class */ + private final String fieldName; + private final TypeAdapter wrappedTypeAdapter; + + public JsonOrgBackwardCompatibleAdapter(String fieldName, TypeAdapter wrappedTypeAdapter) { + this.fieldName = fieldName; + this.wrappedTypeAdapter = wrappedTypeAdapter; + } + + protected abstract T createJsonOrgValue(W wrapped); + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + in.beginObject(); + String name = in.nextName(); + if (!name.equals(fieldName)) { + throw new IllegalArgumentException("Unexpected name '" + name + "', expected '" + fieldName + "' at " + in.getPath()); + } + T value = createJsonOrgValue(wrappedTypeAdapter.read(in)); + in.endObject(); + + return value; + } + + protected abstract W getWrapped(T value); + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + + out.beginObject(); + out.name(fieldName); + wrappedTypeAdapter.write(out, getWrapped(value)); + out.endObject(); + } + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + + // Note: This handling for JSONObject.NULL is not the same as the previous Gson reflection-based + // behavior which would have written `{}`, but this implementation here probably makes more sense + if (rawType == JSONObject.NULL.getClass()) { + return new TypeAdapter() { + @Override + public T read(JsonReader in) throws IOException { + in.nextNull(); + return null; + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + out.nullValue(); + } + }; + } + + if (rawType != JSONArray.class && rawType != JSONObject.class) { + return null; + } + + TypeAdapter adapter; + if (rawType == JSONArray.class) { + TypeAdapter> wrappedAdapter = gson.getAdapter(new TypeToken> () {}); + adapter = new JsonOrgBackwardCompatibleAdapter, JSONArray>("myArrayList", wrappedAdapter) { + @Override + protected JSONArray createJsonOrgValue(List wrapped) { + JSONArray jsonArray = new JSONArray(wrapped.size()); + // Unlike JSONArray(Collection) constructor, putAll does not wrap elements and is therefore closer + // to original Gson reflection-based behavior + jsonArray.putAll(wrapped); + + return jsonArray; + } + + @Override + protected List getWrapped(JSONArray jsonArray) { + // Cannot use JSONArray.toList() because that converts elements + List list = new ArrayList<>(jsonArray.length()); + for (Object element : jsonArray) { + list.add(element); + } + + return list; + } + }; + } else { + TypeAdapter> wrappedAdapter = gson.getAdapter(new TypeToken> () {}); + adapter = new JsonOrgBackwardCompatibleAdapter, JSONObject>("map", wrappedAdapter) { + @Override + protected JSONObject createJsonOrgValue(Map map) { + // JSONObject(Map) constructor wraps elements, so instead put elements separately to be closer + // to original Gson reflection-based behavior + JSONObject jsonObject = new JSONObject(); + for (Entry entry : map.entrySet()) { + jsonObject.put(entry.getKey(), entry.getValue()); + } + + return jsonObject; + } + + @Override + protected Map getWrapped(JSONObject jsonObject) { + // Cannot use JSONObject.toMap() because that converts elements + Map map = new LinkedHashMap<>(jsonObject.length()); + for (String name : jsonObject.keySet()) { + // Use opt(String) because get(String) cannot handle null values + // Most likely null values cannot occur normally though; they would be JSONObject.NULL + map.put(name, jsonObject.opt(name)); + } + + return map; + } + }; + } + + // Safe due to type check at beginning of method + @SuppressWarnings("unchecked") + TypeAdapter t = (TypeAdapter) adapter; + return t; + } + } + + /** + * Tests usage of custom adapters for {@link JSONArray} and {@link JSONObject}, + * which serialize and deserialize these classes in (nearly) the same format which the + * reflection-based adapter would use for them. + * + *

This test also verifies that the code shown in {@code Troubleshooting.md} works + * as expected. + */ + @Test + public void testCustomBackwardCompatibleAdapters() { + Gson gson = new GsonBuilder() + .serializeNulls() + .registerTypeAdapterFactory(new JsonOrgBackwardCompatibleAdapterFactory()) + .create(); + + JSONArray array = new JSONArray(Arrays.asList( + null, + JSONObject.NULL, + new BigDecimal("123.4"), + true, + new JSONObject(Collections.singletonMap("key", 1)), + new JSONArray(Arrays.asList(2)), + Collections.singletonMap("key", 3), + Arrays.asList(4), + new boolean[] {false} + )); + assertThat(gson.toJson(array)).isEqualTo( + "{\"myArrayList\":[null,null,123.4,true,{\"map\":{\"key\":1}},{\"myArrayList\":[2]},{\"map\":{\"key\":3}},{\"myArrayList\":[4]},{\"myArrayList\":[false]}]}"); + assertThat(gson.toJson(null, JSONArray.class)).isEqualTo("null"); + + JSONObject object = new JSONObject(); + object.put("1", JSONObject.NULL); + object.put("2", new BigDecimal("123.4")); + object.put("3", true); + object.put("4", new JSONObject(Collections.singletonMap("key", 1))); + object.put("5", new JSONArray(Arrays.asList(2))); + object.put("6", Collections.singletonMap("key", 3)); + object.put("7", Arrays.asList(4)); + object.put("8", new boolean[] {false}); + assertThat(gson.toJson(object)).isEqualTo( + "{\"map\":{\"1\":null,\"2\":123.4,\"3\":true,\"4\":{\"map\":{\"key\":1}},\"5\":{\"myArrayList\":[2]},\"6\":{\"map\":{\"key\":3}},\"7\":{\"myArrayList\":[4]},\"8\":[false]}}"); + assertThat(gson.toJson(null, JSONObject.class)).isEqualTo("null"); + + ExpectedJSONArray expectedArray = new ExpectedJSONArray(Arrays.asList( + null, + true, + 12.0, + "string", + Collections.singletonMap("key", 1.0), + // Nested JSONObject cannot be restored properly + Collections.singletonMap("map", Collections.singletonMap("key", 2.0)), + Arrays.asList(3.0), + // Nested JSONArray cannot be restored properly + Collections.singletonMap("myArrayList", Arrays.asList(4.0)) + )); + String json = "{\"myArrayList\": [null, true, 12, \"string\", {\"key\": 1}, {\"map\": {\"key\": 2}}, [3], {\"myArrayList\": [4]}]}"; + JSONArraySubject.assertThat(gson.fromJson(json, JSONArray.class)).isEqualTo(expectedArray); + assertThat(gson.fromJson("null", JSONArray.class)).isNull(); + + Map expectedObject = new HashMap<>(); + expectedObject.put("1", true); + expectedObject.put("2", 12.0); + expectedObject.put("3", "string"); + expectedObject.put("4", Collections.singletonMap("key", 1.0)); + // Nested JSONObject cannot be restored properly + expectedObject.put("5", Collections.singletonMap("map", Collections.singletonMap("key", 2.0))); + expectedObject.put("6", Arrays.asList(3.0)); + // Nested JSONArray cannot be restored properly + expectedObject.put("7", Collections.singletonMap("myArrayList", Arrays.asList(4.0))); + json = "{\"map\": {\"1\": true, \"2\": 12, \"3\": \"string\", \"4\": {\"key\": 1}, \"5\": {\"map\": {\"key\": 2}}, \"6\": [3], \"7\": {\"myArrayList\": [4]}}}"; + JSONObjectSubject.assertThat(gson.fromJson(json, JSONObject.class)).isEqualTo(new ExpectedJSONObject(expectedObject)); + assertThat(gson.fromJson("null", JSONObject.class)).isNull(); + } +}