Add RuntimeJsonAdapterFactory to adapters module. (#606)

* Add RuntimeJsonAdapterFactory to adapters module.

* Make RuntimeJsonAdapterFactory create null-safe adapters.

* Add copyright headers.
This commit is contained in:
Eric Cochran
2018-08-06 01:35:53 -07:00
committed by Jesse Wilson
parent 9251309c3f
commit 0f1fa3d385
2 changed files with 378 additions and 0 deletions

View File

@@ -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<T> implements JsonAdapter.Factory {
final Class<T> baseType;
final String labelKey;
final Map<String, Type> 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 <T> RuntimeJsonAdapterFactory<T> of(Class<T> 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<T> 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<T> registerSubtype(Class<? extends T> 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<? extends Annotation> annotations, Moshi moshi) {
if (Types.getRawType(type) != baseType || !annotations.isEmpty()) {
return null;
}
int size = labelToType.size();
Map<Type, JsonAdapter<Object>> typeToAdapter = new LinkedHashMap<>(size);
Map<String, JsonAdapter<Object>> labelToAdapter = new LinkedHashMap<>(size);
for (Map.Entry<String, Type> entry : labelToType.entrySet()) {
String label = entry.getKey();
Type typeValue = entry.getValue();
JsonAdapter<Object> 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<Object> {
final String labelKey;
final Map<String, JsonAdapter<Object>> labelToAdapter;
final Map<Type, JsonAdapter<Object>> typeToAdapter;
RuntimeJsonAdapter(
String labelKey,
Map<String, JsonAdapter<Object>> labelToAdapter,
Map<Type, JsonAdapter<Object>> 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<String, Object> jsonObject = (Map<String, Object>) 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<Object> 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<Object> 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 + ")";
}
}
}

View File

@@ -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<Message> 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.<String, Object>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<Message> adapter = moshi.adapter(Message.class);
assertThat(adapter.toJson(new Success("Okay!")))
.isEqualTo("{\"value\":\"Okay!\"}");
assertThat(adapter.toJson(new Error(Collections.<String, Object>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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<String, Object> error_logs;
Error(Map<String, Object> 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";
}
}
}