Adapter methods.

This is a fun new API that could make writing JSON adapters
much easier.
This commit is contained in:
jwilson
2015-05-25 05:54:54 -07:00
parent 455fa47f9b
commit 71f0889842
6 changed files with 510 additions and 0 deletions

View File

@@ -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<Type, ToAdapter> toAdapters;
private final Map<Type, FromAdapter> fromAdapters;
AdapterMethodsFactory(Map<Type, ToAdapter> toAdapters, Map<Type, FromAdapter> 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<Object> delegate = toAdapter == null || fromAdapter == null
? moshi.nextAdapter(this, type, annotations)
: null;
return new JsonAdapter<Object>() {
@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<Type, ToAdapter> toAdapters = new LinkedHashMap<>();
Map<Type, FromAdapter> 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<Object> 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"
+ " <any access modifier> void toJson(JsonWriter writer, T value) throws <any>;\n"
+ " <any access modifier> R toJson(T value) throws <any>;\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<Integer> o) throws Exception {
return new FromAdapter(returnType, adapter, method) {
@Override public Object fromJson(Moshi moshi, JsonReader reader)
throws IOException, IllegalAccessException, InvocationTargetException {
JsonAdapter<Object> 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"
+ " <any access modifier> void toJson(JsonWriter writer, T value) throws <any>;\n"
+ " <any access modifier> R toJson(T value) throws <any>;\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;
}
}

View File

@@ -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 {
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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 {
}

View File

@@ -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<Point> 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<Integer> pointToJson(Point point) {
return Arrays.asList(point.x, point.y);
}
@FromJson Point pointFromJson(List<Integer> 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<Point> 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<Point> 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<Integer> 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<Point> 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<Integer> 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<Point> 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<Integer> pointToJson(Point point) {
return Arrays.asList(point.x, point.y);
}
@ToJson String integerListToJson(List<Integer> 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<Integer> 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<Integer> listOfIntegersFromJson(String list) throws Exception {
List<Integer> 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<Integer> 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<Integer> 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;
}
}
}