Search code examples
javajsongson

Deserializing a JSON Object with Gson TypeAdapter in an ordering-insensitive manner


Is it possible to achieve both of the following goals?

  • Be able to delegate to default Gson deserializer that invoked our custom implementation.
  • Be unaffected by different order of the keys in the JSON object

Below I describe two possible approaches that only achieve one of them.


The API I'm working with returns either a successful like:

{
  "type": "success",
  "data": {
    "display_name": "Invisible Pink Unicorn",
    "user_email": "user@example.com",
    "user_id": 1234
  }
}

Or an error, like:

{
    "type": "error",
    "data": {
        "error_name": "incorrect_password",
        "error_message": "The username or password you entered is incorrect."
    }
}

The way it's handled at the moment is by registering a TypeAdapter that throws an exception with the given "error_message" if the type is "error":

new GsonBuilder()
    .registerTypeAdapter(User.class, new ContentDeserializer<User>())
    .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
    .create()

public class ContentDeserializer<T> implements JsonDeserializer<T> {
    @Override
    public T deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
        final JsonObject object = json.getAsJsonObject();
        final String type = object.get("type").getAsString();
        final JsonElement data = object.get("data");
        final Gson gson = new Gson();
        if ("error".equals(type)) {
            throw gson.fromJson(data, ApiError.class);
        } else {
            return gson.fromJson(data, typeOfT);
        }
    }
}

Which is neat, because it's pretty succinct and uses a default deserializer to do all the hard work.

But actually it's wrong, because it doesn't use the same Gson to delegate that work to, so it will use a different field naming policy, for example.

To fix that, I wrote a TypeAdapterFactory:

public class UserAdapterFactory implements TypeAdapterFactory {

    @SuppressWarnings("unchecked")
    @Override
    public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
        if (!User.class.isAssignableFrom(type.getRawType())) return null;
        final TypeAdapter<User> userAdapter = (TypeAdapter<User>) gson.getDelegateAdapter(this, type);
        final TypeAdapter<ApiError> apiErrorAdapter = gson.getAdapter(ApiError.class);
        return (TypeAdapter<T>) new Adapter(userAdapter, apiErrorAdapter);
    }

    private static class Adapter extends TypeAdapter<User> {
        private final TypeAdapter<User> userAdapter;
        private final TypeAdapter<ApiError> apiErrorAdapter;

        Adapter(TypeAdapter<User> userAdapter, TypeAdapter<ApiError> apiErrorAdapter) {
            this.userAdapter = userAdapter;
            this.apiErrorAdapter = apiErrorAdapter;
        }

        @Override
        public void write(JsonWriter out, User value) throws IOException {
        }

        @Override
        public User read(JsonReader in) throws IOException {
            User user = null;
            String type = null;
            in.beginObject();
            while (in.hasNext()) {
                switch (in.nextName()) {
                    case "type":
                        type = in.nextString();
                        break;
                    case "data":
                        if ("error".equals(type)) {
                            throw apiErrorAdapter.read(in);
                        } else if ("success".equals(type)) {
                            user = userAdapter.read(in);
                        }
                        break;
                }
            }
            in.endObject();
            return user;
        }
    }
}

Which was a lot more work, but at least lets me delegate to the same Gson configuration.

The problem with this approach is that it breaks when the JSON Object will have a different order:

{
  "data": {
    "display_name": "Invisible Pink Unicorn",
    "user_email": "user@example.com",
    "user_id": 1234
  },
  "type": "success"
}

And I don't see any way around this, because I don't think JsonReader has an option to read the input twice, there's also no way to cache the "data" value in an abstract type like JsonElement to parse after "type" has been encountered.


