diff --git a/java/google/registry/env/common/default/WEB-INF/datastore-indexes.xml b/java/google/registry/env/common/default/WEB-INF/datastore-indexes.xml index 3f75c1209..5b48c1b3e 100644 --- a/java/google/registry/env/common/default/WEB-INF/datastore-indexes.xml +++ b/java/google/registry/env/common/default/WEB-INF/datastore-indexes.xml @@ -80,4 +80,8 @@ + + + + diff --git a/java/google/registry/model/contact/ContactResource.java b/java/google/registry/model/contact/ContactResource.java index 4bfa551ac..4213fded8 100644 --- a/java/google/registry/model/contact/ContactResource.java +++ b/java/google/registry/model/contact/ContactResource.java @@ -24,6 +24,7 @@ import com.google.common.collect.Lists; import com.googlecode.objectify.annotation.Cache; import com.googlecode.objectify.annotation.Entity; import com.googlecode.objectify.annotation.IgnoreSave; +import com.googlecode.objectify.annotation.Index; import com.googlecode.objectify.condition.IfNull; import google.registry.model.EppResource; import google.registry.model.EppResource.ForeignKeyedEppResource; @@ -85,6 +86,15 @@ public class ContactResource extends EppResource implements ForeignKeyedEppResou @XmlTransient PostalInfo internationalizedPostalInfo; + /** + * Contact name used for name searches. This is set automatically to be the internationalized + * postal name, or if null, the localized postal name, or if that is null as well, null. Personal + * info; cleared by wipeOut(). + */ + @Index + @XmlTransient + String searchName; + /** Contact’s voice number. Personal info; cleared by wipeOut(). */ @IgnoreSave(IfNull.class) ContactPhoneNumber voice; @@ -250,6 +260,16 @@ public class ContactResource extends EppResource implements ForeignKeyedEppResou @Override public ContactResource build() { + // Set the searchName using the internationalized and localized postal info names. + if ((getInstance().internationalizedPostalInfo != null) + && (getInstance().internationalizedPostalInfo.getName() != null)) { + getInstance().searchName = getInstance().internationalizedPostalInfo.getName(); + } else if ((getInstance().localizedPostalInfo != null) + && (getInstance().localizedPostalInfo.getName() != null)) { + getInstance().searchName = getInstance().localizedPostalInfo.getName(); + } else { + getInstance().searchName = null; + } return super.build(); } } diff --git a/java/google/registry/model/registrar/Registrar.java b/java/google/registry/model/registrar/Registrar.java index 38f57b338..4e3c46ed9 100644 --- a/java/google/registry/model/registrar/Registrar.java +++ b/java/google/registry/model/registrar/Registrar.java @@ -786,7 +786,7 @@ public class Registrar extends ImmutableObject implements Buildable, Jsonifiable } } - /** Load a registrar entity by its client id. */ + /** Load a registrar entity by its client id outside of a transaction. */ @Nullable public static Registrar loadByClientId(final String clientId) { return ofy().doTransactionless(new Work() { @@ -801,7 +801,7 @@ public class Registrar extends ImmutableObject implements Buildable, Jsonifiable } /** - * Load registrar entities by client id range. + * Load registrar entities by client id range outside of a transaction. * * @param clientIdStart returned registrars will have a client id greater than or equal to this * @param clientIdAfterEnd returned registrars will have a client id less than this @@ -820,6 +820,41 @@ public class Registrar extends ImmutableObject implements Buildable, Jsonifiable }}); } + /** Load a registrar entity by its name outside of a transaction. */ + @Nullable + public static Registrar loadByName(final String name) { + return ofy().doTransactionless(new Work() { + @Override + public Registrar run() { + return ofy().load() + .type(Registrar.class) + .filter("registrarName", name) + .first() + .now(); + }}); + } + + /** + * Load registrar entities by registrar name range, inclusive of the start but not the end, + * outside of a transaction. + * + * @param nameStart returned registrars will have a name greater than or equal to this + * @param nameAfterEnd returned registrars will have a name less than this + * @param resultSetMaxSize the maximum number of registrar entities to be returned + */ + public static Iterable loadByNameRange( + final String nameStart, final String nameAfterEnd, final int resultSetMaxSize) { + return ofy().doTransactionless(new Work>() { + @Override + public Iterable run() { + return ofy().load() + .type(Registrar.class) + .filter("registrarName >=", nameStart) + .filter("registrarName <", nameAfterEnd) + .limit(resultSetMaxSize); + }}); + } + /** Loads all registrar entities. */ public static Iterable loadAll() { return ofy().load().type(Registrar.class).ancestor(getCrossTldKey()); diff --git a/java/google/registry/rdap/RdapEntitySearchAction.java b/java/google/registry/rdap/RdapEntitySearchAction.java index 8c6f05039..c0e36a5d9 100644 --- a/java/google/registry/rdap/RdapEntitySearchAction.java +++ b/java/google/registry/rdap/RdapEntitySearchAction.java @@ -24,7 +24,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.primitives.Booleans; import com.googlecode.objectify.Key; -import com.googlecode.objectify.cmd.Query; import google.registry.config.ConfigModule.Config; import google.registry.model.contact.ContactResource; import google.registry.model.domain.DesignatedContact; @@ -34,7 +33,6 @@ import google.registry.request.Action; import google.registry.request.HttpException; import google.registry.request.HttpException.BadRequestException; import google.registry.request.HttpException.NotFoundException; -import google.registry.request.HttpException.NotImplementedException; import google.registry.request.HttpException.UnprocessableEntityException; import google.registry.request.Parameter; import google.registry.util.Clock; @@ -88,15 +86,8 @@ public class RdapEntitySearchAction extends RdapActionBase { ImmutableList> results; if (fnParam.isPresent()) { // syntax: /rdap/entities?fn=Bobby%20Joe* - // TODO(b/25973399): implement entity name search, and move the comment below to that routine - // As per Gustavo Lozano of ICANN, registrar name search should be by registrar name only, not - // by registrar contact name: - // - // The search is by registrar name only. The profile is supporting the functionality defined - // in the Base Registry Agreement (see 1.6 of Section 4 of the Base Registry Agreement, - // https://newgtlds.icann.org/sites/default/files/agreements/ - // agreement-approved-09jan14-en.htm). - throw new NotImplementedException("Entity name search not implemented"); + // The name is the contact name or registrar name (not registrar contact name). + results = searchByName(RdapSearchPattern.create(fnParam.get(), false), now); } else { // syntax: /rdap/entities?handle=12345-* // The handle is either the contact roid or the registrar clientId. @@ -111,6 +102,57 @@ public class RdapEntitySearchAction extends RdapActionBase { return builder.build(); } + /** + * Searches for entities by name, returning a JSON array of entity info maps. + * + *

As per Gustavo Lozano of ICANN, registrar name search should be by registrar name only, not + * by registrar contact name: + * + *

The search is by registrar name only. The profile is supporting the functionality defined + * in the Base Registry Agreement (see 1.6 of Section 4 of the Base Registry Agreement, + * https://newgtlds.icann.org/sites/default/files/agreements/ + * agreement-approved-09jan14-en.htm). + * + *

According to RFC 7482 section 6.1, punycode is only used for domain name labels, so we can + * assume that entity names are regular unicode. + */ + private ImmutableList> + searchByName(final RdapSearchPattern partialStringQuery, DateTime now) throws HttpException { + // Handle queries without a wildcard -- load by name, which may not be unique. + if (!partialStringQuery.getHasWildcard()) { + Registrar registrar = Registrar.loadByName(partialStringQuery.getInitialString()); + return makeSearchResults( + ofy().load() + .type(ContactResource.class) + .filter("searchName", partialStringQuery.getInitialString()) + .filter("deletionTime", END_OF_TIME) + .limit(rdapResultSetMaxSize), + (registrar == null) + ? ImmutableList.of() : ImmutableList.of(registrar), + now); + // Handle queries with a wildcard, but no suffix. For contact resources, the deletion time will + // always be END_OF_TIME for non-deleted records; unlike domain resources, we don't need to + // worry about deletion times in the future. That allows us to use an equality query for the + // deletion time. + } else if (partialStringQuery.getSuffix() == null) { + return makeSearchResults( + ofy().load() + .type(ContactResource.class) + .filter("searchName >=", partialStringQuery.getInitialString()) + .filter("searchName <", partialStringQuery.getNextInitialString()) + .filter("deletionTime", END_OF_TIME) + .limit(rdapResultSetMaxSize), + Registrar.loadByNameRange( + partialStringQuery.getInitialString(), + partialStringQuery.getNextInitialString(), + rdapResultSetMaxSize), + now); + // Don't allow suffixes in entity name search queries. + } else { + throw new UnprocessableEntityException("Suffixes not allowed in entity name searches"); + } + } + /** Searches for entities by handle, returning a JSON array of entity info maps. */ private ImmutableList> searchByHandle( final RdapSearchPattern partialStringQuery, DateTime now) throws HttpException { @@ -121,63 +163,62 @@ public class RdapEntitySearchAction extends RdapActionBase { .id(partialStringQuery.getInitialString()) .now(); Registrar registrar = Registrar.loadByClientId(partialStringQuery.getInitialString()); - ImmutableList.Builder> builder = new ImmutableList.Builder<>(); - if ((contactResource != null) && contactResource.getDeletionTime().isEqual(END_OF_TIME)) { - // As per Andy Newton on the regext mailing list, contacts by themselves have no role, since - // they are global, and might have different roles for different domains. - builder.add(RdapJsonFormatter.makeRdapJsonForContact( - contactResource, - false, - Optional.absent(), - rdapLinkBase, - rdapWhoisServer, - now)); - } - if ((registrar != null) && registrar.isActiveAndPubliclyVisible()) { - builder.add(RdapJsonFormatter.makeRdapJsonForRegistrar( - registrar, false, rdapLinkBase, rdapWhoisServer, now)); - } - return builder.build(); + return makeSearchResults( + ((contactResource == null) || !contactResource.getDeletionTime().isEqual(END_OF_TIME)) + ? ImmutableList.of() : ImmutableList.of(contactResource), + (registrar == null) + ? ImmutableList.of() : ImmutableList.of(registrar), + now); // Handle queries with a wildcard, but no suffix. For contact resources, the deletion time will // always be END_OF_TIME for non-deleted records; unlike domain resources, we don't need to // worry about deletion times in the future. That allows us to use an equality query for the // deletion time. } else if (partialStringQuery.getSuffix() == null) { - ImmutableList.Builder> builder = new ImmutableList.Builder<>(); - Query query = ofy().load() - .type(ContactResource.class) - .filterKey(">=", Key.create(ContactResource.class, partialStringQuery.getInitialString())) - .filterKey( - "<", Key.create(ContactResource.class, partialStringQuery.getNextInitialString())) - .filter("deletionTime", END_OF_TIME) - .limit(rdapResultSetMaxSize); - for (ContactResource contactResource : query) { - builder.add(RdapJsonFormatter.makeRdapJsonForContact( - contactResource, - false, - Optional.absent(), - rdapLinkBase, - rdapWhoisServer, - now)); - } - for (Registrar registrar - : Registrar.loadByClientIdRange( + return makeSearchResults( + ofy().load() + .type(ContactResource.class) + .filterKey( + ">=", Key.create(ContactResource.class, partialStringQuery.getInitialString())) + .filterKey( + "<", Key.create(ContactResource.class, partialStringQuery.getNextInitialString())) + .filter("deletionTime", END_OF_TIME) + .limit(rdapResultSetMaxSize), + Registrar.loadByClientIdRange( partialStringQuery.getInitialString(), partialStringQuery.getNextInitialString(), - rdapResultSetMaxSize)) { - if (registrar.isActiveAndPubliclyVisible()) { - builder.add(RdapJsonFormatter.makeRdapJsonForRegistrar( - registrar, false, rdapLinkBase, rdapWhoisServer, now)); - } - } - // In theory, there could be more results than our max size, so limit the size. - ImmutableList> resultSet = builder.build(); - return (resultSet.size() <= rdapResultSetMaxSize) - ? resultSet - : resultSet.subList(0, rdapResultSetMaxSize); + rdapResultSetMaxSize), + now); // Don't allow suffixes in entity handle search queries. } else { throw new UnprocessableEntityException("Suffixes not allowed in entity handle searches"); } } + + /** Builds a JSON array of entity info maps based on the specified contacts and registrars. */ + private ImmutableList> makeSearchResults( + Iterable contactResources, Iterable registrars, DateTime now) + throws HttpException { + ImmutableList.Builder> builder = new ImmutableList.Builder<>(); + for (ContactResource contact : contactResources) { + // As per Andy Newton on the regext mailing list, contacts by themselves have no role, since + // they are global, and might have different roles for different domains. + builder.add(RdapJsonFormatter.makeRdapJsonForContact( + contact, + false, + Optional.absent(), + rdapLinkBase, + rdapWhoisServer, now)); + } + for (Registrar registrar : registrars) { + if (registrar.isActiveAndPubliclyVisible()) { + builder.add(RdapJsonFormatter.makeRdapJsonForRegistrar( + registrar, false, rdapLinkBase, rdapWhoisServer, now)); + } + } + // In theory, there could be more results than our max size, so limit the size. + ImmutableList> resultSet = builder.build(); + return (resultSet.size() <= rdapResultSetMaxSize) + ? resultSet + : resultSet.subList(0, rdapResultSetMaxSize); + } } diff --git a/javatests/google/registry/model/contact/ContactResourceTest.java b/javatests/google/registry/model/contact/ContactResourceTest.java index 1fdfe5b68..f8b13218b 100644 --- a/javatests/google/registry/model/contact/ContactResourceTest.java +++ b/javatests/google/registry/model/contact/ContactResourceTest.java @@ -129,7 +129,8 @@ public class ContactResourceTest extends EntityTestCase { verifyIndexing( contactResource, "deletionTime", - "currentSponsorClientId"); + "currentSponsorClientId", + "searchName"); } @Test diff --git a/javatests/google/registry/model/schema.txt b/javatests/google/registry/model/schema.txt index ffc39ec02..6ef63ec90 100644 --- a/javatests/google/registry/model/schema.txt +++ b/javatests/google/registry/model/schema.txt @@ -157,6 +157,7 @@ class google.registry.model.contact.ContactResource { java.lang.String currentSponsorClientId; java.lang.String email; java.lang.String lastEppUpdateClientId; + java.lang.String searchName; java.util.Set status; org.joda.time.DateTime deletionTime; org.joda.time.DateTime lastEppUpdateTime; diff --git a/javatests/google/registry/rdap/RdapEntitySearchActionTest.java b/javatests/google/registry/rdap/RdapEntitySearchActionTest.java index 13fbb6d26..fb8252c0c 100644 --- a/javatests/google/registry/rdap/RdapEntitySearchActionTest.java +++ b/javatests/google/registry/rdap/RdapEntitySearchActionTest.java @@ -85,6 +85,13 @@ public class RdapEntitySearchActionTest { ImmutableList.of("123 Blinky St", "Blinkyland"), clock.nowUtc()); + makeAndPersistContactResource( + "blindly", + "Blindly", + "blindly@b.tld", + ImmutableList.of("123 Blindly St", "Blindlyland"), + clock.nowUtc()); + // deleted persistResource( makeContactResource("clyde", "Clyde (愚図た)", "clyde@c.tld") @@ -188,7 +195,15 @@ public class RdapEntitySearchActionTest { } @Test - public void testSuffix_rejected() throws Exception { + public void testNameMatch_suffixRejected() throws Exception { + assertThat(generateActualJsonWithFullName("exam*ple")) + .isEqualTo( + generateExpectedJson("Suffix not allowed after wildcard", "rdap_error_422.json")); + assertThat(response.getStatus()).isEqualTo(422); + } + + @Test + public void testHandleMatch_suffixRejected() throws Exception { assertThat(generateActualJsonWithHandle("exam*ple")) .isEqualTo( generateExpectedJson("Suffix not allowed after wildcard", "rdap_error_422.json")); @@ -212,11 +227,51 @@ public class RdapEntitySearchActionTest { } @Test - public void testNameMatch_notImplemented() throws Exception { - assertThat(generateActualJsonWithFullName("hello")) + public void testNameMatch_contactFound() throws Exception { + assertThat(generateActualJsonWithFullName("Blinky (赤ベイ)")) .isEqualTo( - generateExpectedJson("Entity name search not implemented", "rdap_error_501.json")); - assertThat(response.getStatus()).isEqualTo(501); + generateExpectedJsonForEntity( + "2-ROID", + "Blinky (赤ベイ)", + "blinky@b.tld", + "\"123 Blinky St\", \"Blinkyland\"", + "rdap_contact.json")); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + public void testNameMatch_contactWildcardFound() throws Exception { + assertThat(generateActualJsonWithFullName("Blinky*")) + .isEqualTo( + generateExpectedJsonForEntity( + "2-ROID", + "Blinky (赤ベイ)", + "blinky@b.tld", + "\"123 Blinky St\", \"Blinkyland\"", + "rdap_contact.json")); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + public void testNameMatch_contactWildcardFoundBoth() throws Exception { + assertThat(generateActualJsonWithFullName("Blin*")) + .isEqualTo(generateExpectedJson("rdap_multiple_contacts2.json")); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + public void testNameMatch_deletedContactNotFound() throws Exception { + generateActualJsonWithFullName("Cl*"); + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + public void testNameMatch_registrarFound() throws Exception { + assertThat(generateActualJsonWithFullName("Yes Virginia