Sometimes API responses do not fit statically typed languages like Java is very well. I would say that if you're facing a problem to align with a not very convenient response format, you have to write more code if you want it to be convenient for you. And in most cases Gson can help in such cases, but not for free.
Is there any way to model this POJOs and have Gson recognize this structure without having to manually handle this scenario?
No. Gson does not mix objects of different structure, so you still have to tell it your intentions.
Is there any better way to accomplish this?
I guess yes, for both modelling the response and implementing the way how such responses are parsed.
Am I missing any scenario where the deserializer might fail or not work as expected?
It's response format sensitive like all deserializers are, so in general it's good enough, but can be improved.
First off, let's consider you can have two cases only: a regular response and an error. This is a classic case, and it can be modelled like that:
abstract class ApiResponse<T> {
// A bunch of protected methods, no interface needed as we're considering it's a value type and we don't want to expose any of them
protected abstract boolean isSuccessful();
protected abstract T getData()
throws UnsupportedOperationException;
protected abstract List<ApiResponseError> getErrors()
throws UnsupportedOperationException;
// Since we can cover all two cases ourselves, let them all be here in this class
private ApiResponse() {
}
static <T> ApiResponse<T> success(final T data) {
return new SucceededApiResponse<>(data);
}
static <T> ApiResponse<T> failure(final List<ApiResponseError> errors) {
@SuppressWarnings("unchecked")
final ApiResponse<T> castApiResponse = (ApiResponse<T>) new FailedApiResponse(errors);
return castApiResponse;
}
// Despite those three protected methods can be technically public, let's encapsulate the state
final void accept(final IApiResponseConsumer<? super T> consumer) {
if ( isSuccessful() ) {
consumer.acceptSuccess(getData());
} else {
consumer.acceptFailure(getErrors());
}
}
// And make a couple of return-friendly accept methods
final T acceptOrNull() {
if ( !isSuccessful() ) {
return null;
}
return getData();
}
final T acceptOrNull(final Consumer<? super List<ApiResponseError>> errorsConsumer) {
if ( !isSuccessful() ) {
errorsConsumer.accept(getErrors());
return null;
}
return getData();
}
private static final class SucceededApiResponse<T>
extends ApiResponse<T> {
private final T data;
private SucceededApiResponse(final T data) {
this.data = data;
}
@Override
protected boolean isSuccessful() {
return true;
}
@Override
protected T getData() {
return data;
}
@Override
protected List<ApiResponseError> getErrors()
throws UnsupportedOperationException {
throw new UnsupportedOperationException();
}
}
private static final class FailedApiResponse
extends ApiResponse<Void> {
private final List<ApiResponseError> errors;
private FailedApiResponse(final List<ApiResponseError> errors) {
this.errors = errors;
}
@Override
protected boolean isSuccessful() {
return false;
}
@Override
protected List<ApiResponseError> getErrors() {
return errors;
}
@Override
protected Void getData()
throws UnsupportedOperationException {
throw new UnsupportedOperationException();
}
}
}
interface IApiResponseConsumer<T> {
void acceptSuccess(T data);
void acceptFailure(List<ApiResponseError> errors);
}
A trivial mapping for errors:
final class ApiResponseError {
// Since incoming DTO are read-only data bags in most-most cases, even getters may be noise here
// Gson can strip off the final modifier easily
// However, primitive values are inlined by javac, so we're cheating javac with Integer.valueOf
final int code = Integer.valueOf(0);
final String message = null;
}
And some values too:
final class Person {
final String name = null;
final int age = Integer.valueOf(0);
}
The second component is a special type adapter to tell Gson how the API responses must be deserialized. Note that type adapter, unlike JsonSerializer
and JsonDeserializer
work in streaming fashion not requiring the whole JSON model (JsonElement
) to be stored in memory, thus you can save memory and improve the performance for large JSON documents.
final class ApiResponseTypeAdapterFactory
implements TypeAdapterFactory {
// No state, so it can be instantiated once
private static final TypeAdapterFactory apiResponseTypeAdapterFactory = new ApiResponseTypeAdapterFactory();
// Type tokens are effective value types and can be instantiated once per parameterization
private static final TypeToken<List<ApiResponseError>> apiResponseErrorsType = new TypeToken<List<ApiResponseError>>() {
};
private ApiResponseTypeAdapterFactory() {
}
static TypeAdapterFactory getApiResponseTypeAdapterFactory() {
return apiResponseTypeAdapterFactory;
}
@Override
public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
// Is it ApiResponse, a class we can handle?
if ( ApiResponse.class.isAssignableFrom(typeToken.getRawType()) ) {
// Trying to resolve its parameterization
final Type typeParameter = getTypeParameter0(typeToken.getType());
// And asking Gson for the success and failure type adapters to use downstream parsers
final TypeAdapter<?> successTypeAdapter = gson.getDelegateAdapter(this, TypeToken.get(typeParameter));
final TypeAdapter<List<ApiResponseError>> failureTypeAdapter = gson.getDelegateAdapter(this, apiResponseErrorsType);
@SuppressWarnings("unchecked")
final TypeAdapter<T> castTypeAdapter = (TypeAdapter<T>) new ApiResponseTypeAdapter<>(successTypeAdapter, failureTypeAdapter);
return castTypeAdapter;
}
return null;
}
private static Type getTypeParameter0(final Type type) {
// Is this type parameterized?
if ( !(type instanceof ParameterizedType) ) {
// No, it's raw
return Object.class;
}
final ParameterizedType parameterizedType = (ParameterizedType) type;
return parameterizedType.getActualTypeArguments()[0];
}
private static final class ApiResponseTypeAdapter<T>
extends TypeAdapter<ApiResponse<T>> {
private final TypeAdapter<T> successTypeAdapter;
private final TypeAdapter<List<ApiResponseError>> failureTypeAdapter;
private ApiResponseTypeAdapter(final TypeAdapter<T> successTypeAdapter, final TypeAdapter<List<ApiResponseError>> failureTypeAdapter) {
this.successTypeAdapter = successTypeAdapter;
this.failureTypeAdapter = failureTypeAdapter;
}
@Override
public void write(final JsonWriter out, final ApiResponse<T> value)
throws UnsupportedOperationException {
throw new UnsupportedOperationException();
}
@Override
public ApiResponse<T> read(final JsonReader in)
throws IOException {
final JsonToken token = in.peek();
switch ( token ) {
case BEGIN_ARRAY:
// Is it array? Assuming that the responses come as arrays only
// Otherwise a more complex parsing is required probably replaced with JsonDeserializer for some cases
// So reading the next value (entire array) and wrapping it up in an API response with the success-on state
return success(successTypeAdapter.read(in));
case BEGIN_OBJECT:
// Otherwise it's probably an error object?
in.beginObject();
final String name = in.nextName();
if ( !name.equals("errors") ) {
// Let it fail fast, what if a successful response would be here?
throw new MalformedJsonException("Expected errors` but was " + name);
}
// Constructing a failed response object and terminating the error object
final ApiResponse<T> failure = failure(failureTypeAdapter.read(in));
in.endObject();
return failure;
// A matter of style, but just to show the intention explicitly and make IntelliJ IDEA "switch on enums with missing case" to not report warnings here
case END_ARRAY:
case END_OBJECT:
case NAME:
case STRING:
case NUMBER:
case BOOLEAN:
case NULL:
case END_DOCUMENT:
throw new MalformedJsonException("Unexpected token: " + token);
default:
throw new AssertionError(token);
}
}
}
}
Now, how it all can be put together. Note that the responses do not expose their internals explicitly but rather requiring consumers to accept making its privates really encapsulated.
public final class Q43113283 {
private Q43113283() {
}
private static final String SUCCESS_JSON = "[{"name":"John","age":21},{"name":"Sarah","age":32}]";
private static final String FAILURE_JSON = "{"errors":[{"code":1001,"message":"Something blew up"}]}";
private static final Gson gson = new GsonBuilder()
.registerTypeAdapterFactory(getApiResponseTypeAdapterFactory())
.create();
// Assuming that the Type instance is immutable under the hood so it might be cached
private static final Type personsApiResponseType = new TypeToken<ApiResponse<List<Person>>>() {
}.getType();
@SuppressWarnings("unchecked")
public static void main(final String... args) {
final ApiResponse<Iterable<Person>> successfulResponse = gson.fromJson(SUCCESS_JSON, personsApiResponseType);
final ApiResponse<Iterable<Person>> failedResponse = gson.fromJson(FAILURE_JSON, personsApiResponseType);
useFullyCallbackApproach(successfulResponse, failedResponse);
useSemiCallbackApproach(successfulResponse, failedResponse);
useNoCallbackApproach(successfulRe