// 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.Map; 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 (Map.Entry entry : otherJsonObject.entrySet()) { String name = entry.getKey(); JsonElement otherElement = entry.getValue(); 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); } } }