RDAP: Implement entity name search

Adds the ability to search for entities (contacts and registrars) by name.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=130305930
This commit is contained in:
mountford 2016-08-15 11:48:40 -07:00 committed by Ben McIlwain
parent 64abebec82
commit 160266f37a
8 changed files with 368 additions and 66 deletions

View file

@ -80,4 +80,8 @@
<property name="deletionTime" direction="asc"/>
<property name="fullyQualifiedHostName" direction="asc"/>
</datastore-index>
<datastore-index kind="ContactResource" ancestor="false" source="manual">
<property name="deletionTime" direction="asc"/>
<property name="searchName" direction="asc"/>
</datastore-index>
</datastore-indexes>

View file

@ -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;
/** Contacts 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();
}
}

View file

@ -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<Registrar>() {
@ -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<Registrar>() {
@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<Registrar> loadByNameRange(
final String nameStart, final String nameAfterEnd, final int resultSetMaxSize) {
return ofy().doTransactionless(new Work<Iterable<Registrar>>() {
@Override
public Iterable<Registrar> run() {
return ofy().load()
.type(Registrar.class)
.filter("registrarName >=", nameStart)
.filter("registrarName <", nameAfterEnd)
.limit(resultSetMaxSize);
}});
}
/** Loads all registrar entities. */
public static Iterable<Registrar> loadAll() {
return ofy().load().type(Registrar.class).ancestor(getCrossTldKey());

View file

@ -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<ImmutableMap<String, Object>> 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.
*
* <p>As per Gustavo Lozano of ICANN, registrar name search should be by registrar name only, not
* by registrar contact name:
*
* <p>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).
*
* <p>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<ImmutableMap<String, Object>>
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.<Registrar>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<ImmutableMap<String, Object>> searchByHandle(
final RdapSearchPattern partialStringQuery, DateTime now) throws HttpException {
@ -121,50 +163,53 @@ public class RdapEntitySearchAction extends RdapActionBase {
.id(partialStringQuery.getInitialString())
.now();
Registrar registrar = Registrar.loadByClientId(partialStringQuery.getInitialString());
ImmutableList.Builder<ImmutableMap<String, Object>> 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.<DesignatedContact.Type>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.<ContactResource>of() : ImmutableList.of(contactResource),
(registrar == null)
? ImmutableList.<Registrar>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<ImmutableMap<String, Object>> builder = new ImmutableList.Builder<>();
Query<ContactResource> query = ofy().load()
return makeSearchResults(
ofy().load()
.type(ContactResource.class)
.filterKey(">=", Key.create(ContactResource.class, partialStringQuery.getInitialString()))
.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) {
.limit(rdapResultSetMaxSize),
Registrar.loadByClientIdRange(
partialStringQuery.getInitialString(),
partialStringQuery.getNextInitialString(),
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<ImmutableMap<String, Object>> makeSearchResults(
Iterable<ContactResource> contactResources, Iterable<Registrar> registrars, DateTime now)
throws HttpException {
ImmutableList.Builder<ImmutableMap<String, Object>> 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(
contactResource,
contact,
false,
Optional.<DesignatedContact.Type>absent(),
rdapLinkBase,
rdapWhoisServer,
now));
rdapWhoisServer, now));
}
for (Registrar registrar
: Registrar.loadByClientIdRange(
partialStringQuery.getInitialString(),
partialStringQuery.getNextInitialString(),
rdapResultSetMaxSize)) {
for (Registrar registrar : registrars) {
if (registrar.isActiveAndPubliclyVisible()) {
builder.add(RdapJsonFormatter.makeRdapJsonForRegistrar(
registrar, false, rdapLinkBase, rdapWhoisServer, now));
@ -175,9 +220,5 @@ public class RdapEntitySearchAction extends RdapActionBase {
return (resultSet.size() <= rdapResultSetMaxSize)
? resultSet
: resultSet.subList(0, rdapResultSetMaxSize);
// Don't allow suffixes in entity handle search queries.
} else {
throw new UnprocessableEntityException("Suffixes not allowed in entity handle searches");
}
}
}

View file

@ -129,7 +129,8 @@ public class ContactResourceTest extends EntityTestCase {
verifyIndexing(
contactResource,
"deletionTime",
"currentSponsorClientId");
"currentSponsorClientId",
"searchName");
}
@Test

View file

@ -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<google.registry.model.eppcommon.StatusValue> status;
org.joda.time.DateTime deletionTime;
org.joda.time.DateTime lastEppUpdateTime;

View file

@ -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 <script>"))
.isEqualTo(
generateExpectedJsonForEntity(
"2-Registrar", "Yes Virginia <script>", null, null, "rdap_registrar.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
@ -253,6 +308,12 @@ public class RdapEntitySearchActionTest {
assertThat(response.getStatus()).isEqualTo(404);
}
@Test
public void testNameMatch_testAndInactiveRegistrars_notFound() throws Exception {
generateActualJsonWithHandle("No Way");
assertThat(response.getStatus()).isEqualTo(404);
}
@Test
public void testHandleMatch_2rstar_found() throws Exception {
assertThat(generateActualJsonWithHandle("2-R*"))

View file

@ -0,0 +1,139 @@
{
"entitySearchResults":
[
{
"objectClassName" : "entity",
"handle" : "4-ROID",
"status" : ["active"],
"links" :
[
{
"value" : "https://example.com/rdap/entity/4-ROID",
"rel" : "self",
"href": "https://example.com/rdap/entity/4-ROID",
"type" : "application/rdap+json"
}
],
"events": [
{
"eventAction": "registration",
"eventActor": "foo",
"eventDate": "2000-01-01T00:00:00.000Z"
},
{
"eventAction": "last update of RDAP database",
"eventDate": "2000-01-01T00:00:00.000Z"
}
],
"vcardArray" :
[
"vcard",
[
["version", {}, "text", "4.0"],
["fn", {}, "text", "Blindly"],
["org", {}, "text", "GOOGLE INCORPORATED <script>"],
["adr", {}, "text",
[
"",
"",
["123 Blindly St", "Blindlyland"],
"KOKOMO",
"BM",
"31337",
"United States"
]
],
["tel", {"type" : ["voice"]}, "uri", "tel:+1.2126660420"],
["tel", {"type" : ["fax"]}, "uri", "tel:+1.2126660420"],
["email", {}, "text", "blindly@b.tld"]
]
]
},
{
"objectClassName" : "entity",
"handle" : "2-ROID",
"status" : ["active"],
"links" :
[
{
"value" : "https://example.com/rdap/entity/2-ROID",
"rel" : "self",
"href": "https://example.com/rdap/entity/2-ROID",
"type" : "application/rdap+json"
}
],
"events": [
{
"eventAction": "registration",
"eventActor": "foo",
"eventDate": "2000-01-01T00:00:00.000Z"
},
{
"eventAction": "last update of RDAP database",
"eventDate": "2000-01-01T00:00:00.000Z"
}
],
"vcardArray" :
[
"vcard",
[
["version", {}, "text", "4.0"],
["fn", {}, "text", "Blinky (赤ベイ)"],
["org", {}, "text", "GOOGLE INCORPORATED <script>"],
["adr", {}, "text",
[
"",
"",
["123 Blinky St", "Blinkyland"],
"KOKOMO",
"BM",
"31337",
"United States"
]
],
["tel", {"type" : ["voice"]}, "uri", "tel:+1.2126660420"],
["tel", {"type" : ["fax"]}, "uri", "tel:+1.2126660420"],
["email", {}, "text", "blinky@b.tld"]
]
]
}
],
"rdapConformance": [ "rdap_level_0" ],
"notices" :
[
{
"title" : "RDAP Terms of Service",
"description" :
[
"By querying our Domain Database, you are agreeing to comply with these terms so please read them carefully.",
"Any information provided is 'as is' without any guarantee of accuracy.",
"Please do not misuse the Domain Database. It is intended solely for query-based access.",
"Don't use the Domain Database to allow, enable, or otherwise support the transmission of mass unsolicited, commercial advertising or solicitations.",
"Don't access our Domain Database through the use of high volume, automated electronic processes that send queries or data to the systems of Charleston Road Registry or any ICANN-accredited registrar.",
"You may only use the information contained in the Domain Database for lawful purposes.",
"Do not compile, repackage, disseminate, or otherwise use the information contained in the Domain Database in its entirety, or in any substantial portion, without our prior written permission.",
"We may retain certain details about queries to our Domain Database for the purposes of detecting and preventing misuse.",
"We reserve the right to restrict or deny your access to the database if we suspect that you have failed to comply with these terms.",
"We reserve the right to modify this agreement at any time."
],
"links" :
[
{
"value" : "https://example.com/rdap/help/tos",
"rel" : "alternate",
"href" : "https://www.registry.google/about/rdap/tos.html",
"type" : "text/html"
}
]
}
],
"remarks" :
[
{
"description" :
[
"This response conforms to the RDAP Operational Profile for gTLD Registries and Registrars version 1.0"
]
}
]
}