From 93eddc9069e4fe783f5ca5d6b48045556b69f315 Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Sun, 10 Aug 2014 13:20:27 -0400 Subject: [PATCH] Initial JsonAdapter structure. This is similar to the way Gson does its type adapters: factories that can delegate and compose. It's different because annotations are fundamental to the design. It's also different because there are no APIs to convert to and from JSON on the central object: instead callers must get the JSON adapter they're interested in and do that there. This is potentially more efficient because applications can hold the adapter they need. --- moshi/pom.xml | 5 + .../java/com/squareup/moshi/JsonAdapter.java | 101 ++++++++ .../java/com/squareup/moshi/JsonReader.java | 6 +- .../main/java/com/squareup/moshi/Moshi.java | 89 +++++++ .../moshi/StandardJsonAdapterFactory.java | 71 ++++++ .../main/java/com/squareup/moshi/Util.java | 44 ++++ .../java/com/squareup/moshi/MoshiTest.java | 225 ++++++++++++++++++ pom.xml | 6 + 8 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 moshi/src/main/java/com/squareup/moshi/JsonAdapter.java create mode 100644 moshi/src/main/java/com/squareup/moshi/Moshi.java create mode 100644 moshi/src/main/java/com/squareup/moshi/StandardJsonAdapterFactory.java create mode 100644 moshi/src/main/java/com/squareup/moshi/Util.java create mode 100644 moshi/src/test/java/com/squareup/moshi/MoshiTest.java diff --git a/moshi/pom.xml b/moshi/pom.xml index 051d930..572f35a 100644 --- a/moshi/pom.xml +++ b/moshi/pom.xml @@ -22,5 +22,10 @@ junit test + + org.assertj + assertj-core + test + diff --git a/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java b/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java new file mode 100644 index 0000000..8197a62 --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2014 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.io.IOException; +import java.io.StringWriter; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; + +/** + * Converts Java values to JSON, and JSON values to Java. + */ +public abstract class JsonAdapter { + public abstract T fromJson(JsonReader reader) throws IOException; + + public final T fromJson(String string) throws IOException { + return fromJson(new JsonReader(string)); + } + + public abstract void toJson(JsonWriter writer, T value) throws IOException; + + public final String toJson(T value) throws IOException { + StringWriter stringWriter = new StringWriter(); + toJson(new JsonWriter(stringWriter), value); + return stringWriter.toString(); + } + + /** + * Returns a JSON adapter equal to this JSON adapter, but with support for reading and writing + * nulls. + */ + public final JsonAdapter nullSafe() { + final JsonAdapter delegate = this; + return new JsonAdapter() { + @Override public T fromJson(JsonReader reader) throws IOException { + if (reader.peek() == JsonToken.NULL) { + return reader.nextNull(); + } else { + return delegate.fromJson(reader); + } + } + @Override public void toJson(JsonWriter writer, T value) throws IOException { + if (value == null) { + writer.nullValue(); + } else { + delegate.toJson(writer, value); + } + } + }; + } + + /** Returns a JSON adapter equal to this JSON adapter, but is lenient when reading and writing. */ + public final JsonAdapter lenient() { + final JsonAdapter delegate = this; + return new JsonAdapter() { + @Override public T fromJson(JsonReader reader) throws IOException { + boolean lenient = reader.isLenient(); + reader.setLenient(true); + try { + return delegate.fromJson(reader); + } finally { + reader.setLenient(lenient); + } + } + @Override public void toJson(JsonWriter writer, T value) throws IOException { + boolean lenient = writer.isLenient(); + writer.setLenient(true); + try { + delegate.toJson(writer, value); + } finally { + writer.setLenient(lenient); + } + } + }; + } + + public interface Factory { + /** + * Attempts to create an adapter for {@code type} annotated with {@code annotations}. This + * returns the adapter if one was created, or null if this factory isn't capable of creating + * such an adapter. + * + *

