diff --git a/moshi/src/main/java/com/squareup/moshi/AdapterMethodsFactory.java b/moshi/src/main/java/com/squareup/moshi/AdapterMethodsFactory.java new file mode 100644 index 0000000..80e88d0 --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/AdapterMethodsFactory.java @@ -0,0 +1,222 @@ +/* + * 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.io.IOException; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.Map; + +// TODO: support qualifier annotations. +// TODO: support @Nullable +// TODO: path in JsonWriter. + +final class AdapterMethodsFactory implements JsonAdapter.Factory { + private final Map toAdapters; + private final Map fromAdapters; + + AdapterMethodsFactory(Map toAdapters, Map fromAdapters) { + this.toAdapters = toAdapters; + this.fromAdapters = fromAdapters; + } + + @Override public JsonAdapter create(Type type, AnnotatedElement annotations, final Moshi moshi) { + final ToAdapter toAdapter = toAdapters.get(type); + final FromAdapter fromAdapter = fromAdapters.get(type); + if (toAdapter == null && fromAdapter == null) return null; + + final JsonAdapter delegate = toAdapter == null || fromAdapter == null + ? moshi.nextAdapter(this, type, annotations) + : null; + + return new JsonAdapter() { + @Override public void toJson(JsonWriter writer, Object value) throws IOException { + if (toAdapter == null) { + delegate.toJson(writer, value); + } else { + try { + toAdapter.toJson(moshi, writer, value); + } catch (IllegalAccessException e) { + throw new AssertionError(); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof IOException) throw (IOException) e.getCause(); + throw new JsonDataException(e.getCause().getMessage()); // TODO: more context? + } + } + } + + @Override public Object fromJson(JsonReader reader) throws IOException { + if (fromAdapter == null) { + return delegate.fromJson(reader); + } else { + try { + return fromAdapter.fromJson(moshi, reader); + } catch (IllegalAccessException e) { + throw new AssertionError(); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof IOException) throw (IOException) e.getCause(); + throw new JsonDataException(e.getCause().getMessage()); // TODO: more context? + } + } + } + }; + } + + public static AdapterMethodsFactory get(Object adapter) { + Map toAdapters = new LinkedHashMap<>(); + Map fromAdapters = new LinkedHashMap<>(); + + for (Class c = adapter.getClass(); c != Object.class; c = c.getSuperclass()) { + for (Method m : c.getDeclaredMethods()) { + if (m.isAnnotationPresent(ToJson.class)) { + ToAdapter toAdapter = toAdapter(adapter, m); + ToAdapter replaced = toAdapters.put(toAdapter.type, toAdapter); + if (replaced != null) { + throw new IllegalArgumentException("Conflicting @ToJson methods:\n" + + " " + replaced.method + "\n" + + " " + toAdapter.method); + } + } + + if (m.isAnnotationPresent(FromJson.class)) { + FromAdapter fromAdapter = fromAdapter(adapter, m); + FromAdapter replaced = fromAdapters.put(fromAdapter.type, fromAdapter); + if (replaced != null) { + throw new IllegalArgumentException("Conflicting @FromJson methods:\n" + + " " + replaced.method + "\n" + + " " + fromAdapter.method); + } + } + } + } + + if (toAdapters.isEmpty() && fromAdapters.isEmpty()) { + throw new IllegalArgumentException("Expected at least one @ToJson or @FromJson method on " + + adapter.getClass().getName()); + } + + return new AdapterMethodsFactory(toAdapters, fromAdapters); + } + + /** + * Returns an object that calls a {@code method} method on {@code adapter} in service of + * converting an object to JSON. + */ + static ToAdapter toAdapter(Object adapter, Method method) { + method.setAccessible(true); + Type[] parameterTypes = method.getGenericParameterTypes(); + final Type returnType = method.getGenericReturnType(); + + if (parameterTypes.length == 2 + && parameterTypes[0] == JsonWriter.class + && returnType == void.class) { + return new ToAdapter(parameterTypes[1], adapter, method) { + @Override public void toJson(Moshi moshi, JsonWriter writer, Object value) + throws IOException, InvocationTargetException, IllegalAccessException { + method.invoke(adapter, writer, value); + } + }; + + } else if (parameterTypes.length == 1 && returnType != void.class) { + return new ToAdapter(parameterTypes[0], adapter, method) { + @Override public void toJson(Moshi moshi, JsonWriter writer, Object value) + throws IOException, InvocationTargetException, IllegalAccessException { + JsonAdapter delegate = moshi.adapter(returnType, method); + Object intermediate = method.invoke(adapter, value); + delegate.toJson(writer, intermediate); + } + }; + + } else { + throw new IllegalArgumentException("Unexpected signature for " + method + ".\n" + + "@ToJson method signatures may have one of the following structures:\n" + + " void toJson(JsonWriter writer, T value) throws ;\n" + + " R toJson(T value) throws ;\n"); + } + } + + static abstract class ToAdapter { + final Type type; + final Object adapter; + final Method method; + + public ToAdapter(Type type, Object adapter, Method method) { + this.type = type; + this.adapter = adapter; + this.method = method; + } + + public abstract void toJson(Moshi moshi, JsonWriter writer, Object value) + throws IOException, IllegalAccessException, InvocationTargetException; + } + + /** + * Returns an object that calls a {@code method} method on {@code adapter} in service of + * converting an object from JSON. + */ + static FromAdapter fromAdapter(Object adapter, Method method) { + method.setAccessible(true); + final Type[] parameterTypes = method.getGenericParameterTypes(); + final Type returnType = method.getGenericReturnType(); + + if (parameterTypes.length == 1 + && parameterTypes[0] == JsonReader.class + && returnType != void.class) { + // public Point pointFromJson(JsonReader jsonReader) throws Exception { + return new FromAdapter(returnType, adapter, method) { + @Override public Object fromJson(Moshi moshi, JsonReader reader) + throws IOException, IllegalAccessException, InvocationTargetException { + return method.invoke(adapter, reader); + } + }; + + } else if (parameterTypes.length == 1 && returnType != void.class) { + // public Point pointFromJson(List o) throws Exception { + return new FromAdapter(returnType, adapter, method) { + @Override public Object fromJson(Moshi moshi, JsonReader reader) + throws IOException, IllegalAccessException, InvocationTargetException { + JsonAdapter delegate = moshi.adapter(parameterTypes[0]); + Object intermediate = delegate.fromJson(reader); + return method.invoke(adapter, intermediate); + } + }; + + } else { + throw new IllegalArgumentException("Unexpected signature for " + method + ".\n" + + "@ToJson method signatures may have one of the following structures:\n" + + " void toJson(JsonWriter writer, T value) throws ;\n" + + " R toJson(T value) throws ;\n"); + } + } + + static abstract class FromAdapter { + final Type type; + final Object adapter; + final Method method; + + public FromAdapter(Type type, Object adapter, Method method) { + this.type = type; + this.adapter = adapter; + this.method = method; + } + + public abstract Object fromJson(Moshi moshi, JsonReader reader) + throws IOException, IllegalAccessException, InvocationTargetException; + } +} diff --git a/moshi/src/main/java/com/squareup/moshi/FromJson.java b/moshi/src/main/java/com/squareup/moshi/FromJson.java new file mode 100644 index 0000000..960c612 --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/FromJson.java @@ -0,0 +1,26 @@ +/* + * 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.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface FromJson { +} diff --git a/moshi/src/main/java/com/squareup/moshi/JsonDataException.java b/moshi/src/main/java/com/squareup/moshi/JsonDataException.java new file mode 100644 index 0000000..093fb72 --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/JsonDataException.java @@ -0,0 +1,31 @@ +/* + * 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; + +/** Thrown when a JSON document doesn't match the expected format. */ +public final class JsonDataException extends RuntimeException { + public JsonDataException(String message) { + super(message); + } + + public JsonDataException(Throwable cause) { + super(cause); + } + + public JsonDataException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/moshi/src/main/java/com/squareup/moshi/Moshi.java b/moshi/src/main/java/com/squareup/moshi/Moshi.java index 2973883..895cb5e 100644 --- a/moshi/src/main/java/com/squareup/moshi/Moshi.java +++ b/moshi/src/main/java/com/squareup/moshi/Moshi.java @@ -131,6 +131,10 @@ public final class Moshi { return this; } + public Builder add(Object adapter) { + return add(AdapterMethodsFactory.get(adapter)); + } + public Moshi build() { return new Moshi(this); } diff --git a/moshi/src/main/java/com/squareup/moshi/ToJson.java b/moshi/src/main/java/com/squareup/moshi/ToJson.java new file mode 100644 index 0000000..1d8d6e4 --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/ToJson.java @@ -0,0 +1,26 @@ +/* + * 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.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ToJson { +} diff --git a/moshi/src/test/java/com/squareup/moshi/AdapterMethodsTest.java b/moshi/src/test/java/com/squareup/moshi/AdapterMethodsTest.java new file mode 100644 index 0000000..c6fb984 --- /dev/null +++ b/moshi/src/test/java/com/squareup/moshi/AdapterMethodsTest.java @@ -0,0 +1,201 @@ +/* + * 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.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; + +public final class AdapterMethodsTest { + @Test public void toAndFromJsonViaListOfIntegers() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(new PointAsListOfIntegersJsonAdapter()) + .build(); + JsonAdapter pointAdapter = moshi.adapter(Point.class); + assertThat(pointAdapter.toJson(new Point(5, 8))).isEqualTo("[5,8]"); + assertThat(pointAdapter.fromJson("[5,8]")).isEqualTo(new Point(5, 8)); + } + + static class PointAsListOfIntegersJsonAdapter { + @ToJson List pointToJson(Point point) { + return Arrays.asList(point.x, point.y); + } + + @FromJson Point pointFromJson(List o) throws Exception { + if (o.size() != 2) throw new Exception("Expected 2 elements but was " + o); + return new Point(o.get(0), o.get(1)); + } + } + + @Test public void toAndFromJsonWithWriterAndReader() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(new PointWriterAndReaderJsonAdapter()) + .build(); + JsonAdapter pointAdapter = moshi.adapter(Point.class); + assertThat(pointAdapter.toJson(new Point(5, 8))).isEqualTo("[5,8]"); + assertThat(pointAdapter.fromJson("[5,8]")).isEqualTo(new Point(5, 8)); + } + + static class PointWriterAndReaderJsonAdapter { + @ToJson void pointToJson(JsonWriter writer, Point point) throws IOException { + writer.beginArray(); + writer.value(point.x); + writer.value(point.y); + writer.endArray(); + } + + @FromJson Point pointFromJson(JsonReader reader) throws Exception { + reader.beginArray(); + int x = reader.nextInt(); + int y = reader.nextInt(); + reader.endArray(); + return new Point(x, y); + } + } + + @Test public void toJsonOnly() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(new PointAsListOfIntegersToAdapter()) + .build(); + JsonAdapter pointAdapter = moshi.adapter(Point.class); + assertThat(pointAdapter.toJson(new Point(5, 8))).isEqualTo("[5,8]"); + assertThat(pointAdapter.fromJson("{\"x\":5,\"y\":8}")).isEqualTo(new Point(5, 8)); + } + + static class PointAsListOfIntegersToAdapter { + @ToJson List pointToJson(Point point) { + return Arrays.asList(point.x, point.y); + } + } + + @Test public void fromJsonOnly() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(new PointAsListOfIntegersFromAdapter()) + .build(); + JsonAdapter pointAdapter = moshi.adapter(Point.class); + assertThat(pointAdapter.toJson(new Point(5, 8))).isEqualTo("{\"x\":5,\"y\":8}"); + assertThat(pointAdapter.fromJson("[5,8]")).isEqualTo(new Point(5, 8)); + } + + static class PointAsListOfIntegersFromAdapter { + @FromJson Point pointFromJson(List o) throws Exception { + if (o.size() != 2) throw new Exception("Expected 2 elements but was " + o); + return new Point(o.get(0), o.get(1)); + } + } + + @Test public void multipleLayersOfAdapters() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(new MultipleLayersJsonAdapter()) + .build(); + JsonAdapter pointAdapter = moshi.adapter(Point.class).lenient(); + assertThat(pointAdapter.toJson(new Point(5, 8))).isEqualTo("\"5 8\""); + assertThat(pointAdapter.fromJson("\"5 8\"")).isEqualTo(new Point(5, 8)); + } + + static class MultipleLayersJsonAdapter { + @ToJson List pointToJson(Point point) { + return Arrays.asList(point.x, point.y); + } + + @ToJson String integerListToJson(List list) { + StringBuilder result = new StringBuilder(); + for (Integer i : list) { + if (result.length() != 0) result.append(" "); + result.append(i.intValue()); + } + return result.toString(); + } + + @FromJson Point pointFromJson(List o) throws Exception { + if (o.size() != 2) throw new Exception("Expected 2 elements but was " + o); + return new Point(o.get(0), o.get(1)); + } + + @FromJson List listOfIntegersFromJson(String list) throws Exception { + List result = new ArrayList<>(); + for (String part : list.split(" ")) { + result.add(Integer.parseInt(part)); + } + return result; + } + } + + @Test public void conflictingToAdapters() throws Exception { + Moshi.Builder builder = new Moshi.Builder(); + try { + builder.add(new ConflictingsToJsonAdapter()); + fail(); + } catch (IllegalArgumentException expected) { + assertThat(expected.getMessage()).contains( + "Conflicting @ToJson methods:", "pointToJson1", "pointToJson2"); + } + } + + static class ConflictingsToJsonAdapter { + @ToJson List pointToJson1(Point point) { + throw new AssertionError(); + } + + @ToJson String pointToJson2(Point point) { + throw new AssertionError(); + } + } + + @Test public void conflictingFromAdapters() throws Exception { + Moshi.Builder builder = new Moshi.Builder(); + try { + builder.add(new ConflictingsFromJsonAdapter()); + fail(); + } catch (IllegalArgumentException expected) { + assertThat(expected.getMessage()).contains( + "Conflicting @FromJson methods:", "pointFromJson1", "pointFromJson2"); + } + } + + static class ConflictingsFromJsonAdapter { + @FromJson Point pointFromJson1(List point) { + throw new AssertionError(); + } + + @FromJson Point pointFromJson2(String point) { + throw new AssertionError(); + } + } + + static class Point { + final int x; + final int y; + + public Point(int x, int y) { + this.x = x; + this.y = y; + } + + @Override public boolean equals(Object o) { + return o instanceof Point && ((Point) o).x == x && ((Point) o).y == y; + } + + @Override public int hashCode() { + return x * 37 + y; + } + } +}