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} +