Mholloway has uploaded a new change for review. https://gerrit.wikimedia.org/r/314428
Change subject: WIP: Add field checking for API responses ...................................................................... WIP: Add field checking for API responses Provides a mechanism for enforcing the API contract on the client side. Specifically, this provides a @RequiredFields annotation that can be used to mark classes for field checking during deserialization. When a class is marked with @RequiredFields, a custom Gson type adapter will be created that will check to ensure that fields designated as required are present in the response, and return NULL if they are absent (rather than a malformed object). This is WIP but I welcome early reviews. Specifically, I'm still on the fence about using a class-level annotation with a list of required field names passed in, as opposed to directly annotating the required fields. The class-level annotation handling is rather more convenient to implement and *maybe* in principle more performant, but is probably clunkier from a consumer perspective than just annotating the fields. TODO: Add tests. Change-Id: Ic4e9d62cb24cb453d1c34e0618ff83479a924fe9 --- A app/src/main/java/org/wikipedia/dataclient/retrofit/FieldCheckingTypeAdapterFactory.java A app/src/main/java/org/wikipedia/dataclient/retrofit/annotations/RequiredFields.java M app/src/main/java/org/wikipedia/feed/image/FeaturedImage.java M app/src/main/java/org/wikipedia/feed/model/CardPageItem.java M app/src/main/java/org/wikipedia/feed/model/Thumbnail.java M app/src/main/java/org/wikipedia/feed/mostread/MostReadArticle.java M app/src/main/java/org/wikipedia/feed/mostread/MostReadArticles.java M app/src/main/java/org/wikipedia/feed/news/NewsItem.java M app/src/main/java/org/wikipedia/json/GsonUtil.java 9 files changed, 112 insertions(+), 0 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/apps/android/wikipedia refs/changes/28/314428/1 diff --git a/app/src/main/java/org/wikipedia/dataclient/retrofit/FieldCheckingTypeAdapterFactory.java b/app/src/main/java/org/wikipedia/dataclient/retrofit/FieldCheckingTypeAdapterFactory.java new file mode 100644 index 0000000..b7f82ac --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/retrofit/FieldCheckingTypeAdapterFactory.java @@ -0,0 +1,70 @@ +package org.wikipedia.dataclient.retrofit; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +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 org.wikipedia.dataclient.retrofit.annotations.RequiredFields; +import org.wikipedia.util.log.L; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; + +// Standing on the shoulders of giants: https://stackoverflow.com/a/11272452 +public class FieldCheckingTypeAdapterFactory<C> implements TypeAdapterFactory { + @SuppressWarnings("unchecked") //use a runtime check to guarantee that 'C' and 'T' are equal + @Nullable + @Override + public final <T> TypeAdapter<T> create(@NonNull Gson gson, @NonNull TypeToken<T> typeToken) { + return typeToken.getRawType().getAnnotation(RequiredFields.class) != null + ? (TypeAdapter<T>) customizeClassAdapter(gson, (TypeToken<C>) typeToken) + : null; + } + + @NonNull + private TypeAdapter<C> customizeClassAdapter(@NonNull Gson gson, @NonNull TypeToken<C> type) { + final TypeAdapter<C> delegate = gson.getDelegateAdapter(this, type); + final TypeAdapter<JsonElement> elementAdapter = gson.getAdapter(JsonElement.class); + return new TypeAdapter<C>() { + @Override public void write(JsonWriter out, C value) throws IOException { + JsonElement tree = delegate.toJsonTree(value); + elementAdapter.write(out, tree); + } + @Override @Nullable public C read(JsonReader in) throws IOException { + JsonElement tree = elementAdapter.read(in); + C deserialized = delegate.fromJsonTree(tree); + return hasRequiredFields(deserialized) ? delegate.fromJsonTree(tree) : null; + } + }; + } + + private boolean hasRequiredFields(@NonNull C deserialized) { + Field[] fields = deserialized.getClass().getDeclaredFields(); + for (Field field : fields) { + //if (field.getAnnotation(Required.class) != null) { + List<String> requiredFields = Arrays.asList(deserialized.getClass() + .getAnnotation(RequiredFields.class).value()); + if (requiredFields.contains(field.getName())) { + try { + field.setAccessible(true); + if (field.get(deserialized) == null) { + L.e("Missing field in JSON: " + field.getName()); + return false; + } + } catch (IllegalArgumentException | IllegalAccessException throwable) { + L.e(throwable); + } + } + } + return true; + } +} diff --git a/app/src/main/java/org/wikipedia/dataclient/retrofit/annotations/RequiredFields.java b/app/src/main/java/org/wikipedia/dataclient/retrofit/annotations/RequiredFields.java new file mode 100644 index 0000000..045663e --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/retrofit/annotations/RequiredFields.java @@ -0,0 +1,24 @@ +package org.wikipedia.dataclient.retrofit.annotations; + + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; + +/** + * Annotate Retrofit POJO classes with this and pass in a list of required fields to enforce their + * presence in order to return an instantiated object. + * + * E.g.: @RequiredFields({"title", "normalizedtitle"}) + * + * Retrieve them with Class.getAnnotation(RequiredFields.class).value() + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(TYPE) +public @interface RequiredFields { + String[] value(); +} diff --git a/app/src/main/java/org/wikipedia/feed/image/FeaturedImage.java b/app/src/main/java/org/wikipedia/feed/image/FeaturedImage.java index fcf26ce..c598b8a 100644 --- a/app/src/main/java/org/wikipedia/feed/image/FeaturedImage.java +++ b/app/src/main/java/org/wikipedia/feed/image/FeaturedImage.java @@ -3,8 +3,10 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import org.wikipedia.dataclient.retrofit.annotations.RequiredFields; import org.wikipedia.feed.model.Thumbnail; +@RequiredFields({"title", "thumbnail", "image"}) public final class FeaturedImage { @SuppressWarnings("unused,NullableProblems") @NonNull private String title; @SuppressWarnings("unused,NullableProblems") @NonNull private Thumbnail thumbnail; @@ -43,6 +45,7 @@ * returns the translation for the request Site language, if available. Otherwise it defaults * to providing the English translation. */ + @RequiredFields({"text", "lang"}) private static class Description { @SuppressWarnings("unused,NullableProblems") @NonNull private String text; @SuppressWarnings("unused,NullableProblems") @NonNull private String lang; diff --git a/app/src/main/java/org/wikipedia/feed/model/CardPageItem.java b/app/src/main/java/org/wikipedia/feed/model/CardPageItem.java index 7a3e0b6..43fbcc5 100644 --- a/app/src/main/java/org/wikipedia/feed/model/CardPageItem.java +++ b/app/src/main/java/org/wikipedia/feed/model/CardPageItem.java @@ -6,10 +6,12 @@ import com.google.gson.annotations.JsonAdapter; +import org.wikipedia.dataclient.retrofit.annotations.RequiredFields; import org.wikipedia.page.Namespace; import org.wikipedia.page.NamespaceTypeAdapter; import org.wikipedia.util.StringUtil; +@RequiredFields({"title", "normalizedtitle"}) public final class CardPageItem { @SuppressWarnings("unused,NullableProblems") @NonNull private String title; @SuppressWarnings("unused,NullableProblems") @NonNull private String normalizedtitle; diff --git a/app/src/main/java/org/wikipedia/feed/model/Thumbnail.java b/app/src/main/java/org/wikipedia/feed/model/Thumbnail.java index 11ad000..984d662 100644 --- a/app/src/main/java/org/wikipedia/feed/model/Thumbnail.java +++ b/app/src/main/java/org/wikipedia/feed/model/Thumbnail.java @@ -3,6 +3,9 @@ import android.net.Uri; import android.support.annotation.NonNull; +import org.wikipedia.dataclient.retrofit.annotations.RequiredFields; + +@RequiredFields({"source"}) public final class Thumbnail { @SuppressWarnings("unused,NullableProblems") @NonNull private Uri source; @SuppressWarnings("unused") private int height; diff --git a/app/src/main/java/org/wikipedia/feed/mostread/MostReadArticle.java b/app/src/main/java/org/wikipedia/feed/mostread/MostReadArticle.java index 29358d8..9ab34b3 100644 --- a/app/src/main/java/org/wikipedia/feed/mostread/MostReadArticle.java +++ b/app/src/main/java/org/wikipedia/feed/mostread/MostReadArticle.java @@ -6,8 +6,10 @@ import com.google.gson.annotations.SerializedName; +import org.wikipedia.dataclient.retrofit.annotations.RequiredFields; import org.wikipedia.feed.model.Thumbnail; +@RequiredFields({"title", "normalizedtitle"}) public final class MostReadArticle { @SerializedName("normalizedtitle") @SuppressWarnings("unused,NullableProblems") @NonNull private String normalizedTitle; @SuppressWarnings("unused,NullableProblems") @NonNull private String title; diff --git a/app/src/main/java/org/wikipedia/feed/mostread/MostReadArticles.java b/app/src/main/java/org/wikipedia/feed/mostread/MostReadArticles.java index cb1e7e3..a6d7f10 100644 --- a/app/src/main/java/org/wikipedia/feed/mostread/MostReadArticles.java +++ b/app/src/main/java/org/wikipedia/feed/mostread/MostReadArticles.java @@ -2,9 +2,12 @@ import android.support.annotation.NonNull; +import org.wikipedia.dataclient.retrofit.annotations.RequiredFields; + import java.util.Date; import java.util.List; +@RequiredFields({"date", "articles"}) public final class MostReadArticles { @SuppressWarnings("unused,NullableProblems") @NonNull private Date date; @SuppressWarnings("unused,NullableProblems") @NonNull private List<MostReadArticle> articles; diff --git a/app/src/main/java/org/wikipedia/feed/news/NewsItem.java b/app/src/main/java/org/wikipedia/feed/news/NewsItem.java index 3980099..6fb58a1 100644 --- a/app/src/main/java/org/wikipedia/feed/news/NewsItem.java +++ b/app/src/main/java/org/wikipedia/feed/news/NewsItem.java @@ -6,6 +6,7 @@ import org.wikipedia.Constants; import org.wikipedia.Site; +import org.wikipedia.dataclient.retrofit.annotations.RequiredFields; import org.wikipedia.feed.model.CardPageItem; import org.wikipedia.news.NewsLinkCard; @@ -14,6 +15,7 @@ import static org.wikipedia.util.ImageUrlUtil.getUrlForSize; +@RequiredFields({"story", "links"}) public final class NewsItem { @SuppressWarnings("unused,NullableProblems") @NonNull private String story; @SuppressWarnings("unused,NullableProblems") @NonNull private List<CardPageItem> links; diff --git a/app/src/main/java/org/wikipedia/json/GsonUtil.java b/app/src/main/java/org/wikipedia/json/GsonUtil.java index fd9f2a9..0a74ff1 100644 --- a/app/src/main/java/org/wikipedia/json/GsonUtil.java +++ b/app/src/main/java/org/wikipedia/json/GsonUtil.java @@ -5,10 +5,13 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import org.wikipedia.dataclient.retrofit.FieldCheckingTypeAdapterFactory; + public final class GsonUtil { private static final String DATE_FORMAT = "MMM dd, yyyy HH:mm:ss"; private static final Gson DEFAULT_GSON = new GsonBuilder() .setDateFormat(DATE_FORMAT) + .registerTypeAdapterFactory(new FieldCheckingTypeAdapterFactory<>()) .registerTypeAdapter(Uri.class, new UriTypeAdapter()) .create(); -- To view, visit https://gerrit.wikimedia.org/r/314428 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: Ic4e9d62cb24cb453d1c34e0618ff83479a924fe9 Gerrit-PatchSet: 1 Gerrit-Project: apps/android/wikipedia Gerrit-Branch: master Gerrit-Owner: Mholloway <mhollo...@wikimedia.org> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits