From 336a34b95a1a9517f9711afe2ecd1e4485718a90 Mon Sep 17 00:00:00 2001 From: guyben Date: Thu, 2 May 2019 10:37:46 -0700 Subject: [PATCH] Add Jsonable and AbstractJsonableObject for easier RDAP object building ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=246345611 --- .../registry/rdap/AbstractJsonableObject.java | 531 ++++++++++++++++++ java/google/registry/rdap/BUILD | 2 +- java/google/registry/rdap/Jsonable.java | 23 + .../rdap/AbstractJsonableObjectTest.java | 373 ++++++++++++ javatests/google/registry/rdap/BUILD | 1 + 5 files changed, 929 insertions(+), 1 deletion(-) create mode 100644 java/google/registry/rdap/AbstractJsonableObject.java create mode 100644 java/google/registry/rdap/Jsonable.java create mode 100644 javatests/google/registry/rdap/AbstractJsonableObjectTest.java 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",