diff --git a/core/src/main/java/google/registry/persistence/VKey.java b/core/src/main/java/google/registry/persistence/VKey.java index b30af5f03..41fa7d8ee 100644 --- a/core/src/main/java/google/registry/persistence/VKey.java +++ b/core/src/main/java/google/registry/persistence/VKey.java @@ -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 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 extends ImmutableObject implements Serializable { return new VKey(kind, Key.create(kind, name), name); } + /** + * Constructs a {@link VKey} from the string representation of a vkey. + * + *

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. + * + *

Example of a Vkey string by fromWebsafeKey(): "agR0ZXN0chYLEgpEb21haW5CYXNlIgZST0lELTEM" + * + *

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 VKey create(String keyString) throws Exception { + if (!keyString.startsWith(CLASS_TYPE + KV_SEPARATOR)) { + // to handle the existing ofy key string + return fromWebsafeKey(keyString); + } else { + ImmutableMap 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 extends ImmutableObject implements Serializable { public static VKey fromWebsafeKey(String ofyKeyRepr) { return from(Key.create(ofyKeyRepr)); } + + /** + * Constructs the string representation of a {@link VKey}. + * + *

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. + * + *

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; + } } diff --git a/core/src/test/java/google/registry/persistence/VKeyTest.java b/core/src/test/java/google/registry/persistence/VKeyTest.java index 7b7bcf1d2..f2347987d 100644 --- a/core/src/test/java/google/registry/persistence/VKeyTest.java +++ b/core/src/test/java/google/registry/persistence/VKeyTest.java @@ -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 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 key = Key.create(domain); + VKey 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 vkey = VKey.createSql(TestObject.class, "foo"); + VKey newVkey = VKey.create(vkey.stringify()); + assertThat(newVkey).isEqualTo(vkey); + } + + @Test + void testStringifyThenCreate_sqlOnlyVKey_testObject_longKey_success() throws Exception { + VKey vkey = VKey.createSql(TestObject.class, (long) 12345); + VKey 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 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 vkey = VKey.fromWebsafeKey(Key.create(TestObject.create("foo")).getString()); + assertThat(VKey.create(vkey.stringify())).isEqualTo(vkey); + } + + @Test + void testStringifyThenCreate_sqlAndOfyVKey_success() throws Exception { + VKey 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 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 vkey = TestObject.create("foo").key(); + assertThat(VKey.create(vkey.stringify())).isEqualTo(vkey); + } + @Entity static class OtherObject {} } diff --git a/dependencies.gradle b/dependencies.gradle index 9685e360f..da0172d34 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -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', diff --git a/util/build.gradle b/util/build.gradle index 7f9fa8ee5..b282b933a 100644 --- a/util/build.gradle +++ b/util/build.gradle @@ -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'] diff --git a/util/src/main/java/google/registry/util/SerializeUtils.java b/util/src/main/java/google/registry/util/SerializeUtils.java index 2d53d65e8..6ed3cf1a8 100644 --- a/util/src/main/java/google/registry/util/SerializeUtils.java +++ b/util/src/main/java/google/registry/util/SerializeUtils.java @@ -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 parse(Class type, String objectString) { + checkNotNull(type, "Class type is not specified"); + checkNotNull(objectString, "Object string cannot be null"); + + return SerializeUtils.deserialize(type, Base64.decodeBase64(objectString)); + } } diff --git a/util/src/test/java/google/registry/util/SerializeUtilsTest.java b/util/src/test/java/google/registry/util/SerializeUtilsTest.java index 67baf6b9e..2b75cf460 100644 --- a/util/src/test/java/google/registry/util/SerializeUtilsTest.java +++ b/util/src/test/java/google/registry/util/SerializeUtilsTest.java @@ -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"); + } }