Solution

  • But actually it's wrong, because it doesn't use the same Gson to delegate that work to, so it will use a different field naming policy, for example.

    Correct. You should use JsonDeserializationContext.

    ... because I don't think JsonReader has an option to read the input twice, there's also no way to cache the "data" value in an abstract type like JsonElement to parse after "type" has been encountered.

    Correct. JsonReader is a stream reader whilst JsonElement is a tree. These are like SAX and DOM from the XML world with all of their respective pros and cons. Streaming readers just read input stream, and you have to buffer/cache intermediate data yourself.

    You can use both approaches for you case, but I would go with JsonDeserializer for its simplicity (assuming you're not going to write a super-fast deserializer).

    I'm not really sure how your User and ApiError relate to each other, but I would go with a common class for both two different kinds of values: real values and errors. It looks like your two classes have a common parent or ancestor, but I'm not really sure how you deal with them at a call-site (perhaps instanceof?). Say, something like this (constructor hidden in order to encapsulate the complexity of the object structure initialization):

    final class Content<T> {
    
        private final boolean isSuccess;
        private final T data;
        private final ApiError error;
    
        private Content(final boolean isSuccess, final T data, final ApiError error) {
            this.isSuccess = isSuccess;
            this.data = data;
            this.error = error;
        }
    
        static <T> Content<T> success(final T data) {
            return new Content<>(true, data, null);
        }
    
        static <T> Content<T> error(final ApiError error) {
            return new Content<>(false, null, error);
        }
    
        boolean isSuccess() {
            return isSuccess;
        }
    
        T getData()
                throws IllegalStateException {
            if ( !isSuccess ) {
                throw new IllegalStateException();
            }
            return data;
        }
    
        ApiError getError()
                throws IllegalStateException {
            if ( isSuccess ) {
                throw new IllegalStateException();
            }
            return error;
        }
    
    }
    

    And both User and ApiError from my perspective (I prefer @SerializedName though to have a stronger control over naming -- but it seems like a matter of habit).

    final class ApiError {
    
        @SuppressWarnings("error_name")
        final String errorName = null;
    
        @SerializedName("error_message")
        final String errorMessage = null;
    
    }
    
    final class User {
    
        @SerializedName("display_name")
        final String displayName = null;
    
        @SerializedName("user_email")
        final String userEmail = null;
    
        @SuppressWarnings("user_id")
        final int userId = Integer.valueOf(0);
    
    }
    

    Next, since tree manipulation is easier, just implement your JSON deserializer:

    final class ContentJsonDeserializer<T>
            implements JsonDeserializer<Content<T>> {
    
        // This deserializer holds no state
        private static final JsonDeserializer<?> contentJsonDeserializer = new ContentJsonDeserializer<>();
    
        private ContentJsonDeserializer() {
        }
    
        // ... and we hide away that fact not letting this one to be instantiated at call sites
        static <T> JsonDeserializer<T> getContentJsonDeserializer() {
            // Narrowing down the @SuppressWarnings scope -- suppressing warnings for entire method may be harmful
            @SuppressWarnings("unchecked")
            final JsonDeserializer<T> contentJsonDeserializer = (JsonDeserializer<T>) ContentJsonDeserializer.contentJsonDeserializer;
            return contentJsonDeserializer;
        }
    
        @Override
        public Content<T> deserialize(final JsonElement jsonElement, final Type type, final JsonDeserializationContext context)
                throws JsonParseException {
            final JsonObject jsonObject = jsonElement.getAsJsonObject();
            final String responseType = jsonObject.getAsJsonPrimitive("type").getAsString();
            switch ( responseType ) {
            case "success":
                return success(context.deserialize(jsonObject.get("data"), getTypeParameter0(type)));
            case "error":
                return error(context.deserialize(jsonObject.get("data"), ApiError.class));
            default:
                throw new JsonParseException(responseType);
            }
        }
    
        // Trying to detect any given type parameterization for its first type parameter
        private static Type getTypeParameter0(final Type type) {
            if ( !(type instanceof ParameterizedType) ) {
                return Object.class;
            }
            return ((ParameterizedType) type).getActualTypeArguments()[0];
        }
    
    }
    

    Demo:

    private static final Gson gson = new GsonBuilder()
            .registerTypeAdapter(Content.class, getContentJsonDeserializer())
            .create();
    
    private static final Type userContent = new TypeToken<Content<User>>() {
    }.getType();
    
    public static void main(final String... args)
            throws IOException {
        for ( final String name : ImmutableList.of("success.json", "error.json", "success-reversed.json", "error-reversed.json") ) {
            try ( final JsonReader jsonReader = getPackageResourceJsonReader(Q44400163.class, name) ) {
                final Content<User> content = gson.fromJson(jsonReader, userContent);
                if ( content.isSuccess() ) {
                    System.out.println("SUCCESS: " + content.getData().displayName);
                } else {
                    System.out.println("ERROR:   " + content.getError().errorMessage);
                }
            }
        }
    }
    

    Output:

    SUCCESS: Invisible Pink Unicorn
    ERROR: The username or password you entered is incorrect.
    SUCCESS: Invisible Pink Unicorn
    ERROR: The username or password you entered is incorrect.

    Now, back to you original question regarding TypeAdapter. As I mentioned above, you can do it using a type adapter as well, but you have to implement the two cases support:

    • Forward case, and you have already implemented it (the best case): read the type property first, and then read the data property according to your real date type. By the way, your TypeAdapter implementation is far from being generic: you had to resolve the real data type and its adapter using Gson.getDelegateAdapter.
    • Reverse case (the worst case): read the data property into a tree view (therefore buffering it into memory) as a JsonElement instance (you have to get the TypeAdapter<JsonElement> from the Gson instance in the create method first), and then, according to the next type property value, read it as a value from the tree using TypeAdapter.fromJsonTree.

    And yes, don't forget to check the parsing state here (handle missing type and data for both cases somehow). As you can see, this introduces variable complexity and performance/memory cost, but it can give you the best performance. You decide.