Search code examples
javaandroidjsongsonretrofit2

GSON flat down map to other fields


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.


Solution

  • 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.