Add VKey to String and String to VKey methods (#1396)

* Add stringify and parse methods to SerializeUTils

* Improve comments and test cases

* Fix comments and test strings

* Fix dependency warning
This commit is contained in:
Rachel Guan 2021-11-02 13:25:35 -04:00 committed by GitHub
parent 230daeeab7
commit adb82565db
6 changed files with 325 additions and 1 deletions

View file

@ -18,10 +18,13 @@ import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static google.registry.util.PreconditionsUtils.checkArgumentNotNull;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableMap;
import com.googlecode.objectify.Key;
import google.registry.model.BackupGroupRoot;
import google.registry.model.ImmutableObject;
import google.registry.model.translators.VKeyTranslatorFactory;
import google.registry.util.SerializeUtils;
import java.io.Serializable;
import java.util.Optional;
import javax.annotation.Nullable;
@ -36,6 +39,15 @@ public class VKey<T> extends ImmutableObject implements Serializable {
private static final long serialVersionUID = -5291472863840231240L;
// Info that's stored in in vkey string generated via stringify().
private static final String SQL_LOOKUP_KEY = "sql";
private static final String OFY_LOOKUP_KEY = "ofy";
private static final String CLASS_TYPE = "kind";
// Web safe delimiters that won't be used in base 64.
private static final String KV_SEPARATOR = ":";
private static final String DELIMITER = "@";
// The SQL key for the referenced entity.
Serializable sqlKey;
@ -114,6 +126,47 @@ public class VKey<T> extends ImmutableObject implements Serializable {
return new VKey<T>(kind, Key.create(kind, name), name);
}
/**
* Constructs a {@link VKey} from the string representation of a vkey.
*
* <p>There are two types of string representations: 1) existing ofy key string handled by
* fromWebsafeKey() and 2) string encoded via stringify() where @ separates the substrings and
* each of the substrings contains a look up key, ":", and its corresponding value. The key info
* is encoded via Base64. The string begins with "kind:" and it must contains at least ofy key or
* sql key.
*
* <p>Example of a Vkey string by fromWebsafeKey(): "agR0ZXN0chYLEgpEb21haW5CYXNlIgZST0lELTEM"
*
* <p>Example of a vkey string by stringify(): "google.registry.testing.TestObject@sql:rO0ABX" +
* "QAA2Zvbw@ofy:agR0ZXN0cjELEg9FbnRpdHlHcm91cFJvb3QiCWNyb3NzLXRsZAwLEgpUZXN0T2JqZWN0IgNmb28M",
* where sql key and ofy key are values are encoded in Base64.
*/
public static <T> VKey<T> create(String keyString) throws Exception {
if (!keyString.startsWith(CLASS_TYPE + KV_SEPARATOR)) {
// to handle the existing ofy key string
return fromWebsafeKey(keyString);
} else {
ImmutableMap<String, String> kvs =
ImmutableMap.copyOf(
Splitter.on(DELIMITER).withKeyValueSeparator(KV_SEPARATOR).split(keyString));
Class classType = Class.forName(kvs.get(CLASS_TYPE));
if (kvs.containsKey(SQL_LOOKUP_KEY) && kvs.containsKey(OFY_LOOKUP_KEY)) {
return VKey.create(
classType,
SerializeUtils.parse(Serializable.class, kvs.get(SQL_LOOKUP_KEY)),
Key.create(kvs.get(OFY_LOOKUP_KEY)));
} else if (kvs.containsKey(SQL_LOOKUP_KEY)) {
return VKey.createSql(
classType, SerializeUtils.parse(Serializable.class, kvs.get(SQL_LOOKUP_KEY)));
} else if (kvs.containsKey(OFY_LOOKUP_KEY)) {
return VKey.createOfy(classType, Key.create(kvs.get(OFY_LOOKUP_KEY)));
} else {
throw new IllegalArgumentException(String.format("Cannot parse key string: %s", keyString));
}
}
}
/**
* Returns a clone with an ofy key restored from {@code ancestors}.
*
@ -233,4 +286,29 @@ public class VKey<T> extends ImmutableObject implements Serializable {
public static <T> VKey<T> fromWebsafeKey(String ofyKeyRepr) {
return from(Key.create(ofyKeyRepr));
}
/**
* Constructs the string representation of a {@link VKey}.
*
* <p>The string representation of a vkey contains its type, and sql key or ofy key, or both. Each
* of the keys is first serialized into a byte array then encoded via Base64 into a web safe
* string.
*
* <p>The string representation of a vkey contains key values pairs separated by delimiter "@".
* Another delimiter ":" is put in between each key and value. The following is the complete
* format of the string: "kind:class_name@sql:encoded_sqlKey@ofy:encoded_ofyKey", where kind is
* required. The string representation may contain an encoded ofy key, or an encoded sql key, or
* both.
*/
public String stringify() {
// class type is required to create a vkey
String key = CLASS_TYPE + KV_SEPARATOR + getKind().getName();
if (maybeGetSqlKey().isPresent()) {
key += DELIMITER + SQL_LOOKUP_KEY + KV_SEPARATOR + SerializeUtils.stringify(getSqlKey());
}
if (maybeGetOfyKey().isPresent()) {
key += DELIMITER + OFY_LOOKUP_KEY + KV_SEPARATOR + getOfyKey().getString();
}
return key;
}
}

View file

@ -24,8 +24,10 @@ import com.googlecode.objectify.annotation.Entity;
import google.registry.model.billing.BillingEvent.OneTime;
import google.registry.model.domain.DomainBase;
import google.registry.model.registrar.RegistrarContact;
import google.registry.model.translators.VKeyTranslatorFactory;
import google.registry.testing.AppEngineExtension;
import google.registry.testing.TestObject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@ -39,6 +41,11 @@ class VKeyTest {
.withOfyTestEntities(TestObject.class)
.build();
@BeforeAll
static void beforeAll() {
VKeyTranslatorFactory.addTestEntityClass(TestObject.class);
}
@Test
void testOptionalAccessors() {
VKey<TestObject> key =
@ -130,6 +137,176 @@ class VKeyTest {
assertThat(vkey.getSqlKey()).isEqualTo("ROID-1");
}
/** Test stringify() with vkey created via different ways. */
@Test
void testStringify_sqlOnlyVKey() throws Exception {
assertThat(VKey.createSql(TestObject.class, "foo").stringify())
.isEqualTo("kind:google.registry.testing.TestObject@sql:rO0ABXQAA2Zvbw");
}
@Test
void testStringify_ofyOnlyVKey() throws Exception {
assertThat(VKey.createOfy(TestObject.class, Key.create(TestObject.class, "foo")).stringify())
.isEqualTo(
"kind:google.registry.testing.TestObject@ofy:agR0ZXN0chMLEgpUZXN0T2JqZWN0IgNmb28M");
}
@Test
void testStringify_vkeyFromWebsafeKey() throws Exception {
DomainBase domain = newDomainBase("example.com", "ROID-1", persistActiveContact("contact-1"));
Key<DomainBase> key = Key.create(domain);
VKey<DomainBase> vkey = VKey.fromWebsafeKey(key.getString());
assertThat(vkey.stringify())
.isEqualTo(
"kind:google.registry.model.domain.DomainBas"
+ "e@sql:rO0ABXQABlJPSUQtMQ"
+ "@ofy:agR0ZXN0chYLEgpEb21haW5CYXNlIgZST0lELTEM");
}
@Test
void testStringify_sqlAndOfyVKey() throws Exception {
assertThat(
VKey.create(TestObject.class, "foo", Key.create(TestObject.create("foo"))).stringify())
.isEqualTo(
"kind:google.registry.testing.TestObject@sql:rO0ABXQAA2Zvbw@ofy:agR0ZXN0cjELEg9FbnRpdH"
+ "lHcm91cFJvb3QiCWNyb3NzLXRsZAwLEgpUZXN0T2JqZWN0IgNmb28M");
}
@Test
void testStringify_asymmetricVKey() throws Exception {
assertThat(
VKey.create(TestObject.class, "test", Key.create(TestObject.create("foo"))).stringify())
.isEqualTo(
"kind:google.registry.testing.TestObject@sql:rO0ABXQABHRlc3Q@ofy:agR0ZXN0cjELEg9FbnRpd"
+ "HlHcm91cFJvb3QiCWNyb3NzLXRsZAwLEgpUZXN0T2JqZWN0IgNmb28M");
}
/** Test create() via different vkey string representations. */
@Test
void testCreate_stringifedVKey_sqlOnlyVKeyString() throws Exception {
assertThat(VKey.create("kind:google.registry.testing.TestObject@sql:rO0ABXQAA2Zvbw"))
.isEqualTo(VKey.createSql(TestObject.class, "foo"));
}
@Test
void testCreate_stringifedVKey_ofyOnlyVKeyString() throws Exception {
assertThat(
VKey.create(
"kind:google.registry.testing.TestObject@ofy:agR0ZXN0chMLEgpUZXN0T2JqZWN0IgNmb28M"))
.isEqualTo(VKey.createOfy(TestObject.class, Key.create(TestObject.class, "foo")));
}
@Test
void testCreate_stringifedVKey_asymmetricVKeyString() throws Exception {
assertThat(
VKey.create(
"kind:google.registry.testing.TestObject@sql:rO0ABXQABHRlc3Q@ofy:agR0ZXN0cjELEg9Fb"
+ "nRpdHlHcm91cFJvb3QiCWNyb3NzLXRsZAwLEgpUZXN0T2JqZWN0IgNmb28M"))
.isEqualTo(VKey.create(TestObject.class, "test", Key.create(TestObject.create("foo"))));
}
@Test
void testCreate_stringifedVKey_sqlAndOfyVKeyString() throws Exception {
assertThat(
VKey.create(
"kind:google.registry.testing.TestObject@sql:rO0ABXQAA2Zvbw@ofy:agR0ZXN0cjELEg9Fbn"
+ "RpdHlHcm91cFJvb3QiCWNyb3NzLXRsZAwLEgpUZXN0T2JqZWN0IgNmb28M"))
.isEqualTo(VKey.create(TestObject.class, "foo", Key.create(TestObject.create("foo"))));
}
@Test
void testCreate_stringifyVkey_fromWebsafeKey() throws Exception {
assertThat(
VKey.create(
"kind:google.registry.model.domain.DomainBase@sql:rO0ABXQABlJPSUQtMQ"
+ "@ofy:agR0ZXN0chYLEgpEb21haW5CYXNlIgZST0lELTEM"))
.isEqualTo(
VKey.fromWebsafeKey(
Key.create(
newDomainBase("example.com", "ROID-1", persistActiveContact("contact-1")))
.getString()));
}
@Test
void testCreate_stringifedVKey_websafeKey() throws Exception {
assertThat(VKey.create("agR0ZXN0chYLEgpEb21haW5CYXNlIgZST0lELTEM"))
.isEqualTo(VKey.fromWebsafeKey("agR0ZXN0chYLEgpEb21haW5CYXNlIgZST0lELTEM"));
}
@Test
void testCreate_invalidStringifiedVKey_failure() throws Exception {
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> VKey.create("kind:google.registry.testing.TestObject@sq:l@ofya:bc"));
assertThat(thrown)
.hasMessageThat()
.contains("Cannot parse key string: kind:google.registry.testing.TestObject@sq:l@ofya:bc");
}
@Test
void testCreate_invalidOfyKeyString_failure() throws Exception {
IllegalArgumentException thrown =
assertThrows(IllegalArgumentException.class, () -> VKey.create("invalid"));
assertThat(thrown).hasMessageThat().contains("Could not parse Reference");
}
/** Test stringify() then create() flow. */
@Test
void testStringifyThenCreate_sqlOnlyVKey_testObject_stringKey_success() throws Exception {
VKey<TestObject> vkey = VKey.createSql(TestObject.class, "foo");
VKey<TestObject> newVkey = VKey.create(vkey.stringify());
assertThat(newVkey).isEqualTo(vkey);
}
@Test
void testStringifyThenCreate_sqlOnlyVKey_testObject_longKey_success() throws Exception {
VKey<TestObject> vkey = VKey.createSql(TestObject.class, (long) 12345);
VKey<TestObject> newVkey = VKey.create(vkey.stringify());
assertThat(newVkey).isEqualTo(vkey);
}
@Test
void testCreate_createFromExistingOfyKey_success() throws Exception {
String keyString =
Key.create(newDomainBase("example.com", "ROID-1", persistActiveContact("contact-1")))
.getString();
assertThat(VKey.fromWebsafeKey(keyString)).isEqualTo(VKey.create(keyString));
}
@Test
void testStringifyThenCreate_ofyOnlyVKey_testObject_success() throws Exception {
VKey<TestObject> vkey =
VKey.createOfy(TestObject.class, Key.create(TestObject.class, "tmpKey"));
assertThat(VKey.create(vkey.stringify())).isEqualTo(vkey);
}
@Test
void testStringifyThenCreate_ofyOnlyVKey_testObject_websafeString_success() throws Exception {
VKey<TestObject> vkey = VKey.fromWebsafeKey(Key.create(TestObject.create("foo")).getString());
assertThat(VKey.create(vkey.stringify())).isEqualTo(vkey);
}
@Test
void testStringifyThenCreate_sqlAndOfyVKey_success() throws Exception {
VKey<TestObject> vkey =
VKey.create(TestObject.class, "foo", Key.create(TestObject.create("foo")));
assertThat(VKey.create(vkey.stringify())).isEqualTo(vkey);
}
@Test
void testStringifyThenCreate_asymmetricVKey_success() throws Exception {
VKey<TestObject> vkey =
VKey.create(TestObject.class, "sqlKey", Key.create(TestObject.create("foo")));
assertThat(VKey.create(vkey.stringify())).isEqualTo(vkey);
}
@Test
void testStringifyThenCreate_symmetricVKey_success() throws Exception {
VKey<TestObject> vkey = TestObject.create("foo").key();
assertThat(VKey.create(vkey.stringify())).isEqualTo(vkey);
}
@Entity
static class OtherObject {}
}

View file

@ -96,6 +96,7 @@ ext {
'com.googlecode.json-simple:json-simple:1.1.1',
'com.ibm.icu:icu4j:68.2',
'com.jcraft:jsch:0.1.55',
'commons-codec:commons-codec:1.15',
'com.squareup:javapoet:1.13.0',
'com.sun.activation:javax.activation:1.2.0',
'com.sun.xml.bind:jaxb-impl:2.3.3',

View file

@ -31,6 +31,7 @@ dependencies {
compile deps['com.google.protobuf:protobuf-java']
compile deps['com.google.re2j:re2j']
compile deps['com.ibm.icu:icu4j']
compile deps['commons-codec:commons-codec']
compile deps['javax.inject:javax.inject']
compile deps['javax.mail:mail']
compile deps['javax.xml.bind:jaxb-api']

View file

@ -22,7 +22,9 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import javax.annotation.Nullable;
import org.apache.commons.codec.binary.Base64;
/** Utilities for easy serialization with informative error messages. */
public final class SerializeUtils {
@ -47,7 +49,7 @@ public final class SerializeUtils {
}
/**
* Turns a byte array into an object.
* Turns a byte string into an object.
*
* @return deserialized object or {@code null} if {@code objectBytes} is {@code null}
*/
@ -71,4 +73,19 @@ public final class SerializeUtils {
}
private SerializeUtils() {}
/** Turns an object into an encoded string that can be used safely as a URI query parameter. */
public static String stringify(Serializable object) {
checkNotNull(object, "Object cannot be null");
return Base64.encodeBase64URLSafeString(SerializeUtils.serialize(object));
}
/** Turns a string encoded by stringify() into an object. */
@Nullable
public static <T> T parse(Class<T> type, String objectString) {
checkNotNull(type, "Class type is not specified");
checkNotNull(objectString, "Object string cannot be null");
return SerializeUtils.deserialize(type, Base64.decodeBase64(objectString));
}
}

View file

@ -16,9 +16,12 @@ package google.registry.util;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.util.SerializeUtils.deserialize;
import static google.registry.util.SerializeUtils.parse;
import static google.registry.util.SerializeUtils.serialize;
import static google.registry.util.SerializeUtils.stringify;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.Serializable;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link SerializeUtils}. */
@ -61,4 +64,51 @@ class SerializeUtilsTest {
() -> deserialize(String.class, new byte[] {(byte) 0xff}));
assertThat(thrown).hasMessageThat().contains("Unable to deserialize: objectBytes=FF");
}
@Test
void testStringify_string_returnsBase64EncodedString() {
assertThat(stringify("foo")).isEqualTo("rO0ABXQAA2Zvbw");
}
@Test
void testParse_stringClass_returnsObject() {
assertThat(parse(String.class, "rO0ABXQAA2Zvbw")).isEqualTo("foo");
}
@Test
void testStringifyParse_stringValue_maintainsValue() {
assertThat(parse(Serializable.class, stringify("hello"))).isEqualTo("hello");
}
@Test
void testStringifyParse_longValue_maintainsValue() {
assertThat(parse(Serializable.class, stringify((long) 12345))).isEqualTo((long) 12345);
}
@Test
void testStringify_nullValue_throwsException() {
NullPointerException thrown = assertThrows(NullPointerException.class, () -> stringify(null));
assertThat(thrown).hasMessageThat().contains("Object cannot be null");
}
@Test
void testParse_nullClass_throwsException() {
NullPointerException thrown =
assertThrows(NullPointerException.class, () -> parse(null, "test"));
assertThat(thrown).hasMessageThat().contains("Class type is not specified");
}
@Test
void testParse_invalidBase64String_throwsException() {
IllegalArgumentException thrown =
assertThrows(IllegalArgumentException.class, () -> parse(String.class, "abcde:atest"));
assertThat(thrown).hasMessageThat().contains("Unable to deserialize");
}
@Test
void testParse_nullObjectStringValue_throwsException() {
NullPointerException thrown =
assertThrows(NullPointerException.class, () -> parse(String.class, null));
assertThat(thrown).hasMessageThat().contains("Object string cannot be null");
}
}