From 144f57ad4c04a81a6123d7983b79a339f6f0305b Mon Sep 17 00:00:00 2001 From: jwilson Date: Tue, 24 Mar 2015 23:52:34 -0400 Subject: [PATCH] JsonAdapterFactories can lookup types they create. Without this, things fail with stack overflow exceptions. --- .../com/squareup/moshi/ClassJsonAdapter.java | 1 - .../main/java/com/squareup/moshi/Moshi.java | 94 ++++++++++- .../squareup/moshi/CircularAdaptersTest.java | 153 ++++++++++++++++++ .../squareup/moshi/JsonReaderPathTest.java | 2 +- .../java/com/squareup/moshi/MoshiTest.java | 30 ++++ .../java/com/squareup/moshi/TypesTest.java | 2 +- 6 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 moshi/src/test/java/com/squareup/moshi/CircularAdaptersTest.java diff --git a/moshi/src/main/java/com/squareup/moshi/ClassJsonAdapter.java b/moshi/src/main/java/com/squareup/moshi/ClassJsonAdapter.java index 90eb092..ec44698 100644 --- a/moshi/src/main/java/com/squareup/moshi/ClassJsonAdapter.java +++ b/moshi/src/main/java/com/squareup/moshi/ClassJsonAdapter.java @@ -48,7 +48,6 @@ final class ClassJsonAdapter extends JsonAdapter { throw new IllegalArgumentException("cannot serialize abstract class " + rawType.getName()); } - ClassFactory classFactory = ClassFactory.get(rawType); Map> fields = new TreeMap<>(); for (Type t = type; t != Object.class; t = Types.getGenericSuperclass(t)) { diff --git a/moshi/src/main/java/com/squareup/moshi/Moshi.java b/moshi/src/main/java/com/squareup/moshi/Moshi.java index aea43e5..2973883 100644 --- a/moshi/src/main/java/com/squareup/moshi/Moshi.java +++ b/moshi/src/main/java/com/squareup/moshi/Moshi.java @@ -15,6 +15,8 @@ */ package com.squareup.moshi; +import java.io.IOException; +import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Type; import java.util.ArrayList; @@ -26,13 +28,16 @@ import java.util.List; */ public final class Moshi { private final List factories; + private final ThreadLocal>> reentrantCalls = new ThreadLocal<>(); private Moshi(Builder builder) { - List factories = new ArrayList(); + List factories = new ArrayList<>(); factories.addAll(builder.factories); factories.add(StandardJsonAdapters.FACTORY); factories.add(CollectionJsonAdapter.FACTORY); + factories.add(MapJsonAdapter.FACTORY); factories.add(ArrayJsonAdapter.FACTORY); + factories.add(ClassJsonAdapter.FACTORY); this.factories = Collections.unmodifiableList(factories); } @@ -59,17 +64,43 @@ public final class Moshi { @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; + List> deferredAdapters = reentrantCalls.get(); + if (deferredAdapters == null) { + deferredAdapters = new ArrayList<>(); + reentrantCalls.set(deferredAdapters); + } else if (firstIndex == 0) { + // If this is a regular adapter lookup, check that this isn't a reentrant call. + for (DeferredAdapter deferredAdapter : deferredAdapters) { + if (deferredAdapter.type.equals(type) && deferredAdapter.annotations.equals(annotations)) { + return (JsonAdapter) deferredAdapter; + } + } } + + DeferredAdapter deferredAdapter = new DeferredAdapter<>(type, annotations); + deferredAdapters.add(deferredAdapter); + try { + for (int i = firstIndex, size = factories.size(); i < size; i++) { + JsonAdapter result = (JsonAdapter) factories.get(i).create(type, annotations, this); + if (result != null) { + deferredAdapter.ready(result); + return result; + } + } + } finally { + deferredAdapters.remove(deferredAdapters.size() - 1); + } + throw new IllegalArgumentException("no JsonAdapter for " + type); } public static final class Builder { - private final List factories = new ArrayList(); + private final List factories = new ArrayList<>(); public Builder add(final Type type, final JsonAdapter jsonAdapter) { + if (type == null) throw new IllegalArgumentException("type == null"); + if (jsonAdapter == null) throw new IllegalArgumentException("jsonAdapter == null"); + return add(new JsonAdapter.Factory() { @Override public JsonAdapter create( Type targetType, AnnotatedElement annotations, Moshi moshi) { @@ -78,6 +109,22 @@ public final class Moshi { }); } + public Builder add(final Type type, final Class annotation, + final JsonAdapter jsonAdapter) { + if (type == null) throw new IllegalArgumentException("type == null"); + if (annotation == null) throw new IllegalArgumentException("annotation == null"); + if (jsonAdapter == null) throw new IllegalArgumentException("jsonAdapter == null"); + + return add(new JsonAdapter.Factory() { + @Override public JsonAdapter create( + Type targetType, AnnotatedElement annotations, Moshi moshi) { + return Util.typesMatch(type, targetType) && annotations.isAnnotationPresent(annotation) + ? jsonAdapter + : null; + } + }); + } + public Builder add(JsonAdapter.Factory jsonAdapter) { // TODO: define precedence order. Last added wins? First added wins? factories.add(jsonAdapter); @@ -88,4 +135,41 @@ public final class Moshi { return new Moshi(this); } } + + /** + * Sometimes a type adapter factory depends on its own product; either directly or indirectly. + * To make this work, we offer this type adapter stub while the final adapter is being computed. + * When it is ready, we wire this to delegate to that finished adapter. + * + *

Typically this is necessary in self-referential object models, such as an {@code Employee} + * class that has a {@code List} field for an organization's management hierarchy. + */ + private static class DeferredAdapter extends JsonAdapter { + private Type type; + private AnnotatedElement annotations; + private JsonAdapter delegate; + + public DeferredAdapter(Type type, AnnotatedElement annotations) { + this.type = type; + this.annotations = annotations; + } + + public void ready(JsonAdapter delegate) { + this.delegate = delegate; + + // Null out the type and annotations so they can be garbage collected. + this.type = null; + this.annotations = null; + } + + @Override public T fromJson(JsonReader reader) throws IOException { + if (delegate == null) throw new IllegalStateException("type adapter isn't ready"); + return delegate.fromJson(reader); + } + + @Override public void toJson(JsonWriter writer, T value) throws IOException { + if (delegate == null) throw new IllegalStateException("type adapter isn't ready"); + delegate.toJson(writer, value); + } + } } diff --git a/moshi/src/test/java/com/squareup/moshi/CircularAdaptersTest.java b/moshi/src/test/java/com/squareup/moshi/CircularAdaptersTest.java new file mode 100644 index 0000000..f90e253 --- /dev/null +++ b/moshi/src/test/java/com/squareup/moshi/CircularAdaptersTest.java @@ -0,0 +1,153 @@ +/* + * 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.annotation.Retention; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; +import org.junit.Test; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static org.assertj.core.api.Assertions.assertThat; + +public final class CircularAdaptersTest { + static class Team { + final String lead; + final Project[] projects; + + public Team(String lead, Project... projects) { + this.lead = lead; + this.projects = projects; + } + } + + static class Project { + final String name; + final Team[] teams; + + Project(String name, Team... teams) { + this.name = name; + this.teams = teams; + } + } + + @Test public void circularAdapters() throws Exception { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter teamAdapter = moshi.adapter(Team.class); + + Team team = new Team("Alice", new Project("King", new Team("Charlie", + new Project("Delivery", null)))); + assertThat(teamAdapter.toJson(team)).isEqualTo("{\"lead\":\"Alice\",\"projects\":[{\"name\":" + + "\"King\",\"teams\":[{\"lead\":\"Charlie\",\"projects\":[{\"name\":\"Delivery\"}]}]}]}"); + + Team fromJson = teamAdapter.fromJson("{\"lead\":\"Alice\",\"projects\":[{\"name\":" + + "\"King\",\"teams\":[{\"lead\":\"Charlie\",\"projects\":[{\"name\":\"Delivery\"}]}]}]}"); + assertThat(fromJson.lead).isEqualTo("Alice"); + assertThat(fromJson.projects[0].name).isEqualTo("King"); + assertThat(fromJson.projects[0].teams[0].lead).isEqualTo("Charlie"); + assertThat(fromJson.projects[0].teams[0].projects[0].name).isEqualTo("Delivery"); + } + + @Retention(RUNTIME) + public @interface Left { + } + + @Retention(RUNTIME) + public @interface Right { + } + + static class Node { + final String name; + final @Left Node left; + final @Right Node right; + + Node(String name, Node left, Node right) { + this.name = name; + this.left = left; + this.right = right; + } + + Node plusPrefix(String prefix) { + return new Node(prefix + name, left, right); + } + + Node minusPrefix(String prefix) { + if (!name.startsWith(prefix)) throw new IllegalArgumentException(); + return new Node(name.substring(prefix.length()), left, right); + } + } + + /** + * This factory uses extensive delegation. Each node delegates to this for the left and right + * subtrees, and those delegate to the built-in class adapter to do most of the serialization + * work. + */ + static class PrefixingNodeFactory implements JsonAdapter.Factory { + @Override public JsonAdapter create(Type type, AnnotatedElement annotations, Moshi moshi) { + if (type != Node.class) return null; + + final String prefix; + if (annotations.isAnnotationPresent(Left.class)) { + prefix = "L "; + } else if (annotations.isAnnotationPresent(Right.class)) { + prefix = "R "; + } else { + return null; + } + + final JsonAdapter delegate = moshi.nextAdapter(this, Node.class, annotations); + + return new JsonAdapter() { + @Override public void toJson(JsonWriter writer, Node value) throws IOException { + delegate.toJson(writer, value.plusPrefix(prefix)); + } + + @Override public Node fromJson(JsonReader reader) throws IOException { + Node result = delegate.fromJson(reader); + return result.minusPrefix(prefix); + } + }.nullSafe(); + } + } + + @Test public void circularAdaptersAndAnnotations() throws Exception { + Moshi moshi = new Moshi.Builder() + .add(new PrefixingNodeFactory()) + .build(); + JsonAdapter nodeAdapter = moshi.adapter(Node.class); + + Node tree = new Node("C", + new Node("A", null, new Node("B", null, null)), + new Node("D", null, new Node("E", null, null))); + assertThat(nodeAdapter.toJson(tree)).isEqualTo("{" + + "\"left\":{\"name\":\"L A\",\"right\":{\"name\":\"R B\"}}," + + "\"name\":\"C\"," + + "\"right\":{\"name\":\"R D\",\"right\":{\"name\":\"R E\"}}" + + "}"); + + Node fromJson = nodeAdapter.fromJson("{" + + "\"left\":{\"name\":\"L A\",\"right\":{\"name\":\"R B\"}}," + + "\"name\":\"C\"," + + "\"right\":{\"name\":\"R D\",\"right\":{\"name\":\"R E\"}}" + + "}"); + assertThat(fromJson.name).isEqualTo("C"); + assertThat(fromJson.left.name).isEqualTo("A"); + assertThat(fromJson.left.right.name).isEqualTo("B"); + assertThat(fromJson.right.name).isEqualTo("D"); + assertThat(fromJson.right.right.name).isEqualTo("E"); + } +} diff --git a/moshi/src/test/java/com/squareup/moshi/JsonReaderPathTest.java b/moshi/src/test/java/com/squareup/moshi/JsonReaderPathTest.java index 899df75..b881f35 100644 --- a/moshi/src/test/java/com/squareup/moshi/JsonReaderPathTest.java +++ b/moshi/src/test/java/com/squareup/moshi/JsonReaderPathTest.java @@ -20,7 +20,7 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; -public class JsonReaderPathTest { +public final class JsonReaderPathTest { @Test public void path() throws IOException { JsonReader reader = new JsonReader("{\"a\":[2,true,false,null,\"b\",{\"c\":\"d\"},[3]]}"); assertEquals("$", reader.getPath()); diff --git a/moshi/src/test/java/com/squareup/moshi/MoshiTest.java b/moshi/src/test/java/com/squareup/moshi/MoshiTest.java index ac83735..eed1661 100644 --- a/moshi/src/test/java/com/squareup/moshi/MoshiTest.java +++ b/moshi/src/test/java/com/squareup/moshi/MoshiTest.java @@ -552,6 +552,36 @@ public final class MoshiTest { .isEqualTo(new MealDeal(new Pizza(18, true), "Coke")); } + static class Message { + String speak; + @Uppercase String shout; + } + + @Test public void registerJsonAdapterForAnnotatedType() throws Exception { + JsonAdapter uppercaseAdapter = new JsonAdapter() { + @Override public String fromJson(JsonReader reader) throws IOException { + throw new AssertionError(); + } + + @Override public void toJson(JsonWriter writer, String value) throws IOException { + writer.value(value.toUpperCase(Locale.US)); + } + }; + + Moshi moshi = new Moshi.Builder() + .add(String.class, Uppercase.class, uppercaseAdapter) + .build(); + + JsonAdapter messageAdapter = moshi.adapter(Message.class); + + Message message = new Message(); + message.speak = "Yo dog"; + message.shout = "What's up"; + + assertThat(messageAdapter.toJson(message)) + .isEqualTo("{\"shout\":\"WHAT'S UP\",\"speak\":\"Yo dog\"}"); + } + @Uppercase static String uppercaseString; diff --git a/moshi/src/test/java/com/squareup/moshi/TypesTest.java b/moshi/src/test/java/com/squareup/moshi/TypesTest.java index fa77ba1..74f84c2 100644 --- a/moshi/src/test/java/com/squareup/moshi/TypesTest.java +++ b/moshi/src/test/java/com/squareup/moshi/TypesTest.java @@ -27,7 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; -public class TypesTest { +public final class TypesTest { @Test public void newParameterizedType() throws Exception { // List. List is a top-level class.