From bb31ba18ca64cf0d7b3361f1c89e1a2e918e36c7 Mon Sep 17 00:00:00 2001 From: jwilson Date: Sat, 13 Jun 2015 13:06:09 -0400 Subject: [PATCH] Runtime type adapter. Closes https://github.com/square/moshi/issues/27 --- .../squareup/moshi/AdapterMethodsFactory.java | 3 - .../java/com/squareup/moshi/JsonReader.java | 7 +- .../main/java/com/squareup/moshi/Moshi.java | 9 +- .../squareup/moshi/StandardJsonAdapters.java | 80 ++++++++ .../main/java/com/squareup/moshi/Types.java | 12 -- .../squareup/moshi/CircularAdaptersTest.java | 2 +- .../java/com/squareup/moshi/MoshiTest.java | 4 +- .../com/squareup/moshi/ObjectAdapterTest.java | 171 ++++++++++++++++++ 8 files changed, 260 insertions(+), 28 deletions(-) create mode 100644 moshi/src/test/java/com/squareup/moshi/ObjectAdapterTest.java diff --git a/moshi/src/main/java/com/squareup/moshi/AdapterMethodsFactory.java b/moshi/src/main/java/com/squareup/moshi/AdapterMethodsFactory.java index aadc866..901c55c 100644 --- a/moshi/src/main/java/com/squareup/moshi/AdapterMethodsFactory.java +++ b/moshi/src/main/java/com/squareup/moshi/AdapterMethodsFactory.java @@ -24,9 +24,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; -// TODO: support @Nullable -// TODO: path in JsonWriter. - final class AdapterMethodsFactory implements JsonAdapter.Factory { private final List toAdapters; private final List fromAdapters; diff --git a/moshi/src/main/java/com/squareup/moshi/JsonReader.java b/moshi/src/main/java/com/squareup/moshi/JsonReader.java index 272c8e3..f4d125d 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonReader.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonReader.java @@ -1206,11 +1206,8 @@ public final class JsonReader implements Closeable { return c; } } else if (c == '#') { - /* - * Skip a # hash end-of-line comment. The JSON RFC doesn't - * specify this behaviour, but it's required to parse - * existing documents. See http://b/2571423. - */ + // Skip a # hash end-of-line comment. The JSON RFC doesn't specify this behaviour, but it's + // required to parse existing documents. See http://b/2571423. checkLenient(); skipToEndOfLine(); p = 0; diff --git a/moshi/src/main/java/com/squareup/moshi/Moshi.java b/moshi/src/main/java/com/squareup/moshi/Moshi.java index 059ecaf..5cf9428 100644 --- a/moshi/src/main/java/com/squareup/moshi/Moshi.java +++ b/moshi/src/main/java/com/squareup/moshi/Moshi.java @@ -41,11 +41,11 @@ public final class Moshi { this.factories = Collections.unmodifiableList(factories); } + /** Returns a JSON adapter for {@code type}, creating it if necessary. */ public JsonAdapter adapter(Type type) { return adapter(type, Util.NO_ANNOTATIONS); } - /** Returns a JSON adapter for {@code type}, creating it if necessary. */ public JsonAdapter adapter(Class type) { // TODO: cache created JSON adapters. return adapter(type, Util.NO_ANNOTATIONS); @@ -60,10 +60,6 @@ public final class Moshi { return createAdapter(factories.indexOf(skipPast) + 1, type, annotations); } - public JsonAdapter nextAdapter(JsonAdapter.Factory skipPast, Type type) { - return nextAdapter(skipPast, type, Util.NO_ANNOTATIONS); - } - @SuppressWarnings("unchecked") // Factories are required to return only matching JsonAdapters. private JsonAdapter createAdapter( int firstIndex, Type type, Set annotations) { @@ -125,7 +121,10 @@ public final class Moshi { @Override public JsonAdapter create( Type targetType, Set annotations, Moshi moshi) { if (!Util.typesMatch(type, targetType)) return null; + + // TODO: check for an annotations exact match. if (!Util.isAnnotationPresent(annotations, annotation)) return null; + return jsonAdapter; } }); diff --git a/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapters.java b/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapters.java index baaa4ce..9283680 100644 --- a/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapters.java +++ b/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapters.java @@ -18,7 +18,11 @@ package com.squareup.moshi; import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.reflect.Type; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; import java.util.Set; final class StandardJsonAdapters { @@ -43,6 +47,7 @@ final class StandardJsonAdapters { if (type == Long.class) return LONG_JSON_ADAPTER.nullSafe(); if (type == Short.class) return SHORT_JSON_ADAPTER.nullSafe(); if (type == String.class) return STRING_JSON_ADAPTER.nullSafe(); + if (type == Object.class) return new ObjectJsonAdapter(moshi).nullSafe(); Class rawType = Types.getRawType(type); if (rawType.isEnum()) { @@ -189,4 +194,79 @@ final class StandardJsonAdapters { } }; } + + /** + * This adapter is used when the declared type is {@code java.lang.Object}. Typically the runtime + * type is something else, and when encoding JSON this delegates to the runtime type's adapter. + * For decoding (where there is no runtime type to inspect), this uses maps and lists. + * + *

