diff --git a/docs/rdap.md b/docs/rdap.md index 9f6c8cc46..1e678b6a7 100644 --- a/docs/rdap.md +++ b/docs/rdap.md @@ -341,6 +341,14 @@ formatted version can be requested by adding an extra parameter: The result is still valid JSON, but with extra whitespace added to align the data on the page. +### `subtype` parameter + +The subtype parameter is used only for entity searches, to select whether the +results should include contacts, registrars or both. If specified, the subtype +should be 'all', 'contacts' or 'registrars'. Setting the subtype to 'all' +duplicates the normal behavior of returning both. Setting it to 'contacts' or +'registrars' causes an entity search to return only contacts or only registrars. + ### Next page links The number of results returned in a domain, nameserver or entity search is diff --git a/java/google/registry/rdap/RdapEntitySearchAction.java b/java/google/registry/rdap/RdapEntitySearchAction.java index bf250c4af..5666a6ae0 100644 --- a/java/google/registry/rdap/RdapEntitySearchAction.java +++ b/java/google/registry/rdap/RdapEntitySearchAction.java @@ -86,6 +86,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { @Inject Clock clock; @Inject @Parameter("fn") Optional fnParam; @Inject @Parameter("handle") Optional handleParam; + @Inject @Parameter("subtype") Optional subtypeParam; @Inject RdapEntitySearchAction() {} private enum QueryType { @@ -93,6 +94,12 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { HANDLE } + private enum Subtype { + ALL, + CONTACTS, + REGISTRARS + } + private enum CursorType { NONE, CONTACT, @@ -132,6 +139,18 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { throw new BadRequestException("You must specify either fn=XXXX or handle=YYYY"); } + // Check the subtype. + Subtype subtype; + if (!subtypeParam.isPresent() || subtypeParam.get().equalsIgnoreCase("all")) { + subtype = Subtype.ALL; + } else if (subtypeParam.get().equalsIgnoreCase("contacts")) { + subtype = Subtype.CONTACTS; + } else if (subtypeParam.get().equalsIgnoreCase("registrars")) { + subtype = Subtype.REGISTRARS; + } else { + throw new BadRequestException("Subtype parameter must specify contacts, registrars or all"); + } + // Decode the cursor token and extract the prefix and string portions. decodeCursorToken(); CursorType cursorType; @@ -164,6 +183,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { recordWildcardType(RdapSearchPattern.create(fnParam.get(), false)), cursorType, cursorQueryString, + subtype, now); // Search by handle. @@ -175,6 +195,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { searchByHandle( recordWildcardType(RdapSearchPattern.create(handleParam.get(), false)), cursorQueryString, + subtype, now); } @@ -221,6 +242,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { final RdapSearchPattern partialStringQuery, CursorType cursorType, Optional cursorQueryString, + Subtype subtype, DateTime now) { // For wildcard searches, make sure the initial string is long enough, and don't allow suffixes. if (partialStringQuery.getHasWildcard() && (partialStringQuery.getSuffix() != null)) { @@ -238,40 +260,50 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { : "Initial search string required when searching for deleted entities"); } // Get the registrar matches. If we have a registrar cursor, weed out registrars up to and - // including the one we ended with last time. - ImmutableList registrars = - Streams.stream(Registrar.loadAllCached()) - .filter( - registrar -> - partialStringQuery.matches(registrar.getRegistrarName()) - && ((cursorType != CursorType.REGISTRAR) - || (registrar.getRegistrarName().compareTo(cursorQueryString.get()) - > 0)) - && shouldBeVisible(registrar)) - .limit(rdapResultSetMaxSize + 1) - .collect(toImmutableList()); + // including the one we ended with last time. We can skip registrars if subtype is CONTACTS. + ImmutableList registrars; + if (subtype == Subtype.CONTACTS) { + registrars = ImmutableList.of(); + } else { + registrars = + Streams.stream(Registrar.loadAllCached()) + .filter( + registrar -> + partialStringQuery.matches(registrar.getRegistrarName()) + && ((cursorType != CursorType.REGISTRAR) + || (registrar.getRegistrarName().compareTo(cursorQueryString.get()) + > 0)) + && shouldBeVisible(registrar)) + .limit(rdapResultSetMaxSize + 1) + .collect(toImmutableList()); + } // Get the contact matches and return the results, fetching an additional contact to detect // truncation. Don't bother searching for contacts by name if the request would not be able to // see any names anyway. Also, if a registrar cursor is present, we have already moved past the - // contacts, and don't need to fetch them this time. + // contacts, and don't need to fetch them this time. We can skip contacts if subtype is + // REGISTRARS. RdapResultSet resultSet; - RdapAuthorization authorization = getAuthorization(); - if ((authorization.role() == RdapAuthorization.Role.PUBLIC) - || (cursorType == CursorType.REGISTRAR)) { + if (subtype == Subtype.REGISTRARS) { resultSet = RdapResultSet.create(ImmutableList.of()); } else { - Query query = - queryItems( - ContactResource.class, - "searchName", - partialStringQuery, - cursorQueryString, // if we get this far, and there's a cursor, it must be a contact - DeletedItemHandling.EXCLUDE, - rdapResultSetMaxSize + 1); - if (authorization.role() != RdapAuthorization.Role.ADMINISTRATOR) { - query = query.filter("currentSponsorClientId in", authorization.clientIds()); + RdapAuthorization authorization = getAuthorization(); + if ((authorization.role() == RdapAuthorization.Role.PUBLIC) + || (cursorType == CursorType.REGISTRAR)) { + resultSet = RdapResultSet.create(ImmutableList.of()); + } else { + Query query = + queryItems( + ContactResource.class, + "searchName", + partialStringQuery, + cursorQueryString, // if we get this far, and there's a cursor, it must be a contact + DeletedItemHandling.EXCLUDE, + rdapResultSetMaxSize + 1); + if (authorization.role() != RdapAuthorization.Role.ADMINISTRATOR) { + query = query.filter("currentSponsorClientId in", authorization.clientIds()); + } + resultSet = getMatchingResources(query, false, now, rdapResultSetMaxSize + 1); } - resultSet = getMatchingResources(query, false, now, rdapResultSetMaxSize + 1); } return makeSearchResults(resultSet, registrars, QueryType.FULL_NAME, now); } @@ -289,25 +321,39 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { private RdapSearchResults searchByHandle( final RdapSearchPattern partialStringQuery, Optional cursorQueryString, + Subtype subtype, DateTime now) { if (partialStringQuery.getSuffix() != null) { throw new UnprocessableEntityException("Suffixes not allowed in entity handle searches"); } // Handle queries without a wildcard (and not including deleted) -- load by ID. if (!partialStringQuery.getHasWildcard() && !shouldIncludeDeleted()) { - ContactResource contactResource = ofy().load() - .type(ContactResource.class) - .id(partialStringQuery.getInitialString()) - .now(); - ImmutableList contactResourceList = - ((contactResource != null) && shouldBeVisible(contactResource, now)) - ? ImmutableList.of(contactResource) - : ImmutableList.of(); + ImmutableList contactResourceList; + if (subtype == Subtype.REGISTRARS) { + contactResourceList = ImmutableList.of(); + } else { + ContactResource contactResource = + ofy() + .load() + .type(ContactResource.class) + .id(partialStringQuery.getInitialString()) + .now(); + contactResourceList = + ((contactResource != null) && shouldBeVisible(contactResource, now)) + ? ImmutableList.of(contactResource) + : ImmutableList.of(); + } + ImmutableList registrarList; + if (subtype == Subtype.CONTACTS) { + registrarList = ImmutableList.of(); + } else { + registrarList = getMatchingRegistrars(partialStringQuery.getInitialString()); + } return makeSearchResults( contactResourceList, IncompletenessWarningType.COMPLETE, contactResourceList.size(), - getMatchingRegistrars(partialStringQuery.getInitialString()), + registrarList, QueryType.HANDLE, now); // Handle queries with a wildcard (or including deleted), but no suffix. Because the handle @@ -316,7 +362,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { // detect result set truncation. } else { ImmutableList registrars = - partialStringQuery.getHasWildcard() + ((subtype == Subtype.CONTACTS) || partialStringQuery.getHasWildcard()) ? ImmutableList.of() : getMatchingRegistrars(partialStringQuery.getInitialString()); // Get the contact matches and return the results, fetching an additional contact to detect @@ -324,15 +370,24 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { // get excluded due to permissioning. Any cursor present must be a contact cursor, because we // would never return a registrar for this search. int querySizeLimit = getStandardQuerySizeLimit(); - Query query = - queryItemsByKey( - ContactResource.class, - partialStringQuery, - cursorQueryString, - getDeletedItemHandling(), - querySizeLimit); + RdapResultSet contactResultSet; + if (subtype == Subtype.REGISTRARS) { + contactResultSet = RdapResultSet.create(ImmutableList.of()); + } else { + contactResultSet = + getMatchingResources( + queryItemsByKey( + ContactResource.class, + partialStringQuery, + cursorQueryString, + getDeletedItemHandling(), + querySizeLimit), + shouldIncludeDeleted(), + now, + querySizeLimit); + } return makeSearchResults( - getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit), + contactResultSet, registrars, QueryType.HANDLE, now); diff --git a/java/google/registry/rdap/RdapModule.java b/java/google/registry/rdap/RdapModule.java index b51276974..4cfc1af8f 100644 --- a/java/google/registry/rdap/RdapModule.java +++ b/java/google/registry/rdap/RdapModule.java @@ -67,6 +67,12 @@ public final class RdapModule { return RequestParameters.extractOptionalParameter(req, "registrar"); } + @Provides + @Parameter("subtype") + static Optional provideSubtype(HttpServletRequest req) { + return RequestParameters.extractOptionalParameter(req, "subtype"); + } + @Provides @Parameter("includeDeleted") static Optional provideIncludeDeleted(HttpServletRequest req) { diff --git a/javatests/google/registry/rdap/RdapEntitySearchActionTest.java b/javatests/google/registry/rdap/RdapEntitySearchActionTest.java index 6f486eeb2..c76db2d83 100644 --- a/javatests/google/registry/rdap/RdapEntitySearchActionTest.java +++ b/javatests/google/registry/rdap/RdapEntitySearchActionTest.java @@ -26,6 +26,7 @@ import static google.registry.testing.DatastoreHelper.persistSimpleResources; import static google.registry.testing.FullFieldsTestEntityHelper.makeAndPersistContactResource; import static google.registry.testing.FullFieldsTestEntityHelper.makeAndPersistDeletedContactResource; import static google.registry.testing.FullFieldsTestEntityHelper.makeContactResource; +import static google.registry.testing.FullFieldsTestEntityHelper.makeHistoryEntry; import static google.registry.testing.FullFieldsTestEntityHelper.makeRegistrar; import static google.registry.testing.FullFieldsTestEntityHelper.makeRegistrarContacts; import static google.registry.testing.TestDataHelper.loadFile; @@ -40,6 +41,7 @@ import google.registry.model.ImmutableObject; import google.registry.model.contact.ContactResource; import google.registry.model.ofy.Ofy; import google.registry.model.registrar.Registrar; +import google.registry.model.reporting.HistoryEntry; import google.registry.rdap.RdapMetrics.EndpointType; import google.registry.rdap.RdapMetrics.SearchType; import google.registry.rdap.RdapSearchResults.IncompletenessWarningType; @@ -192,6 +194,7 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { action.rdapWhoisServer = null; action.fnParam = Optional.empty(); action.handleParam = Optional.empty(); + action.subtypeParam = Optional.empty(); action.registrarParam = Optional.empty(); action.includeDeletedParam = Optional.empty(); action.formatOutputParam = Optional.empty(); @@ -281,16 +284,20 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { .asBuilder() .setRepoId(String.format("%04d-ROID", i)) .build(); + resourcesBuilder.add(makeHistoryEntry( + contact, HistoryEntry.Type.CONTACT_CREATE, null, "created", clock.nowUtc())); resourcesBuilder.add(contact); } persistResources(resourcesBuilder.build()); for (int i = 1; i <= numRegistrars; i++) { - resourcesBuilder.add( + Registrar registrar = makeRegistrar( String.format("registrar%d", i), String.format("Entity %d", i + numContacts), Registrar.State.ACTIVE, - 300L + i)); + 300L + i); + resourcesBuilder.add(registrar); + resourcesBuilder.addAll(makeRegistrarContacts(registrar)); } persistResources(resourcesBuilder.build()); } @@ -537,6 +544,19 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { verifyErrorMetrics(Optional.empty(), 422); } + @Test + public void testInvalidSubtype_rejected() throws Exception { + action.subtypeParam = Optional.of("Space Aliens"); + assertThat(generateActualJsonWithFullName("Blinky (赤ベイ)")) + .isEqualTo( + generateExpectedJson( + "Subtype parameter must specify contacts, registrars or all", + "rdap_error_400.json")); + assertThat(response.getStatus()).isEqualTo(400); + metricSearchType = SearchType.NONE; // Error occurs before search type is set. + verifyErrorMetrics(Optional.empty(), 400); + } + @Test public void testNameMatchContact_found() throws Exception { login("2-RegistrarTest"); @@ -544,6 +564,30 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { verifyMetrics(1); } + @Test + public void testNameMatchContact_found_subtypeAll() throws Exception { + login("2-RegistrarTest"); + action.subtypeParam = Optional.of("aLl"); + runSuccessfulNameTestWithBlinky("Blinky (赤ベイ)", "rdap_contact.json"); + verifyMetrics(1); + } + + @Test + public void testNameMatchContact_found_subtypeContacts() throws Exception { + login("2-RegistrarTest"); + action.subtypeParam = Optional.of("cONTACTS"); + runSuccessfulNameTestWithBlinky("Blinky (赤ベイ)", "rdap_contact.json"); + verifyMetrics(1); + } + + @Test + public void testNameMatchContact_notFound_subtypeRegistrars() throws Exception { + login("2-RegistrarTest"); + action.subtypeParam = Optional.of("Registrars"); + runNotFoundNameTest("Blinky (赤ベイ)"); + verifyErrorMetrics(0); + } + @Test public void testNameMatchContact_found_specifyingSameRegistrar() throws Exception { login("2-RegistrarTest"); @@ -653,6 +697,32 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { verifyMetrics(0); } + @Test + public void testNameMatchRegistrar_found_subtypeAll() throws Exception { + login("2-RegistrarTest"); + action.subtypeParam = Optional.of("all"); + runSuccessfulNameTest( + "Yes Virginia