mirror of
https://github.com/google/nomulus.git
synced 2025-05-15 00:47:11 +02:00
Add Jsonable and AbstractJsonableObject for easier RDAP object building
------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=246345611
This commit is contained in:
parent
c45d3b843c
commit
e1926dbc57
5 changed files with 929 additions and 1 deletions
531
java/google/registry/rdap/AbstractJsonableObject.java
Normal file
531
java/google/registry/rdap/AbstractJsonableObject.java
Normal file
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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)
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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}
|
||||||
|
* -----------------------
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* - 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<String> a = Optional.of("value1");
|
||||||
|
* @JsonableElement public Optional<String> 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<String> a = ImmutableList.of("value1", "value2");
|
||||||
|
* @JsonableElement("lst") public List<String> 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<String> a = ImmutableList.of("value1", "value2");
|
||||||
|
* @JsonableElement("lst[]") public String b = "value3";
|
||||||
|
* @JsonableElement("lst[]") public Optional<String> c = Optional.empty();
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* will result in:
|
||||||
|
* {
|
||||||
|
* "lst": ["value1", "value2", "value3"]
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* {@link RestrictJsonNames}
|
||||||
|
* -------------------------
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* - 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<Something> 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)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* {@link JsonableElement} with "*" for name - merge instead of sub-object
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 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.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@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.
|
||||||
|
*
|
||||||
|
* <p>We aren't using {@link Class#getFields} because that would return only the public fields.
|
||||||
|
*/
|
||||||
|
private Iterable<Field> getAllJsonableElementFields() {
|
||||||
|
ImmutableList.Builder<Field> 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.
|
||||||
|
*
|
||||||
|
* <p>We aren't using {@link Class#getMethods} because that would return only the public methods.
|
||||||
|
*/
|
||||||
|
private Iterable<Method> getAllJsonableElementMethods() {
|
||||||
|
ImmutableList.Builder<Method> builder = new ImmutableList.Builder<>();
|
||||||
|
HashSet<String> 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.
|
||||||
|
*
|
||||||
|
* <p>Empty means there are no restrictions - all names are allowed.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>A name of "*" means this is allowed to merge.
|
||||||
|
*/
|
||||||
|
static Optional<ImmutableSet<String>> 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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>If there's no @RestrictJsonNames annotation, all names are allowed.
|
||||||
|
*/
|
||||||
|
static void verifyAllowedJsonKeyName(String name, @Nullable Member member, Class<?> clazz) {
|
||||||
|
Optional<ImmutableSet<String>> 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<String, Member> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,11 +13,11 @@ java_library(
|
||||||
"//java/google/registry/model",
|
"//java/google/registry/model",
|
||||||
"//java/google/registry/request",
|
"//java/google/registry/request",
|
||||||
"//java/google/registry/request/auth",
|
"//java/google/registry/request/auth",
|
||||||
"//java/google/registry/ui/server/registrar",
|
|
||||||
"//java/google/registry/util",
|
"//java/google/registry/util",
|
||||||
"//third_party/objectify:objectify-v4_1",
|
"//third_party/objectify:objectify-v4_1",
|
||||||
"@com_google_auto_value",
|
"@com_google_auto_value",
|
||||||
"@com_google_code_findbugs_jsr305",
|
"@com_google_code_findbugs_jsr305",
|
||||||
|
"@com_google_code_gson",
|
||||||
"@com_google_dagger",
|
"@com_google_dagger",
|
||||||
"@com_google_flogger",
|
"@com_google_flogger",
|
||||||
"@com_google_flogger_system_backend",
|
"@com_google_flogger_system_backend",
|
||||||
|
|
23
java/google/registry/rdap/Jsonable.java
Normal file
23
java/google/registry/rdap/Jsonable.java
Normal file
|
@ -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();
|
||||||
|
}
|
373
javatests/google/registry/rdap/AbstractJsonableObjectTest.java
Normal file
373
javatests/google/registry/rdap/AbstractJsonableObjectTest.java
Normal file
|
@ -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<String> 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<String> myList = ImmutableList.of("my", "immutable", "list");
|
||||||
|
@JsonableElement("myList")
|
||||||
|
ImmutableList<Number> 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<String> 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<String> myList = ImmutableList.of("my", "immutable", "list");
|
||||||
|
@JsonableElement("myList[]")
|
||||||
|
Optional<Integer> myListMeaningOfLife = Optional.of(42);
|
||||||
|
@JsonableElement("myList[]")
|
||||||
|
Optional<Double> 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<Integer> 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<JsonableWithNameRestrictions> 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<String> 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'}"));
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,6 +23,7 @@ java_library(
|
||||||
"//third_party/objectify:objectify-v4_1",
|
"//third_party/objectify:objectify-v4_1",
|
||||||
"@com_google_appengine_api_1_0_sdk",
|
"@com_google_appengine_api_1_0_sdk",
|
||||||
"@com_google_code_findbugs_jsr305",
|
"@com_google_code_findbugs_jsr305",
|
||||||
|
"@com_google_code_gson",
|
||||||
"@com_google_dagger",
|
"@com_google_dagger",
|
||||||
"@com_google_guava",
|
"@com_google_guava",
|
||||||
"@com_google_monitoring_client_contrib",
|
"@com_google_monitoring_client_contrib",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue