diff --git a/adapters/src/main/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactory.java b/adapters/src/main/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactory.java new file mode 100644 index 0000000..44b97d2 --- /dev/null +++ b/adapters/src/main/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactory.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2011 Google 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.adapters; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import javax.annotation.CheckReturnValue; + +/** + * A JsonAdapter factory for polymorphic types. This is useful when the type is not known before + * deserializing the JSON. This factory's adapters expect JSON in the format of a JSON object with a + * key whose value is a label that determines the type to which to map the JSON object. + */ +public final class RuntimeJsonAdapterFactory implements JsonAdapter.Factory { + final Class baseType; + final String labelKey; + final Map labelToType = new LinkedHashMap<>(); + + /** + * @param baseType The base type for which this factory will create adapters. + * @param labelKey The key in the JSON object whose value determines the type to which to map the + * JSON object. + */ + @CheckReturnValue + public static RuntimeJsonAdapterFactory of(Class baseType, String labelKey) { + if (baseType == null) throw new NullPointerException("baseType == null"); + if (labelKey == null) throw new NullPointerException("labelKey == null"); + return new RuntimeJsonAdapterFactory<>(baseType, labelKey); + } + + RuntimeJsonAdapterFactory(Class baseType, String labelKey) { + this.baseType = baseType; + this.labelKey = labelKey; + } + + /** + * Register the subtype that can be created based on the label. When deserializing, if a label + * that was not registered is found, a JsonDataException will be thrown. When serializing, if a + * type that was not registered is used, an IllegalArgumentException will be thrown. + */ + public RuntimeJsonAdapterFactory registerSubtype(Class subtype, String label) { + if (subtype == null) throw new NullPointerException("subtype == null"); + if (label == null) throw new NullPointerException("label == null"); + if (labelToType.containsKey(label) || labelToType.containsValue(subtype)) { + throw new IllegalArgumentException("Subtypes and labels must be unique."); + } + labelToType.put(label, subtype); + return this; + } + + @Override + public JsonAdapter create(Type type, Set annotations, Moshi moshi) { + if (Types.getRawType(type) != baseType || !annotations.isEmpty()) { + return null; + } + int size = labelToType.size(); + Map> typeToAdapter = new LinkedHashMap<>(size); + Map> labelToAdapter = new LinkedHashMap<>(size); + for (Map.Entry entry : labelToType.entrySet()) { + String label = entry.getKey(); + Type typeValue = entry.getValue(); + JsonAdapter adapter = moshi.adapter(typeValue); + labelToAdapter.put(label, adapter); + typeToAdapter.put(typeValue, adapter); + } + return new RuntimeJsonAdapter(labelKey, labelToAdapter, typeToAdapter).nullSafe(); + } + + static final class RuntimeJsonAdapter extends JsonAdapter { + final String labelKey; + final Map> labelToAdapter; + final Map> typeToAdapter; + + RuntimeJsonAdapter( + String labelKey, + Map> labelToAdapter, + Map> typeToAdapter) { + this.labelKey = labelKey; + this.labelToAdapter = labelToAdapter; + this.typeToAdapter = typeToAdapter; + } + + @Override public Object fromJson(JsonReader reader) throws IOException { + JsonReader.Token peekedToken = reader.peek(); + if (peekedToken != JsonReader.Token.BEGIN_OBJECT) { + throw new JsonDataException("Expected BEGIN_OBJECT but was " + peekedToken + + " at path " + reader.getPath()); + } + Object jsonValue = reader.readJsonValue(); + Map jsonObject = (Map) jsonValue; + Object label = jsonObject.get(labelKey); + if (label == null) { + throw new JsonDataException("Missing label for " + labelKey); + } + if (!(label instanceof String)) { + throw new JsonDataException("Label for '" + + labelKey + + "' must be a string but was " + + label + + ", a " + + label.getClass()); + } + JsonAdapter adapter = labelToAdapter.get(label); + if (adapter == null) { + throw new JsonDataException("Expected one of " + + labelToAdapter.keySet() + + " for key '" + + labelKey + + "' but found '" + + label + + "'. Register a subtype for this label."); + } + return adapter.fromJsonValue(jsonValue); + } + + @Override public void toJson(JsonWriter writer, Object value) throws IOException { + Class type = value.getClass(); + JsonAdapter adapter = typeToAdapter.get(type); + if (adapter == null) { + throw new IllegalArgumentException("Expected one of " + + typeToAdapter.keySet() + + " but found " + + value + + ", a " + + value.getClass() + + ". Register this subtype."); + } + adapter.toJson(writer, value); + } + + @Override public String toString() { + return "RuntimeJsonAdapter(" + labelKey + ")"; + } + } +} diff --git a/adapters/src/test/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactoryTest.java b/adapters/src/test/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactoryTest.java new file mode 100644 index 0000000..a105fd5 --- /dev/null +++ b/adapters/src/test/java/com/squareup/moshi/adapters/RuntimeJsonAdapterFactoryTest.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2018 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.adapters; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.Moshi; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import okio.Buffer; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; + +@SuppressWarnings("CheckReturnValue") +public final class RuntimeJsonAdapterFactoryTest { + @Test public void fromJson() throws IOException { + Moshi moshi = new Moshi.Builder() + .add(RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "success") + .registerSubtype(Error.class, "error")) + .build(); + JsonAdapter adapter = moshi.adapter(Message.class); + + assertThat(adapter.fromJson("{\"type\":\"success\",\"value\":\"Okay!\"}")) + .isEqualTo(new Success("Okay!")); + assertThat(adapter.fromJson("{\"type\":\"error\",\"error_logs\":{\"order\":66}}")) + .isEqualTo(new Error(Collections.singletonMap("order", 66d))); + } + + @Test public void toJson() { + Moshi moshi = new Moshi.Builder() + .add(RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "success") + .registerSubtype(Error.class, "error")) + .build(); + JsonAdapter adapter = moshi.adapter(Message.class); + + assertThat(adapter.toJson(new Success("Okay!"))) + .isEqualTo("{\"value\":\"Okay!\"}"); + assertThat(adapter.toJson(new Error(Collections.singletonMap("order", 66)))) + .isEqualTo("{\"error_logs\":{\"order\":66}}"); + } + + @Test public void unregisteredLabelValue() throws IOException { + Moshi moshi = new Moshi.Builder() + .add(RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "success") + .registerSubtype(Error.class, "error")) + .build(); + JsonAdapter adapter = moshi.adapter(Message.class); + + JsonReader reader = + JsonReader.of(new Buffer().writeUtf8("{\"type\":\"data\",\"value\":\"Okay!\"}")); + try { + adapter.fromJson(reader); + fail(); + } catch (JsonDataException expected) { + assertThat(expected).hasMessage("Expected one of [success, error] for key 'type' but found" + + " 'data'. Register a subtype for this label."); + } + assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT); + } + + @Test public void unregisteredSubtype() { + Moshi moshi = new Moshi.Builder() + .add(RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "success") + .registerSubtype(Error.class, "error")) + .build(); + JsonAdapter adapter = moshi.adapter(Message.class); + + try { + adapter.toJson(new EmptyMessage()); + } catch (IllegalArgumentException expected) { + assertThat(expected).hasMessage("Expected one of [class" + + " com.squareup.moshi.adapters.RuntimeJsonAdapterFactoryTest$Success, class" + + " com.squareup.moshi.adapters.RuntimeJsonAdapterFactoryTest$Error] but found" + + " EmptyMessage, a class" + + " com.squareup.moshi.adapters.RuntimeJsonAdapterFactoryTest$EmptyMessage. Register" + + " this subtype."); + } + } + + @Test public void nonStringLabelValue() throws IOException { + Moshi moshi = new Moshi.Builder() + .add(RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "success") + .registerSubtype(Error.class, "error")) + .build(); + JsonAdapter adapter = moshi.adapter(Message.class); + + try { + adapter.fromJson("{\"type\":{},\"value\":\"Okay!\"}"); + fail(); + } catch (JsonDataException expected) { + assertThat(expected).hasMessage("Label for 'type' must be a string but was {}," + + " a class com.squareup.moshi.LinkedHashTreeMap"); + } + } + + @Test public void nonObjectDoesNotConsume() throws IOException { + Moshi moshi = new Moshi.Builder() + .add(RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "success") + .registerSubtype(Error.class, "error")) + .build(); + JsonAdapter adapter = moshi.adapter(Message.class); + + JsonReader reader = JsonReader.of(new Buffer().writeUtf8("\"Failure\"")); + try { + adapter.fromJson(reader); + fail(); + } catch (JsonDataException expected) { + assertThat(expected).hasMessage("Expected BEGIN_OBJECT but was STRING at path $"); + } + assertThat(reader.nextString()).isEqualTo("Failure"); + assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT); + } + + @Test public void uniqueSubtypes() { + RuntimeJsonAdapterFactory factory = + RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "success"); + try { + factory.registerSubtype(Success.class, "data"); + fail(); + } catch (IllegalArgumentException expected) { + assertThat(expected).hasMessage("Subtypes and labels must be unique."); + } + } + + @Test public void uniqueLabels() { + RuntimeJsonAdapterFactory factory = + RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "data"); + try { + factory.registerSubtype(Error.class, "data"); + fail(); + } catch (IllegalArgumentException expected) { + assertThat(expected).hasMessage("Subtypes and labels must be unique."); + } + } + + @Test public void nullSafe() throws IOException { + Moshi moshi = new Moshi.Builder() + .add(RuntimeJsonAdapterFactory.of(Message.class, "type") + .registerSubtype(Success.class, "success") + .registerSubtype(Error.class, "error")) + .build(); + JsonAdapter adapter = moshi.adapter(Message.class); + + JsonReader reader = JsonReader.of(new Buffer().writeUtf8("null")); + assertThat(adapter.fromJson(reader)).isNull(); + assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT); + } + + interface Message { + } + + static final class Success implements Message { + final String value; + + Success(String value) { + this.value = value; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Success)) return false; + Success success = (Success) o; + return value.equals(success.value); + } + + @Override public int hashCode() { + return value.hashCode(); + } + } + + static final class Error implements Message { + final Map error_logs; + + Error(Map error_logs) { + this.error_logs = error_logs; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Error)) return false; + Error error = (Error) o; + return error_logs.equals(error.error_logs); + } + + @Override public int hashCode() { + return error_logs.hashCode(); + } + } + + static final class EmptyMessage implements Message { + @Override public String toString() { + return "EmptyMessage"; + } + } +}