Implementations may use to {@link Moshi#adapter} to compose adapters of other types, or + * {@link Moshi#nextAdapter} to delegate to the underlying adapter of the same type. + */ + JsonAdapter create(Type type, AnnotatedElement annotations, Moshi moshi); + } +} diff --git a/moshi/src/main/java/com/squareup/moshi/JsonReader.java b/moshi/src/main/java/com/squareup/moshi/JsonReader.java index af106ca..d84026c 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonReader.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonReader.java @@ -214,7 +214,6 @@ public class JsonReader implements Closeable { private static final int NUMBER_CHAR_EXP_SIGN = 6; private static final int NUMBER_CHAR_EXP_DIGIT = 7; - /** True to accept non-spec compliant JSON */ private boolean lenient = false; @@ -843,12 +842,12 @@ public class JsonReader implements Closeable { /** * Consumes the next token from the JSON stream and asserts that it is a - * literal null. + * literal null. Returns null. * * @throws IllegalStateException if the next token is not null or if this * reader is closed. */ - public void nextNull() throws IOException { + public T nextNull() throws IOException { int p = peeked; if (p == PEEKED_NONE) { p = doPeek(); @@ -856,6 +855,7 @@ public class JsonReader implements Closeable { if (p == PEEKED_NULL) { peeked = PEEKED_NONE; pathIndices[stackSize - 1]++; + return null; } else { throw new IllegalStateException("Expected null but was " + peek() + " at path " + getPath()); diff --git a/moshi/src/main/java/com/squareup/moshi/Moshi.java b/moshi/src/main/java/com/squareup/moshi/Moshi.java new file mode 100644 index 0000000..08e93d0 --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/Moshi.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2014 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.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Coordinates binding between JSON values and Java objects. + */ +public final class Moshi { + private final List factories; + + private Moshi(Builder builder) { + List factories = new ArrayList(); + factories.addAll(builder.factories); + factories.add(new StandardJsonAdapterFactory()); + this.factories = Collections.unmodifiableList(factories); + } + + 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); + } + + public JsonAdapter adapter(Type type, AnnotatedElement annotations) { + // TODO: support re-entrant calls. + return createAdapter(0, type, annotations); + } + + public JsonAdapter nextAdapter(JsonAdapter.Factory skipPast, Type type, + AnnotatedElement annotations) { + return createAdapter(factories.indexOf(skipPast) + 1, type, annotations); + } + + @SuppressWarnings("unchecked") // Factories are required to return only matching JsonAdapters. + private JsonAdapter createAdapter( + int firstIndex, Type type, AnnotatedElement annotations) { + for (int i = firstIndex, size = factories.size(); i < size; i++) { + JsonAdapter result = factories.get(i).create(type, annotations, this); + if (result != null) return (JsonAdapter) result; + } + throw new IllegalArgumentException("no JsonAdapter for " + type); + } + + public static final class Builder { + private final List factories = new ArrayList(); + + public Builder add(final Type type, final JsonAdapter jsonAdapter) { + return add(new JsonAdapter.Factory() { + @Override public JsonAdapter create( + Type targetType, AnnotatedElement annotations, Moshi moshi) { + return Util.typesMatch(type, targetType) ? jsonAdapter : null; + } + }); + } + + public Builder add(JsonAdapter.Factory jsonAdapter) { + // TODO: define precedence order. Last added wins? First added wins? + factories.add(jsonAdapter); + return this; + } + + public Moshi build() { + return new Moshi(this); + } + } +} diff --git a/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapterFactory.java b/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapterFactory.java new file mode 100644 index 0000000..73261bf --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapterFactory.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2014 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.io.IOException; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; + +final class StandardJsonAdapterFactory implements JsonAdapter.Factory { + static final JsonAdapter BOOLEAN_JSON_ADAPTER = new JsonAdapter() { + @Override public Boolean fromJson(JsonReader reader) throws IOException { + return reader.nextBoolean(); + } + @Override public void toJson(JsonWriter writer, Boolean value) throws IOException { + writer.value(value); + } + }; + + static final JsonAdapter DOUBLE_JSON_ADAPTER = new JsonAdapter() { + @Override public Double fromJson(JsonReader reader) throws IOException { + return reader.nextDouble(); + } + @Override public void toJson(JsonWriter writer, Double value) throws IOException { + writer.value(value); + } + }; + + static final JsonAdapter INTEGER_JSON_ADAPTER = new JsonAdapter() { + @Override public Integer fromJson(JsonReader reader) throws IOException { + return reader.nextInt(); + } + @Override public void toJson(JsonWriter writer, Integer value) throws IOException { + writer.value(value.intValue()); + } + }; + + static final JsonAdapter STRING_JSON_ADAPTER = new JsonAdapter() { + @Override public String fromJson(JsonReader reader) throws IOException { + return reader.nextString(); + } + @Override public void toJson(JsonWriter writer, String value) throws IOException { + writer.value(value); + } + }; + + @Override public JsonAdapter create( + Type type, AnnotatedElement annotations, Moshi moshi) { + // TODO: support all 8 primitive types. + if (type == boolean.class) return BOOLEAN_JSON_ADAPTER; + if (type == double.class) return DOUBLE_JSON_ADAPTER; + if (type == int.class) return INTEGER_JSON_ADAPTER; + if (type == Boolean.class) return BOOLEAN_JSON_ADAPTER.nullSafe(); + if (type == Double.class) return DOUBLE_JSON_ADAPTER.nullSafe(); + if (type == Integer.class) return INTEGER_JSON_ADAPTER.nullSafe(); + if (type == String.class) return STRING_JSON_ADAPTER.nullSafe(); + return null; + } +} diff --git a/moshi/src/main/java/com/squareup/moshi/Util.java b/moshi/src/main/java/com/squareup/moshi/Util.java new file mode 100644 index 0000000..3ad3faf --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/Util.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014 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.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; + +final class Util { + public static final Annotation[] EMPTY_ANNOTATIONS_ARRAY = new Annotation[0]; + + public static final AnnotatedElement NO_ANNOTATIONS = new AnnotatedElement() { + @Override public boolean isAnnotationPresent(Class aClass) { + return false; + } + @Override public T getAnnotation(Class tClass) { + return null; + } + @Override public Annotation[] getAnnotations() { + return EMPTY_ANNOTATIONS_ARRAY; + } + @Override public Annotation[] getDeclaredAnnotations() { + return EMPTY_ANNOTATIONS_ARRAY; + } + }; + + public static boolean typesMatch(Type pattern, Type candidate) { + // TODO: permit raw types (like Set.class) to match non-raw candidates (like Set). + return pattern.equals(candidate); + } +} diff --git a/moshi/src/test/java/com/squareup/moshi/MoshiTest.java b/moshi/src/test/java/com/squareup/moshi/MoshiTest.java new file mode 100644 index 0000000..86719c8 --- /dev/null +++ b/moshi/src/test/java/com/squareup/moshi/MoshiTest.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2014 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.io.IOException; +import java.lang.annotation.Retention; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; +import java.util.Locale; +import org.junit.Test; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; + +public final class MoshiTest { + /** No nulls for int.class. */ + @Test public void intAdapter() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(int.class).lenient(); + assertThat(adapter.fromJson("1")).isEqualTo(1); + assertThat(adapter.toJson(2)).isEqualTo("2"); + + try { + adapter.fromJson("null"); + fail(); + } catch (IllegalStateException expected) { + assertThat(expected.getMessage()).isEqualTo("Expected an int but was NULL at path $"); + } + + try { + moshi.adapter(int.class).toJson(null); + fail(); + } catch (NullPointerException expected) { + } + } + + /** Moshi supports nulls for Integer.class. */ + @Test public void integerAdapter() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(Integer.class).lenient(); + assertThat(adapter.fromJson("1")).isEqualTo(1); + assertThat(adapter.toJson(2)).isEqualTo("2"); + assertThat(adapter.fromJson("null")).isEqualTo(null); + assertThat(adapter.toJson(null)).isEqualTo("null"); + } + + @Test public void stringAdapter() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(String.class).lenient(); + assertThat(adapter.fromJson("\"a\"")).isEqualTo("a"); + assertThat(adapter.toJson("b")).isEqualTo("\"b\""); + assertThat(adapter.fromJson("null")).isEqualTo(null); + assertThat(adapter.toJson(null)).isEqualTo("null"); + } + + @Test public void customJsonAdapter() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(Pizza.class, new PizzaAdapter()) + .build(); + + JsonAdapter jsonAdapter = moshi.adapter(Pizza.class); + assertThat(jsonAdapter.toJson(new Pizza(15, true))) + .isEqualTo("{\"size\":15,\"extra cheese\":true}"); + assertThat(jsonAdapter.fromJson("{\"extra cheese\":true,\"size\":18}")) + .isEqualTo(new Pizza(18, true)); + } + + @Test public void composingJsonAdapterFactory() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(new MealDealAdapterFactory()) + .add(Pizza.class, new PizzaAdapter()) + .build(); + + JsonAdapter jsonAdapter = moshi.adapter(MealDeal.class); + assertThat(jsonAdapter.toJson(new MealDeal(new Pizza(15, true), "Pepsi"))) + .isEqualTo("[{\"size\":15,\"extra cheese\":true},\"Pepsi\"]"); + assertThat(jsonAdapter.fromJson("[{\"extra cheese\":true,\"size\":18},\"Coke\"]")) + .isEqualTo(new MealDeal(new Pizza(18, true), "Coke")); + } + + @Uppercase + static String uppercaseString; + + @Test public void delegatingJsonAdapterFactory() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(new UppercaseAdapterFactory()) + .build(); + + AnnotatedElement annotations = MoshiTest.class.getDeclaredField("uppercaseString"); + JsonAdapter adapter = moshi.adapter(String.class, annotations).lenient(); + assertThat(adapter.toJson("a")).isEqualTo("\"A\""); + assertThat(adapter.fromJson("\"b\"")).isEqualTo("B"); + } + + static class Pizza { + final int diameter; + final boolean extraCheese; + + Pizza(int diameter, boolean extraCheese) { + this.diameter = diameter; + this.extraCheese = extraCheese; + } + + @Override public boolean equals(Object o) { + return o instanceof Pizza + && ((Pizza) o).diameter == diameter + && ((Pizza) o).extraCheese == extraCheese; + } + + @Override public int hashCode() { + return diameter * (extraCheese ? 31 : 1); + } + } + + static class MealDeal { + final Pizza pizza; + final String drink; + + MealDeal(Pizza pizza, String drink) { + this.pizza = pizza; + this.drink = drink; + } + + @Override public boolean equals(Object o) { + return o instanceof MealDeal + && ((MealDeal) o).pizza.equals(pizza) + && ((MealDeal) o).drink.equals(drink); + } + + @Override public int hashCode() { + return pizza.hashCode() + (31 * drink.hashCode()); + } + } + + static class PizzaAdapter extends JsonAdapter { + @Override public Pizza fromJson(JsonReader reader) throws IOException { + int diameter = 13; + boolean extraCheese = false; + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (name.equals("size")) { + diameter = reader.nextInt(); + } else if (name.equals("extra cheese")) { + extraCheese = reader.nextBoolean(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + return new Pizza(diameter, extraCheese); + } + + @Override public void toJson(JsonWriter writer, Pizza value) throws IOException { + writer.beginObject(); + writer.name("size").value(value.diameter); + writer.name("extra cheese").value(value.extraCheese); + writer.endObject(); + } + } + + static class MealDealAdapterFactory implements JsonAdapter.Factory { + @Override public JsonAdapter create( + Type type, AnnotatedElement annotations, Moshi moshi) { + if (!type.equals(MealDeal.class)) return null; + + final JsonAdapter pizzaAdapter = moshi.adapter(Pizza.class); + final JsonAdapter drinkAdapter = moshi.adapter(String.class); + return new JsonAdapter() { + @Override public MealDeal fromJson(JsonReader reader) throws IOException { + reader.beginArray(); + Pizza pizza = pizzaAdapter.fromJson(reader); + String drink = drinkAdapter.fromJson(reader); + reader.endArray(); + return new MealDeal(pizza, drink); + } + + @Override public void toJson(JsonWriter writer, MealDeal value) throws IOException { + writer.beginArray(); + pizzaAdapter.toJson(writer, value.pizza); + drinkAdapter.toJson(writer, value.drink); + writer.endArray(); + } + }; + } + } + + @Retention(RUNTIME) + public @interface Uppercase { + } + + static class UppercaseAdapterFactory implements JsonAdapter.Factory { + @Override public JsonAdapter create( + Type type, AnnotatedElement annotations, Moshi moshi) { + if (!type.equals(String.class)) return null; + if (!annotations.isAnnotationPresent(Uppercase.class)) return null; + + final JsonAdapter stringAdapter = moshi.nextAdapter(this, String.class, annotations); + return new JsonAdapter() { + @Override public String fromJson(JsonReader reader) throws IOException { + String s = stringAdapter.fromJson(reader); + return s.toUpperCase(Locale.US); + } + + @Override public void toJson(JsonWriter writer, String value) throws IOException { + stringAdapter.toJson(writer, value.toUpperCase()); + } + }; + } + } +} diff --git a/pom.xml b/pom.xml index f305da8..926b102 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ 4.11 + 1.6.1 @@ -63,6 +64,11 @@ okio ${okio.version} + + org.assertj + assertj-core + ${assertj.version} +