This adapter needs a Moshi instance to look up the appropriate adapter for runtime types as + * they are encountered. + */ + static final class ObjectJsonAdapter extends JsonAdapter { + private final Moshi moshi; + + public ObjectJsonAdapter(Moshi moshi) { + this.moshi = moshi; + } + + @Override public Object fromJson(JsonReader reader) throws IOException { + switch (reader.peek()) { + case BEGIN_ARRAY: + List list = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + list.add(fromJson(reader)); + } + reader.endArray(); + return list; + + case BEGIN_OBJECT: + Map map = new LinkedHashTreeMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + map.put(reader.nextName(), fromJson(reader)); + } + reader.endObject(); + return map; + + case STRING: + return reader.nextString(); + + case NUMBER: + return reader.nextDouble(); + + case BOOLEAN: + return reader.nextBoolean(); + + default: + throw new IllegalStateException("Expected a value but was " + reader.peek() + + " at path " + reader.getPath()); + } + } + + @Override public void toJson(JsonWriter writer, Object value) throws IOException { + Class valueClass = value.getClass(); + if (valueClass == Object.class) { + // Don't recurse infinitely when the runtime type is also Object.class. + writer.beginObject(); + writer.endObject(); + } else { + moshi.adapter(toJsonType(valueClass), Util.NO_ANNOTATIONS).toJson(writer, value); + } + } + + /** + * Returns the type to look up a type adapter for when writing {@code value} to JSON. Without + * this, attempts to emit standard types like `LinkedHashMap` would fail because Moshi doesn't + * provide built-in adapters for implementation types. It knows how to write + * those types, but lacks a mechanism to read them because it doesn't know how to find the + * appropriate constructor. + */ + private Class toJsonType(Class valueClass) { + if (Map.class.isAssignableFrom(valueClass)) return Map.class; + if (Collection.class.isAssignableFrom(valueClass)) return Collection.class; + return valueClass; + } + } } diff --git a/moshi/src/main/java/com/squareup/moshi/Types.java b/moshi/src/main/java/com/squareup/moshi/Types.java index 7f3fe71..73b80cf 100644 --- a/moshi/src/main/java/com/squareup/moshi/Types.java +++ b/moshi/src/main/java/com/squareup/moshi/Types.java @@ -48,18 +48,6 @@ final class Types { return new ParameterizedTypeImpl(null, rawType, typeArguments); } - /** - * Returns a new parameterized type, applying {@code typeArguments} to {@code rawType} and - * enclosed by {@code ownerType}. - */ - public static ParameterizedType newParameterizedTypeWithOwner( - Type ownerType, Type rawType, Type... typeArguments) { - if (ownerType == null) { - throw new NullPointerException("ownerType"); - } - return new ParameterizedTypeImpl(ownerType, rawType, typeArguments); - } - /** Returns an array type whose elements are all instances of {@code componentType}. */ public static GenericArrayType arrayOf(Type componentType) { return new GenericArrayTypeImpl(componentType); diff --git a/moshi/src/test/java/com/squareup/moshi/CircularAdaptersTest.java b/moshi/src/test/java/com/squareup/moshi/CircularAdaptersTest.java index f5652e9..14256f3 100644 --- a/moshi/src/test/java/com/squareup/moshi/CircularAdaptersTest.java +++ b/moshi/src/test/java/com/squareup/moshi/CircularAdaptersTest.java @@ -113,7 +113,7 @@ public final class CircularAdaptersTest { return null; } - final JsonAdapter delegate = moshi.nextAdapter(this, Node.class); + final JsonAdapter delegate = moshi.nextAdapter(this, Node.class, Util.NO_ANNOTATIONS); return new JsonAdapter() { @Override public void toJson(JsonWriter writer, Node value) throws IOException { diff --git a/moshi/src/test/java/com/squareup/moshi/MoshiTest.java b/moshi/src/test/java/com/squareup/moshi/MoshiTest.java index 23c0d5b..433965f 100644 --- a/moshi/src/test/java/com/squareup/moshi/MoshiTest.java +++ b/moshi/src/test/java/com/squareup/moshi/MoshiTest.java @@ -35,7 +35,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.fail; public final class MoshiTest { - @Test public void booleanAdapter() throws Exception { Moshi moshi = new Moshi.Builder().build(); JsonAdapter adapter = moshi.adapter(boolean.class).lenient(); @@ -792,7 +791,8 @@ public final class MoshiTest { if (!type.equals(String.class)) return null; if (!Util.isAnnotationPresent(annotations, Uppercase.class)) return null; - final JsonAdapter stringAdapter = moshi.nextAdapter(this, String.class); + final JsonAdapter stringAdapter + = moshi.nextAdapter(this, String.class, Util.NO_ANNOTATIONS); return new JsonAdapter() { @Override public String fromJson(JsonReader reader) throws IOException { String s = stringAdapter.fromJson(reader); diff --git a/moshi/src/test/java/com/squareup/moshi/ObjectAdapterTest.java b/moshi/src/test/java/com/squareup/moshi/ObjectAdapterTest.java new file mode 100644 index 0000000..eea2669 --- /dev/null +++ b/moshi/src/test/java/com/squareup/moshi/ObjectAdapterTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2015 Square, 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. + */ +package com.squareup.moshi; + +import java.util.AbstractCollection; +import java.util.AbstractList; +import java.util.AbstractMap; +import java.util.AbstractSet; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.Ignore; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public final class ObjectAdapterTest { + @Test public void toJsonUsesRuntimeType() throws Exception { + Delivery delivery = new Delivery(); + delivery.address = "1455 Market St."; + Pizza pizza = new Pizza(); + pizza.diameter = 12; + pizza.extraCheese = true; + delivery.items = Arrays.asList(pizza, "Pepsi"); + + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.toJson(delivery)).isEqualTo("{" + + "\"address\":\"1455 Market St.\"," + + "\"items\":[" + + "{\"diameter\":12,\"extraCheese\":true}," + + "\"Pepsi\"" + + "]" + + "}"); + } + + @Test public void toJsonJavaLangObject() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.toJson(new Object())).isEqualTo("{}"); + } + + @Test public void fromJsonReturnsMapsAndLists() throws Exception { + Map delivery = new LinkedHashMap<>(); + delivery.put("address", "1455 Market St."); + Map pizza = new LinkedHashMap<>(); + pizza.put("diameter", 12d); + pizza.put("extraCheese", true); + delivery.put("items", Arrays.asList(pizza, "Pepsi")); + + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.fromJson("{" + + "\"address\":\"1455 Market St.\"," + + "\"items\":[" + + "{\"diameter\":12,\"extraCheese\":true}," + + "\"Pepsi\"" + + "]" + + "}")).isEqualTo(delivery); + } + + @Test public void fromJsonUsesDoublesForNumbers() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.fromJson("[0, 1]")).isEqualTo(Arrays.asList(0d, 1d)); + } + + @Test public void toJsonCoercesRuntimeTypeForCollections() throws Exception { + Collection collection = new AbstractCollection() { + @Override public Iterator iterator() { + return Collections.singleton("A").iterator(); + } + @Override public int size() { + return 1; + } + }; + + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.toJson(collection)).isEqualTo("[\"A\"]"); + } + + @Test public void toJsonCoercesRuntimeTypeForLists() throws Exception { + List list = new AbstractList() { + @Override public String get(int i) { + return "A"; + } + + @Override public int size() { + return 1; + } + }; + + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.toJson(list)).isEqualTo("[\"A\"]"); + } + + @Test public void toJsonCoercesRuntimeTypeForSets() throws Exception { + Set set = new AbstractSet() { + @Override public Iterator iterator() { + return Collections.singleton("A").iterator(); + } + @Override public int size() { + return 1; + } + }; + + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.toJson(set)).isEqualTo("[\"A\"]"); + } + + @Ignore // We don't support raw maps, like Map. (Even if the keys are strings!) + @Test public void toJsonCoercesRuntimeTypeForMaps() throws Exception { + Map map = new AbstractMap() { + @Override public Set> entrySet() { + return Collections.singletonMap("A", true).entrySet(); + } + }; + + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.toJson(map)).isEqualTo("{\"A\":true}"); + } + + @Test public void toJsonUsesTypeAdapters() throws Exception { + Object dateAdapter = new Object() { + @ToJson Long dateToJson(Date d) { + return d.getTime(); + } + @FromJson Date dateFromJson(Long millis) { + return new Date(millis); + } + }; + Moshi moshi = new Moshi.Builder() + .add(dateAdapter) + .build(); + JsonAdapter adapter = moshi.adapter(Object.class); + assertThat(adapter.toJson(Arrays.asList(new Date(1), new Date(2)))).isEqualTo("[1,2]"); + } + + static class Delivery { + String address; + List items; + } + + static class Pizza { + int diameter; + boolean extraCheese; + } +}