diff --git a/java/google/registry/rdap/AbstractJsonableObject.java b/java/google/registry/rdap/AbstractJsonableObject.java
new file mode 100644
index 000000000..5c6b53c28
--- /dev/null
+++ b/java/google/registry/rdap/AbstractJsonableObject.java
@@ -0,0 +1,531 @@
+// Copyright 2019 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.rdap;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Ordering;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.lang.reflect.Field;
+import java.lang.reflect.Member;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import org.joda.time.DateTime;
+
+/**
+ * An Jsonable that can turn itself into a JSON object using reflection.
+ *
+ *
This can only be used to create JSON *objects*, so if your class needs a different JSON type,
+ * you'll have to implement Jsonable yourself. (for example, VCards objects are represented as a
+ * list rather than an object)
+ *
+ *
You can annotate fields or methods with 0 parameters with {@link JsonElement}, and its value
+ * will be "JSONified" and added to the generated JSON object.
+ *
+ *
This implementation is geared towards RDAP replies, and hence has RDAP-specific quirks.
+ * Specifically:
+ *
+ * - Fields with empty arrays are not shown at all
+ *
+ * - VCards are a built-in special case (Not implemented yet)
+ *
+ * - DateTime conversion is specifically supported as if it were a primitive
+ *
+ * - Arrays are considered to be SETS rather than lists, meaning repeated values are removed and the
+ * order isn't guaranteed
+ *
+ * Usage:
+ *
+ * {@link JsonableElement}
+ * -----------------------
+ *
+ *
+ * - JsonableElement annotates Members that become JSON object fields:
+ *
+ * class Something extends AbstractJsonableObject {
+ * @JsonableElement public String a = "value1";
+ * @JsonableElement public String b() {return "value2";}
+ * }
+ *
+ * will result in:
+ * {
+ * "a": "value1",
+ * "b": "value2"
+ * }
+ *
+ * - Passing a name to JsonableElement overrides the Member's name. Multiple elements with the same
+ * name is an error (except for lists - see later)
+ *
+ * class Something extends AbstractJsonableObject {
+ * @JsonableElement("b") public String a = "value1";
+ * }
+ *
+ * will result in:
+ * {
+ * "b": "value1"
+ * }
+ *
+ * - the supported object types are String, Boolean, Number, DateTime, Jsonable. In addition,
+ * Iterable and Optional are respected.
+ *
+ * - An Optional that's empty is skipped, while a present Optional acts exactly like the object it
+ * wraps. Null values are errors.
+ *
+ * class Something extends AbstractJsonableObject {
+ * @JsonableElement public Optional a = Optional.of("value1");
+ * @JsonableElement public Optional b = Optional.empty();
+ * }
+ *
+ * will result in:
+ * {
+ * "a": "value1"
+ * }
+ *
+ * - An Iterable will turn into an array. Multiple Iterables with the same name are merged. Remember
+ * - arrays are treated as "sets" here.
+ *
+ * class Something extends AbstractJsonableObject {
+ * @JsonableElement("lst") public List a = ImmutableList.of("value1", "value2");
+ * @JsonableElement("lst") public List b = ImmutableList.of("value2", "value3");
+ * }
+ *
+ * will result in:
+ * {
+ * "lst": ["value1", "value2", "value3"]
+ * }
+ *
+ * - A single element with a [] after its name is added to the array as if it were wrapped in a list
+ * with one element. Optionals are still respected (an empty Optional is skipped).
+ *
+ * class Something extends AbstractJsonableObject {
+ * @JsonableElement("lst") public List a = ImmutableList.of("value1", "value2");
+ * @JsonableElement("lst[]") public String b = "value3";
+ * @JsonableElement("lst[]") public Optional c = Optional.empty();
+ * }
+ *
+ * will result in:
+ * {
+ * "lst": ["value1", "value2", "value3"]
+ * }
+ *
+ *
+ * {@link RestrictJsonNames}
+ * -------------------------
+ *
+ *
+ * - RestrictJsonNames is a way to prevent typos in the JsonableElement names.
+ *
+ * - If it annotates a Jsonable class declaration, this class can only be annotated with
+ * JsonableElements with one of the allowed names
+ *
+ * @RestrictJsonNames({"key", "lst[]"})
+ * class Something implements Jsonable {...}
+ *
+ * means that Something can only be used as an element named "key", OR as an element in an array
+ * named "lst".
+ *
+ * @JsonableElement public Something something; // ERROR
+ * @JsonableElement("something") public Something key; // ERROR
+ * @JsonableElement public Something key; // OK
+ * @JsonableElement("key") public Something something; // OK
+ * @JsonableElement("lst") public List myList; // OK
+ * @JsonableElement("lst[]") public Something something; // OK
+ *
+ * - @RestrictJsonNames({}) means this Jsonable can't be inserted into an AbstractJsonableObject at
+ * all. It's useful for "outer" Jsonable that are to be returned as is, or for
+ * AbstractJsonableObject only used for JsonableElement("*") (Merging - see next)
+ *
+ *
+ * {@link JsonableElement} with "*" for name - merge instead of sub-object
+ * -----------------------------------------------------------------------
+ *
+ *
+ * The special name "*" means we want to merge the object with the current object instead of having
+ * it a value for the name key.
+ *
+ * THIS MIGHT BE REMOVED LATER, we'll see how it goes. Currently it's only used in one place and
+ * might not be worth it.
+ *
+ * - JsonableElement("*") annotates an AbstractJsonableObject Member that is to be merged with the
+ * current AbstractJsonableObject, instead of being a sub-object
+ *
+ * class Something extends AbstractJsonableObject {
+ * @JsonableElement public String a = "value1";
+ * }
+ *
+ * class Other extends AbstractJsonableObject {
+ * @JsonableElement("*") public Something something = new Something();
+ * @JsonableElement public String b = "value2";
+ * }
+ *
+ * Other will result in:
+ * {
+ * "a": "value1",
+ * "b": "value2"
+ * }
+ *
+ * - Arrays of the same name are merges (remember, they are considered sets so duplicates are
+ * removed), but elements with the same name are an error
+ *
+ * class Something extends AbstractJsonableObject {
+ * @JsonableElement("lst[]") public String a = "value1";
+ * }
+ *
+ * class Other extends AbstractJsonableObject {
+ * @JsonableElement("*") public Something something = new Something();
+ * @JsonableElement("lst[]") public String b = "value2";
+ * }
+ *
+ * Other will result in:
+ * {
+ * "lst": ["value1", "value2"]
+ * }
+ *
+ * - Optionals are still respected. An empty JsonableElement("*") is skipped.
+ *
+ */
+@SuppressWarnings("InvalidBlockTag")
+abstract class AbstractJsonableObject implements Jsonable {
+
+ private static final String ARRAY_NAME_SUFFIX = "[]";
+
+ private static final String MERGE_NAME = "*";
+
+ @Target({ElementType.METHOD, ElementType.FIELD})
+ @Retention(RUNTIME)
+ @interface JsonableElement {
+ String value() default "";
+ }
+
+ @Target(ElementType.TYPE)
+ @Retention(RUNTIME)
+ @interface RestrictJsonNames {
+ String[] value();
+ }
+
+ @Override
+ public final JsonObject toJson() {
+ try {
+ JsonObjectBuilder builder = new JsonObjectBuilder();
+
+ for (Field field : getAllJsonableElementFields()) {
+ JsonableElement jsonableElement = field.getAnnotation(JsonableElement.class);
+ Object object;
+ try {
+ field.setAccessible(true);
+ object = field.get(this);
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException(
+ String.format("Error reading value of field '%s'", field), e);
+ } finally {
+ field.setAccessible(false);
+ }
+ builder.add(jsonableElement, field, object);
+ }
+
+ for (Method method : getAllJsonableElementMethods()) {
+ JsonableElement jsonableElement = method.getAnnotation(JsonableElement.class);
+ Object object;
+ try {
+ method.setAccessible(true);
+ object = method.invoke(this);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(
+ String.format("Error reading value of method '%s'", method), e);
+ } finally {
+ method.setAccessible(false);
+ }
+ builder.add(jsonableElement, method, object);
+ }
+
+ return builder.build();
+ } catch (Throwable e) {
+ throw new JsonableException(
+ e, String.format("Error JSONifying %s: %s", this.getClass(), e.getMessage()));
+ }
+ }
+
+ /**
+ * Get all the fields declared on this class.
+ *
+ * We aren't using {@link Class#getFields} because that would return only the public fields.
+ */
+ private Iterable getAllJsonableElementFields() {
+ ImmutableList.Builder builder = new ImmutableList.Builder<>();
+ for (Class> clazz = this.getClass();
+ clazz != null;
+ clazz = clazz.getSuperclass()) {
+ for (Field field : clazz.getDeclaredFields()) {
+ if (!field.isAnnotationPresent(JsonableElement.class)) {
+ continue;
+ }
+ builder.add(field);
+ }
+ }
+ // Sorting for test consistency
+ return Ordering.natural().onResultOf(Field::getName).sortedCopy(builder.build());
+ }
+
+ /**
+ * Get all the methods declared on this class.
+ *
+ * We aren't using {@link Class#getMethods} because that would return only the public methods.
+ */
+ private Iterable getAllJsonableElementMethods() {
+ ImmutableList.Builder builder = new ImmutableList.Builder<>();
+ HashSet seenNames = new HashSet<>();
+ for (Class> clazz = this.getClass();
+ clazz != null;
+ clazz = clazz.getSuperclass()) {
+ for (Method method : clazz.getDeclaredMethods()) {
+ if (!method.isAnnotationPresent(JsonableElement.class)) {
+ continue;
+ }
+ checkState(method.getParameterCount() == 0, "Method '%s' must have no arguments", method);
+ if (!seenNames.add(method.getName())) {
+ // We've seen the same function in a sub-class, so we already added the overridden
+ // version.
+ continue;
+ }
+ builder.add(method);
+ }
+ }
+ // Sorting for test consistency
+ return Ordering.natural().onResultOf(Method::getName).sortedCopy(builder.build());
+ }
+
+ /** Converts an Object to a JsonElement. */
+ private static JsonElement toJsonElement(String name, Member member, Object object) {
+ if (object instanceof Jsonable) {
+ Jsonable jsonable = (Jsonable) object;
+ verifyAllowedJsonKeyName(name, member, jsonable.getClass());
+ return jsonable.toJson();
+ }
+ if (object instanceof String) {
+ return new JsonPrimitive((String) object);
+ }
+ if (object instanceof Number) {
+ return new JsonPrimitive((Number) object);
+ }
+ if (object instanceof Boolean) {
+ return new JsonPrimitive((Boolean) object);
+ }
+ if (object instanceof DateTime) {
+ // According to RFC7483 section 3, the syntax of dates and times is defined in RFC3339.
+ //
+ // According to RFC3339, we should use ISO8601, which is what DateTime.toString does!
+ return new JsonPrimitive(((DateTime) object).toString());
+ }
+ if (object == null) {
+ return JsonNull.INSTANCE;
+ }
+ throw new IllegalArgumentException(
+ String.format(
+ "Unknows object type '%s' in member '%s'",
+ object.getClass(), member));
+ }
+
+ /**
+ * Finds all the name restrictions on the given class.
+ *
+ * Empty means there are no restrictions - all names are allowed.
+ *
+ *
If not empty - the resulting list is the allowed names. If the name ends with [], it means
+ * the class is an element in a array with this name.
+ *
+ *
A name of "*" means this is allowed to merge.
+ */
+ static Optional> getNameRestriction(Class> clazz) {
+ // Find the first superclass that has an RestrictJsonNames annotation.
+ //
+ // The reason we don't use @Inherited on the annotation instead is that we want a good error
+ // message - we want to tell the user exactly which class was annotated with the
+ // RestrictJsonNames if there was an error!
+ for (; clazz != null; clazz = clazz.getSuperclass()) {
+ RestrictJsonNames restrictJsonFields = clazz.getAnnotation(RestrictJsonNames.class);
+ if (restrictJsonFields == null) {
+ continue;
+ }
+ return Optional.of(ImmutableSet.copyOf(restrictJsonFields.value()));
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Makes sure that the name of this element is allowed on the resulting object.
+ *
+ * A name is not allowed if the object's class (or one of its ancesstors) is annotated
+ * with @RestrictJsonNames and the name isn't in that list.
+ *
+ *
If there's no @RestrictJsonNames annotation, all names are allowed.
+ */
+ static void verifyAllowedJsonKeyName(String name, @Nullable Member member, Class> clazz) {
+ Optional> allowedFieldNames = getNameRestriction(clazz);
+ if (!allowedFieldNames.isPresent()) {
+ return;
+ }
+ checkState(
+ !allowedFieldNames.get().isEmpty(),
+ "Object of type '%s' is annotated with an empty "
+ + "RestrictJsonNames, so it can't be on member '%s'",
+ clazz,
+ member);
+ checkState(
+ allowedFieldNames.get().contains(name),
+ "Object of type '%s' must be named one of ['%s'], but is named '%s' on member '%s'",
+ clazz,
+ Joiner.on("', '").join(allowedFieldNames.get()),
+ name,
+ member);
+ }
+
+ private static final class JsonObjectBuilder {
+ private final JsonObject jsonObject = new JsonObject();
+ private final HashMap seenNames = new HashMap<>();
+
+ void add(JsonableElement jsonableElement, Member member, Object object) {
+ checkNotNull(
+ object, "Member '%s' is null. If you want an optional member - use Optional", member);
+
+ // We ignore any Optional that are empty, as if they didn't exist at all
+ if (object instanceof Optional) {
+ Optional> optional = (Optional>) object;
+ if (!optional.isPresent()) {
+ return;
+ }
+ object = optional.get();
+ }
+
+ // First, if this is a Merge element, merge it with the current elements
+ if (MERGE_NAME.equals(jsonableElement.value())) {
+ // We want to merge this member with the current member.
+ // Recursively get all the ElementData of this member
+ checkState(
+ object instanceof AbstractJsonableObject,
+ "JsonableElement(\"*\") annotating a non-AbstractJsonableObject object in '%s'",
+ member);
+ AbstractJsonableObject jsonableObject = (AbstractJsonableObject) object;
+ mergeWith(jsonableObject.toJson(), member);
+ return;
+ }
+
+ String name =
+ jsonableElement.value().isEmpty() ? member.getName() : jsonableElement.value();
+
+ // If this is an Iterable, return a stream of the inner elements
+ if (object instanceof Iterable) {
+ checkState(
+ !name.endsWith(ARRAY_NAME_SUFFIX),
+ "Error in JsonableElement(\"%s\") on '%s': Can't have array of arrays",
+ name,
+ member);
+ // Adds each element individually into the array
+ for (Object innerObject : (Iterable>) object) {
+ addObjectIntoArray(name, member, innerObject);
+ }
+ return;
+ }
+
+ if (name.endsWith(ARRAY_NAME_SUFFIX)) {
+ // If the name ends with the "array suffix", add it as if it's an element in an iterable
+ addObjectIntoArray(
+ name.substring(0, name.length() - ARRAY_NAME_SUFFIX.length()), member, object);
+ } else {
+ // Otherwise, add the object as-is
+ addObject(name, member, object);
+ }
+ }
+
+ JsonObject build() {
+ return jsonObject;
+ }
+
+ private void mergeWith(JsonObject otherJsonObject, Member member) {
+ for (String name : otherJsonObject.keySet()) {
+ JsonElement otherElement = otherJsonObject.get(name);
+ JsonElement ourElement = jsonObject.get(name);
+ if (ourElement == null) {
+ jsonObject.add(name, otherElement);
+ seenNames.put(name, member);
+ } else {
+ // Both this and the other object have element with the same name. That's only OK if that
+ // element is an array - in which case we merge the arrays.
+ checkState((ourElement instanceof JsonArray) && (otherElement instanceof JsonArray),
+ "Encountered the same field name '%s' multiple times: '%s' vs. '%s'",
+ name,
+ member,
+ seenNames.get(name));
+ ((JsonArray) ourElement).addAll((JsonArray) otherElement);
+ }
+ }
+
+ }
+
+ private void addObject(String name, Member member, Object object) {
+ checkState(
+ !jsonObject.has(name),
+ "Encountered the same field name '%s' multiple times: '%s' vs. '%s'",
+ name,
+ member,
+ seenNames.get(name));
+ seenNames.put(name, member);
+ jsonObject.add(name, toJsonElement(name, member, object));
+ }
+
+ private void addObjectIntoArray(String name, Member member, Object object) {
+ JsonElement innerElement = jsonObject.get(name);
+ JsonArray jsonArray;
+ if (innerElement == null) {
+ jsonArray = new JsonArray();
+ jsonObject.add(name, jsonArray);
+ } else {
+ checkState(innerElement instanceof JsonArray,
+ "Encountered the same field name '%s' multiple times: '%s' vs. '%s'",
+ name,
+ member,
+ seenNames.get(name));
+ jsonArray = (JsonArray) innerElement;
+ }
+ seenNames.put(name, member);
+ jsonArray.add(toJsonElement(name + ARRAY_NAME_SUFFIX, member, object));
+ }
+ }
+
+ static class JsonableException extends RuntimeException {
+
+ JsonableException(String message) {
+ super(message);
+ }
+
+ JsonableException(Throwable e, String message) {
+ super(message, e);
+ }
+ }
+}
diff --git a/java/google/registry/rdap/BUILD b/java/google/registry/rdap/BUILD
index 1cf803868..f1cb65f8b 100644
--- a/java/google/registry/rdap/BUILD
+++ b/java/google/registry/rdap/BUILD
@@ -13,11 +13,11 @@ java_library(
"//java/google/registry/model",
"//java/google/registry/request",
"//java/google/registry/request/auth",
- "//java/google/registry/ui/server/registrar",
"//java/google/registry/util",
"//third_party/objectify:objectify-v4_1",
"@com_google_auto_value",
"@com_google_code_findbugs_jsr305",
+ "@com_google_code_gson",
"@com_google_dagger",
"@com_google_flogger",
"@com_google_flogger_system_backend",
diff --git a/java/google/registry/rdap/Jsonable.java b/java/google/registry/rdap/Jsonable.java
new file mode 100644
index 000000000..1034c73eb
--- /dev/null
+++ b/java/google/registry/rdap/Jsonable.java
@@ -0,0 +1,23 @@
+// Copyright 2019 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.rdap;
+
+import com.google.gson.JsonElement;
+
+/** Designates something that can be turned to a JSON format. */
+interface Jsonable {
+
+ JsonElement toJson();
+}
diff --git a/javatests/google/registry/rdap/AbstractJsonableObjectTest.java b/javatests/google/registry/rdap/AbstractJsonableObjectTest.java
new file mode 100644
index 000000000..f6271086f
--- /dev/null
+++ b/javatests/google/registry/rdap/AbstractJsonableObjectTest.java
@@ -0,0 +1,373 @@
+// Copyright 2019 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.rdap;
+
+import static com.google.common.truth.Truth.assertThat;
+import static google.registry.testing.JUnitBackports.assertThrows;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonPrimitive;
+import google.registry.rdap.AbstractJsonableObject.JsonableException;
+import google.registry.rdap.AbstractJsonableObject.RestrictJsonNames;
+import java.util.Optional;
+import org.joda.time.DateTime;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class AbstractJsonableObjectTest {
+
+ private final Gson gson = new GsonBuilder().create();
+
+ private JsonElement createJson(String... lines) {
+ return gson.fromJson(Joiner.on("\n").join(lines), JsonElement.class);
+ }
+
+ @Test
+ public void testPrimitives() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement String myString = "Hello, world!";
+ @JsonableElement int myInt = 42;
+ @JsonableElement double myDouble = 3.14;
+ @JsonableElement boolean myBoolean = false;
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(
+ createJson(
+ "{'myBoolean':false,'myDouble':3.14,'myInt':42,'myString':'Hello, world!'}"));
+ }
+
+
+ @Test
+ public void testDateTime() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement DateTime dateTime = DateTime.parse("2019-01-02T13:53Z");
+ };
+ assertThat(jsonable.toJson()).isEqualTo(createJson("{'dateTime':'2019-01-02T13:53:00.000Z'}"));
+ }
+
+ @Test
+ public void testRenaming() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement("newName") String myString = "Hello, world!";
+ };
+ assertThat(jsonable.toJson()).isEqualTo(createJson("{'newName':'Hello, world!'}"));
+ }
+
+ @Test
+ public void testDuplicateNames_fails() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement String myString = "A";
+ @JsonableElement("myString") String anotherString = "B";
+ };
+ assertThat(assertThrows(JsonableException.class, () -> jsonable.toJson()))
+ .hasMessageThat().contains("Encountered the same field name 'myString' multiple times");
+ }
+
+ @Test
+ public void testMethod() {
+ Jsonable jsonable =
+ new AbstractJsonableObject() {
+ @JsonableElement String myString() {
+ return "Hello, world!";
+ }
+ };
+ assertThat(jsonable.toJson()).isEqualTo(createJson("{'myString':'Hello, world!'}"));
+ }
+
+ @Test
+ public void testMethodWithArguments_fails() {
+ Jsonable jsonable =
+ new AbstractJsonableObject() {
+ @JsonableElement String myString(String in) {
+ return in;
+ }
+ };
+ assertThat(assertThrows(JsonableException.class, () -> jsonable.toJson()))
+ .hasMessageThat().contains("must have no arguments");
+ }
+
+
+ @Test
+ public void testRecursive() {
+ Jsonable myJsonableObject = new AbstractJsonableObject() {
+ @JsonableElement double myDouble = 3.14;
+ @JsonableElement boolean myBoolean = false;
+ };
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement Jsonable internalJsonableObject = myJsonableObject;
+ @JsonableElement int myInt = 42;
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(
+ createJson(
+ "{",
+ " 'internalJsonableObject':{'myBoolean':false,'myDouble':3.14},",
+ " 'myInt':42",
+ "}"));
+ }
+
+ @Test
+ public void testList() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement
+ ImmutableList myList = ImmutableList.of("my", "immutable", "list");
+ };
+ assertThat(jsonable.toJson()).isEqualTo(createJson("{'myList':['my','immutable','list']}"));
+ }
+
+ @Test
+ public void testMultipleLists() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement
+ ImmutableList myList = ImmutableList.of("my", "immutable", "list");
+ @JsonableElement("myList")
+ ImmutableList myList2 = ImmutableList.of(42, 3.14);
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'myList':['my','immutable','list',42,3.14]}"));
+ }
+
+ @Test
+ public void testListElements() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement
+ ImmutableList myList = ImmutableList.of("my", "immutable", "list");
+ @JsonableElement("myList[]")
+ Integer myListMeaningOfLife = 42;
+ @JsonableElement("myList[]")
+ Double myListPi = 3.14;
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'myList':['my','immutable','list',42,3.14]}"));
+ }
+
+ @Test
+ public void testListElementsWithoutList() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement("myList[]")
+ Integer myListMeaningOfLife = 42;
+ @JsonableElement("myList[]")
+ Double myListPi = 3.14;
+ };
+ assertThat(jsonable.toJson()).isEqualTo(createJson("{'myList':[42,3.14]}"));
+ }
+
+ @Test
+ public void testListOptionalElements() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement
+ ImmutableList myList = ImmutableList.of("my", "immutable", "list");
+ @JsonableElement("myList[]")
+ Optional myListMeaningOfLife = Optional.of(42);
+ @JsonableElement("myList[]")
+ Optional myListPi = Optional.empty();
+ };
+ assertThat(jsonable.toJson()).isEqualTo(createJson("{'myList':['my','immutable','list',42]}"));
+ }
+
+ @Test
+ public void testList_sameNameAsElement_failes() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement("myList")
+ String myString = "A";
+ @JsonableElement("myList[]")
+ Optional myListMeaningOfLife = Optional.of(42);
+ };
+ assertThat(assertThrows(JsonableException.class, () -> jsonable.toJson()))
+ .hasMessageThat().contains("Encountered the same field name 'myList' multiple times");
+ }
+
+ @RestrictJsonNames({"allowed", "allowedList[]"})
+ private static final class JsonableWithNameRestrictions implements Jsonable {
+ @Override
+ public JsonPrimitive toJson() {
+ return new JsonPrimitive("someValue");
+ }
+ }
+
+ @RestrictJsonNames({})
+ private static final class JsonableWithNoAllowedNames implements Jsonable {
+ @Override
+ public JsonPrimitive toJson() {
+ return new JsonPrimitive("someValue");
+ }
+ }
+
+ @Test
+ public void testRestricted_withAllowedNames() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement
+ JsonableWithNameRestrictions allowed = new JsonableWithNameRestrictions();
+
+ @JsonableElement
+ ImmutableList allowedList =
+ ImmutableList.of(new JsonableWithNameRestrictions());
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'allowed':'someValue','allowedList':['someValue']}"));
+ }
+
+ @Test
+ public void testRestricted_withWrongName_failes() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement
+ JsonableWithNameRestrictions wrong = new JsonableWithNameRestrictions();
+ };
+ assertThat(assertThrows(JsonableException.class, () -> jsonable.toJson()))
+ .hasMessageThat()
+ .contains("must be named one of ['allowed', 'allowedList[]'], but is named 'wrong'");
+ }
+
+ @Test
+ public void testRestricted_withNoNamesAllowed_fails() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement
+ JsonableWithNoAllowedNames wrong = new JsonableWithNoAllowedNames();
+ };
+ assertThat(assertThrows(JsonableException.class, () -> jsonable.toJson()))
+ .hasMessageThat()
+ .contains("is annotated with an empty RestrictJsonNames");
+ }
+
+ @RestrictJsonNames({})
+ private static final class JsonableObjectWithNoAllowedNames extends AbstractJsonableObject {
+ @JsonableElement final String key = "value";
+ }
+
+ @Test
+ public void testRestricted_withNoNamesAllowed_canBeOutermost() {
+ Jsonable jsonable = new JsonableObjectWithNoAllowedNames();
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'key':'value'}"));
+ }
+
+ @Test
+ public void testRestricted_withNoNamesAllowed_canBeMerged() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement("*") final Jsonable inner = new JsonableObjectWithNoAllowedNames();
+ @JsonableElement final String otherKey = "otherValue";
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'key':'value','otherKey':'otherValue'}"));
+ }
+
+ private abstract static class BaseWithStatic extends AbstractJsonableObject {
+ @JsonableElement("messages[]")
+ static final String MESSAGE_1 = "message 1";
+ @JsonableElement("messages[]")
+ static String getMessage2() {
+ return "message 2";
+ }
+ }
+
+ private static final class InheritedWithStatic extends BaseWithStatic {
+ @JsonableElement("messages")
+ final ImmutableList moreMessages = ImmutableList.of("more messages");
+ }
+
+ @Test
+ public void testInheritingStaticMembers_works() {
+ Jsonable jsonable = new InheritedWithStatic();
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'messages':['message 1','more messages','message 2']}"));
+ }
+
+ private abstract static class BaseToOverride extends AbstractJsonableObject {
+ @JsonableElement String annotationOnlyOnBase() {
+ return "old value";
+ }
+ @JsonableElement String annotationOnBoth() {
+ return "old value";
+ }
+ String annotationOnlyOnInherited() {
+ return "old value";
+ }
+ }
+
+ private static final class InheritedOverriding extends BaseToOverride {
+ @Override
+ String annotationOnlyOnBase() {
+ return "new value";
+ }
+ @Override
+ @JsonableElement String annotationOnBoth() {
+ return "new value";
+ }
+ @Override
+ @JsonableElement String annotationOnlyOnInherited() {
+ return "new value";
+ }
+ }
+
+ @Test
+ public void testOverriding_works() {
+ Jsonable jsonable = new InheritedOverriding();
+ assertThat(jsonable.toJson())
+ .isEqualTo(
+ createJson(
+ "{",
+ " 'annotationOnlyOnBase':'new value',",
+ " 'annotationOnBoth':'new value',",
+ " 'annotationOnlyOnInherited':'new value'",
+ "}"));
+ }
+
+ @Test
+ public void testMerge_works() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement String key = "value";
+ @JsonableElement("*") Object subObject = new AbstractJsonableObject() {
+ @JsonableElement String innerKey = "innerValue";
+ };
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'key':'value','innerKey':'innerValue'}"));
+ }
+
+ @Test
+ public void testMerge_joinsArrays_works() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement("lst[]") String a = "value";
+ @JsonableElement("*") Object subObject = new AbstractJsonableObject() {
+ @JsonableElement("lst[]") String b = "innerValue";
+ };
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'lst':['value','innerValue']}"));
+ }
+
+ @Test
+ public void testMerge_multiLayer_works() {
+ Jsonable jsonable = new AbstractJsonableObject() {
+ @JsonableElement String key = "value";
+
+ @JsonableElement("*") Object middleObject = new AbstractJsonableObject() {
+ @JsonableElement String middleKey = "middleValue";
+
+ @JsonableElement("*") Object subObject = new AbstractJsonableObject() {
+ @JsonableElement String innerKey = "innerValue";
+ };
+ };
+ };
+ assertThat(jsonable.toJson())
+ .isEqualTo(createJson("{'key':'value','middleKey':'middleValue','innerKey':'innerValue'}"));
+ }
+}
diff --git a/javatests/google/registry/rdap/BUILD b/javatests/google/registry/rdap/BUILD
index 678e9c5f8..398ae0851 100644
--- a/javatests/google/registry/rdap/BUILD
+++ b/javatests/google/registry/rdap/BUILD
@@ -23,6 +23,7 @@ java_library(
"//third_party/objectify:objectify-v4_1",
"@com_google_appengine_api_1_0_sdk",
"@com_google_code_findbugs_jsr305",
+ "@com_google_code_gson",
"@com_google_dagger",
"@com_google_guava",
"@com_google_monitoring_client_contrib",