Skip to content
Advertisement

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.

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.

User contributions licensed under: CC BY-SA
5 People found this is helpful
Advertisement