So I have an Android app which uses Retrofit for API. I have a class like which looks like:
class Foo { String bar; Map<String, String> map; }
When GSON creates a JSON it looks like:
{ "bar":"value", "map": { "key1":"value1" } }
Would it be possible to change JSON serialization to:
{ "bar":"value", "key1":"value1" }
Thanks.
Advertisement
Answer
Here is how Gson could be used to implement the flattening:
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Flatten { }
public final class FlatteningTypeAdapterFactory implements TypeAdapterFactory { private FlatteningTypeAdapterFactory() { } private static final TypeAdapterFactory instance = new FlatteningTypeAdapterFactory(); private static final String[] emptyStringArray = {}; public static TypeAdapterFactory getInstance() { return instance; } @Override @Nullable public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) { final Class<?> rawType = typeToken.getRawType(); // if the class to be serialized or deserialized is known to never contain @Flatten-annotated elements if ( rawType == Object.class || rawType == Void.class || rawType.isPrimitive() || rawType.isArray() || rawType.isInterface() || rawType.isAnnotation() || rawType.isEnum() || rawType.isSynthetic() ) { // then just skip it return null; } // otherwise traverse the given class up to java.lang.Object and collect all of its fields // that are annotated with @Flatten having their names transformed using FieldNamingStrategy // in order to support some Gson built-ins like @SerializedName final FieldNamingStrategy fieldNamingStrategy = gson.fieldNamingStrategy(); final Excluder excluder = gson.excluder(); final Collection<String> propertiesToFlatten = new HashSet<>(); for ( Class<?> c = rawType; c != Object.class; c = c.getSuperclass() ) { for ( final Field f : c.getDeclaredFields() ) { // only support @Flatten-annotated fields that aren't excluded by Gson (static or transient fields, are excluded by default) if ( f.isAnnotationPresent(Flatten.class) && !excluder.excludeField(f, true) ) { // and collect their names as they appear from the Gson perspective (see how @SerializedName works) propertiesToFlatten.add(fieldNamingStrategy.translateName(f)); } } } // if nothing collected, obviously, consider we have nothing to do if ( propertiesToFlatten.isEmpty() ) { return null; } return new TypeAdapter<T>() { private final TypeAdapter<T> delegate = gson.getDelegateAdapter(FlatteningTypeAdapterFactory.this, typeToken); @Override public void write(final JsonWriter out, final T value) throws IOException { // on write, buffer the given value into a JSON tree (it costs but it's easy) final JsonElement outerElement = delegate.toJsonTree(value); if ( outerElement.isJsonObject() ) { final JsonObject outerObject = outerElement.getAsJsonObject(); // and if the intermediate JSON tree is a JSON object, iterate over each its property for ( final String outerPropertyName : propertiesToFlatten ) { @Nullable final JsonElement innerElement = outerObject.get(outerPropertyName); if ( innerElement == null || !innerElement.isJsonObject() ) { continue; } // do the flattening here final JsonObject innerObject = innerElement.getAsJsonObject(); switch ( innerObject.size() ) { case 0: // do nothing obviously break; case 1: { // a special case, takes some less memory and works a bit faster final String propertyNameToMove = innerObject.keySet().iterator().next(); outerObject.add(propertyNameToMove, innerObject.remove(propertyNameToMove)); break; } default: // graft each inner property to the outer object for ( final String propertyNameToMove : innerObject.keySet().toArray(emptyStringArray) ) { outerObject.add(propertyNameToMove, innerObject.remove(propertyNameToMove)); } break; } // detach the object to be flattened because we grafter the result to upper level already outerObject.remove(outerPropertyName); } } // write the result TypeAdapters.JSON_ELEMENT.write(out, outerElement); } @Override public T read(final JsonReader jsonReader) { throw new UnsupportedOperationException(); } } .nullSafe(); } }
I’ve put some comments explaining “whats” and “hows”. But it would be really easy to understand even without being commented. And example unit test:
public final class FlatteningTypeAdapterFactoryTest { private static final Gson gson = new GsonBuilder() .disableHtmlEscaping() .disableInnerClassSerialization() .registerTypeAdapterFactory(FlatteningTypeAdapterFactory.getInstance()) .create(); @Test public void test() { final Object source = new Bar( "foo-value", Map.of("k1", "v1", "k2", "v2", "k3", "v3"), "bar-value", Map.of("k4", "v4") ); final JsonObject expected = new JsonObject(); expected.add("foo", new JsonPrimitive("foo-value")); expected.add("k1", new JsonPrimitive("v1")); expected.add("k2", new JsonPrimitive("v2")); expected.add("k3", new JsonPrimitive("v3")); expected.add("bar", new JsonPrimitive("bar-value")); expected.add("k4", new JsonPrimitive("v4")); final JsonElement actual = gson.toJsonTree(source); Assertions.assertEquals(expected, actual); } private static class Foo { private final String foo; @Flatten private final Map<String, String> fooMap; private Foo(final String foo, final Map<String, String> fooMap) { this.foo = foo; this.fooMap = fooMap; } } private static class Bar extends Foo { private final String bar; @Flatten private final Map<String, String> barMap; private final transient String thisMustNotBeSerialized = "This must not be serialized"; private Bar(final String foo, final Map<String, String> fooMap, final String bar, final Map<String, String> barMap) { super(foo, fooMap); this.bar = bar; this.barMap = barMap; } } }
The code above can be simplified by using Java 8 streams, some Guava or Apache Commons stuff, but as long as you’re on Android, you probably need some pure Java 6 only.