/*
* Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package com.scvngr.levelup.core.model.factory.json;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import com.scvngr.levelup.core.annotation.LevelUpApi;
import com.scvngr.levelup.core.annotation.LevelUpApi.Contract;
import com.scvngr.levelup.core.annotation.model.NonWrappable;
import com.scvngr.levelup.core.annotation.model.RequiredField;
import com.scvngr.levelup.core.model.MonetaryValue;
import com.scvngr.levelup.core.net.JsonElementRequestBody;
import com.scvngr.levelup.core.util.NullUtils;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import net.jcip.annotations.NotThreadSafe;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
/**
* Loads an LevelUp model from JSON using Gson. When inflating from JSON, this ensures that the
* model's null annotation contract is enforced. Note: to be null-checked, the model must be in or
* below the {@code com.scvngr.levelup} package. Objects from all other packages will be ignored.
*
* @param <T> the type of object to load.
*/
@NotThreadSafe
@LevelUpApi(contract = Contract.INTERNAL)
public class GsonModelFactory<T> {
/**
* The Gson object parser.
*/
@NonNull
private final Gson mGson;
/**
* The type that this class will return.
*/
@NonNull
private final Class<T> mType;
/**
* The type key under which the JSON object can be nested under.
*
* @see {@link AbstractJsonModelFactory}
*/
@NonNull
private final String mTypeKey;
/**
* Constructs a new factory.
*
* @param typeKey the key which the object to parse can be nested under. It will usually be the
* name of the object's type:
*
* <pre>
* { "typeKey": { "field1": "test" } }
* </pre>
*
* When requesting a single object or a list of objects, the object will be nested under
* this type key.
* @param type the type of object to load
* @param wrapped if true, the input JSON must be wrapped with a JSON object that has a typeKey,
* as mentioned above.
*/
public GsonModelFactory(@NonNull final String typeKey, @NonNull final Class<T> type,
final boolean wrapped) {
mType = type;
mTypeKey = typeKey;
final GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES);
gsonBuilder.registerTypeAdapter(MonetaryValue.class, new MonetaryValueTypeAdapter());
gsonBuilder.registerTypeAdapter(Uri.class, new UriTypeAdapter());
gsonBuilder.registerTypeAdapterFactory(new RequiredFieldTypeAdapterFactory());
if (wrapped) {
gsonBuilder.registerTypeAdapterFactory(new WrappedModelTypeAdapterFactory());
}
onBuildFactory(gsonBuilder);
mGson = NullUtils.nonNullContract(gsonBuilder.create());
}
/**
* Override this to register type adapters or anything else of that sort. The default
* implementation is empty.
*
* @param gsonBuilder the Gson builder that will be used for this class.
*/
protected void onBuildFactory(@NonNull final GsonBuilder gsonBuilder) {
// Default implementation does nothing.
}
/**
* Parse a model from the JSON object passed, un-nesting from a root element if necessary.
*
* @param jsonObject the JSON representation of the model to parse.
* @return a model instance.
*/
@NonNull
public final T from(@NonNull final JsonObject jsonObject) {
return createFrom(jsonObject);
}
/**
* Parse a model from the JSON object passed, un-nesting from a root element if necessary.
*
* @param json the JSON representation of the model to parse.
* @return a model instance.
* @throws JsonParseException if there is a problem decoding the JSON structure.
*/
@NonNull
public final T from(@NonNull final String json) throws JsonParseException {
final JsonParser p = new JsonParser();
final JsonElement root = p.parse(json);
if (!root.isJsonObject()) {
throw new JsonSyntaxException(NullUtils.format(
"JSON data must be a JSON object. Type is '%s'.", root.getClass()
.getSimpleName()));
}
return from(NullUtils.nonNullContract(root.getAsJsonObject()));
}
/**
* Parse a list of models from the JSON array passed.
*
* @param jsonArray the {@link JsonArray} containing a list of models.
* @return a {@link List} of model instances.
* @throws JsonParseException if there is a problem decoding the JSON structure.
*/
@NonNull
public final List<T> fromList(@NonNull final JsonArray jsonArray) throws JsonParseException {
final int count = jsonArray.size();
final List<T> objectList = new ArrayList<T>(count);
for (final JsonElement object : jsonArray) {
if (object.isJsonObject()) {
objectList.add(from(NullUtils.nonNullContract(object.getAsJsonObject())));
} else {
throw new JsonSyntaxException(NullUtils.format(
"Element in array was a '%s', not an object.", object.getClass()
.getSimpleName()));
}
}
return objectList;
}
/**
* Parse a list of models from the JSON array passed.
*
* @param json the JSON representation of the list of models to parse.
* @return a {@link List} of model instances.
* @throws JsonParseException if there is a problem decoding the JSON structure.
*/
@NonNull
public final List<T> fromList(@NonNull final String json) throws JsonParseException {
final JsonElement root = new JsonParser().parse(json);
if (!root.isJsonArray()) {
throw new JsonSyntaxException(NullUtils.format(
"JSON data must be a JSON array. Type is '%s'.", root.getClass()
.getSimpleName()));
}
return fromList(NullUtils.nonNullContract(root.getAsJsonArray()));
}
/**
* @param model the model to serialize as JSON.
* @return a string representation of the given {@code model}.
*/
@NonNull
public final String to(@NonNull final T model) {
return NullUtils.nonNullContract(mGson.toJson(model));
}
/**
* @param model the model to serialize as JSON.
* @return a {@link JsonElement} representing the given model.
*/
@NonNull
public final JsonElement toJsonElement(@NonNull final T model) {
return NullUtils.nonNullContract(mGson.toJsonTree(model));
}
/**
* Serializes a model instance to a JSON string representation in a
* {@link com.scvngr.levelup.core.net.RequestBody}.
*
* @param model the model to serialize as JSON.
* @return The JSON representation of the model in a
* {@link com.scvngr.levelup.core.net.RequestBody}.
*/
@NonNull
public final JsonElementRequestBody toRequestSerializer(@NonNull final T model) {
return new JsonElementRequestBody(mGson, NullUtils.nonNullContract(mGson.toJsonTree(model)));
}
/**
* Parse an instance of the model from a {@link JsonObject}.
*
* @param json the JSON representation of the model to parse.
* @return an instance of {@code T} parsed from {@code json}.
* @throws JsonParseException If the model fails to parse.
*/
@NonNull
protected T createFrom(@NonNull final JsonObject json) throws JsonParseException {
return NullUtils.nonNullContract(mGson.fromJson(json, mType));
}
/**
* @return the type key which this object can be nested under.
*/
@NonNull
protected final String getTypeKey() {
return mTypeKey;
}
/**
* Serializer and deserializer for {@link MonetaryValue}s. Values are written and read as JSON
* longs.
*/
private static class MonetaryValueTypeAdapter extends TypeAdapter<MonetaryValue> {
@NonNull
@Override
public MonetaryValue read(final JsonReader reader) throws IOException {
return new MonetaryValue(reader.nextLong());
}
@Override
public void write(final JsonWriter writer, final MonetaryValue value) throws IOException {
writer.value(value.getAmount());
}
}
/**
* A type adapter factory that checks all non-static, final fields marked with the
* {@link RequiredField} annotation for {@code null} values. Only applies to classes belonging
* to packages with names that start with {@code com.scvngr.levelup}.
*/
private static class RequiredFieldTypeAdapterFactory implements TypeAdapterFactory {
@Nullable
@Override
public <T2> TypeAdapter<T2> create(final Gson gson, final TypeToken<T2> type) {
final Class<? super T2> rawType = type.getRawType();
if (!rawType.getName().startsWith("com.scvngr.levelup")) {
return null;
}
final TypeAdapter<T2> delegate =
NullUtils.nonNullContract(gson.getDelegateAdapter(this, type));
return new RequiredFieldTypeAdapter<T2>(delegate, type);
}
}
/**
* A type adapter factory that wraps/unwraps all {@code com.scvngr.levelup} JSON model
* representations in a JSON Object whose key represents the model type.
*/
private static class WrappedModelTypeAdapterFactory implements TypeAdapterFactory {
@Nullable
@Override
public <T2> TypeAdapter<T2> create(final Gson gson, final TypeToken<T2> type) {
final Class<? super T2> rawType = type.getRawType();
if (rawType.isAnnotationPresent(NonWrappable.class)) {
return null;
}
if (!rawType.getName().startsWith("com.scvngr.levelup")) {
return null;
}
final TypeAdapter<T2> delegate =
NullUtils.nonNullContract(gson.getDelegateAdapter(this, type));
return new WrappedModelTypeAdapter<T2>(delegate, type);
}
}
/**
* Wraps another type adapter and checks all non-static, final fields marked with the
* {@link RequiredField} annotation for {@code null} values. This works by inflating the model
* using the delegate adapter and then accessing the generated model's fields.
*
* @param <T2> the model type
*/
private static class RequiredFieldTypeAdapter<T2> extends TypeAdapter<T2> {
@NonNull
private final TypeAdapter<T2> mDelegate;
@NonNull
private final HashSet<String> mRequiredFields = new HashSet<String>();
public RequiredFieldTypeAdapter(@NonNull final TypeAdapter<T2> delegate,
@NonNull final TypeToken<T2> type) {
mDelegate = delegate;
final Class<? super T2> thisType = type.getRawType();
for (final Field field : thisType.getDeclaredFields()) {
final int modifiers = field.getModifiers();
// static and transient are ignored by gson.
if (Modifier.isStatic(modifiers) || !Modifier.isFinal(modifiers)
|| Modifier.isTransient(modifiers)) {
// This type of field is likely not what we should be checking
continue;
}
if (field.isAnnotationPresent(RequiredField.class)) {
mRequiredFields.add(field.getName());
}
}
}
@Nullable
@Override
public T2 read(final JsonReader reader) throws IOException {
final T2 inspected = mDelegate.read(reader);
if (null == inspected) {
return null;
}
final Class<?> r = inspected.getClass();
try {
for (final String field : mRequiredFields) {
try {
final Field f = r.getDeclaredField(field);
try {
f.setAccessible(true);
if (null == f.get(inspected)) {
// This is what it's all about.
throw new IOException(
NullUtils.format("Field %s cannot be null", field));
}
} finally {
f.setAccessible(false);
}
} catch (final NoSuchFieldException e) {
throw new RuntimeException("Unexpected reflection exception", e);
}
}
} catch (final IllegalAccessException | IllegalArgumentException e) {
throw new RuntimeException("Unexpected reflection exception", e);
}
return inspected;
}
@Override
public void write(final JsonWriter writer, final T2 model) throws IOException {
mDelegate.write(writer, model);
}
}
/**
* Unwraps the given type from a JSON type container. This container is a JSON Object with one
* key, the type name. Its value is the desired object.
*
* @param <T2> model type
*/
private static class WrappedModelTypeAdapter<T2> extends TypeAdapter<T2> {
@NonNull
private final TypeAdapter<T2> mDelegate;
@NonNull
private final String mModelRoot;
public WrappedModelTypeAdapter(@NonNull final TypeAdapter<T2> delegate,
@NonNull final TypeToken<T2> type) {
mDelegate = delegate;
mModelRoot =
NullUtils
.nonNullContract(separateCamelCase(
NullUtils.nonNullContract(type.getRawType().getSimpleName()),
"_").toLowerCase(Locale.US));
}
//@formatter:off
/*
* Method below from Gson
*
* Copyright (C) 2008 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//@formatter:on
/**
* Converts the field name that uses camel-case define word separation into separate words
* that are separated by the provided {@code separatorString}.
*
* @param name the name to convert
* @param separator the delimiter
* @return the modified name.
*/
@NonNull
private static String separateCamelCase(@NonNull final String name,
@NonNull final String separator) {
final StringBuilder translation = new StringBuilder();
for (int i = 0; i < name.length(); i++) {
final char character = name.charAt(i);
if (Character.isUpperCase(character) && translation.length() != 0) {
translation.append(separator);
}
translation.append(character);
}
return NullUtils.nonNullContract(translation.toString());
}
@Nullable
@Override
public T2 read(final JsonReader reader) throws IOException {
reader.beginObject();
final String name = reader.nextName();
if (!mModelRoot.equals(name)) {
throw new IOException(NullUtils.format(
"Expecting key '%s' in wrapped model, but was '%s'.", mModelRoot, name));
}
final T2 object = mDelegate.read(reader);
reader.endObject();
return object;
}
@Override
public void write(final JsonWriter writer, final T2 val) throws IOException {
writer.beginObject();
writer.name(mModelRoot);
mDelegate.write(writer, val);
writer.endObject();
}
}
/**
* Type adapter for {@link Uri}s.
*/
private static class UriTypeAdapter extends TypeAdapter<Uri> {
@NonNull
@Override
public Uri read(final JsonReader reader) throws IOException {
return NullUtils.nonNullContract(Uri.parse(reader.nextString()));
}
@Override
public void write(final JsonWriter writer, final Uri value) throws IOException {
writer.value(value.toString());
}
}
}