Add next page navigation for RDAP entity searches

A couple methods were moved to new locations so they are accessible to all types of search queries, not just nameservers like they originally were.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=179089014
This commit is contained in:
mountford 2017-12-14 13:40:02 -08:00 committed by Ben McIlwain
parent c8059d4d8a
commit e619ea1bff
12 changed files with 398 additions and 111 deletions

View file

@ -121,6 +121,10 @@ public class ContactResource extends EppResource implements
return internationalizedPostalInfo; return internationalizedPostalInfo;
} }
public String getSearchName() {
return searchName;
}
public ContactPhoneNumber getVoiceNumber() { public ContactPhoneNumber getVoiceNumber() {
return voice; return voice;
} }

View file

@ -419,6 +419,7 @@ public abstract class RdapActionBase implements Runnable {
static <T extends EppResource> Query<T> queryItemsByKey( static <T extends EppResource> Query<T> queryItemsByKey(
Class<T> clazz, Class<T> clazz,
RdapSearchPattern partialStringQuery, RdapSearchPattern partialStringQuery,
Optional<String> cursorString,
DeletedItemHandling deletedItemHandling, DeletedItemHandling deletedItemHandling,
int resultSetMaxSize) { int resultSetMaxSize) {
if (partialStringQuery.getInitialString().length() if (partialStringQuery.getInitialString().length()
@ -437,6 +438,9 @@ public abstract class RdapActionBase implements Runnable {
.filterKey(">=", Key.create(clazz, partialStringQuery.getInitialString())) .filterKey(">=", Key.create(clazz, partialStringQuery.getInitialString()))
.filterKey("<", Key.create(clazz, partialStringQuery.getNextInitialString())); .filterKey("<", Key.create(clazz, partialStringQuery.getNextInitialString()));
} }
if (cursorString.isPresent()) {
query = query.filterKey(">", Key.create(clazz, cursorString.get()));
}
return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize); return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize);
} }

View file

@ -51,6 +51,24 @@ import org.joda.time.DateTime;
* *
* <p>All commands and responses conform to the RDAP spec as defined in RFCs 7480 through 7485. * <p>All commands and responses conform to the RDAP spec as defined in RFCs 7480 through 7485.
* *
* <p>The RDAP specification lumps contacts and registrars together and calls them "entities", which
* is confusing for us, because "entity" means something else in Objectify. But here, when we use
* the term, it means either a contact or registrar. When searching for entities, we always start by
* returning all matching contacts, and after that all matching registrars.
*
* <p>There are two ways to search for entities: by full name (for contacts, the search name, for
* registrars, the registrar name) or by handle (for contacts, the ROID, for registrars, the IANA
* number). The ICANN operational profile document specifies this meaning for handle searches.
*
* <p>Cursors are complicated by the fact that we are essentially doing two independent searches:
* one for contacts, and one for registrars. To accommodate this, the cursor has a prefix indicating
* the type of the last returned item. If the last item was a contact, we return c:{value}, where
* the value is either the search name or the ROID. If the last item was a registrar, we return
* r:{value}, where the value is either the registrar name or the IANA number. If we get a c:
* cursor, we use it to weed out contacts, and fetch all registrars. If we get an r: cursor, we know
* that we can skip the contact search altogether (because we returned a registrar, and all
* registrars come after all contacts).
*
* @see <a href="http://tools.ietf.org/html/rfc7482">RFC 7482: Registration Data Access Protocol * @see <a href="http://tools.ietf.org/html/rfc7482">RFC 7482: Registration Data Access Protocol
* (RDAP) Query Format</a> * (RDAP) Query Format</a>
* @see <a href="http://tools.ietf.org/html/rfc7483">RFC 7483: JSON Responses for the Registration * @see <a href="http://tools.ietf.org/html/rfc7483">RFC 7483: JSON Responses for the Registration
@ -70,6 +88,20 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
@Inject @Parameter("handle") Optional<String> handleParam; @Inject @Parameter("handle") Optional<String> handleParam;
@Inject RdapEntitySearchAction() {} @Inject RdapEntitySearchAction() {}
private enum QueryType {
FULL_NAME,
HANDLE
}
private enum CursorType {
NONE,
CONTACT,
REGISTRAR
}
private static final String CONTACT_CURSOR_PREFIX = "c:";
private static final String REGISTRAR_CURSOR_PREFIX = "r:";
@Override @Override
public String getHumanReadableObjectTypeName() { public String getHumanReadableObjectTypeName() {
return "entity search"; return "entity search";
@ -90,6 +122,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
public ImmutableMap<String, Object> getJsonObjectForResource( public ImmutableMap<String, Object> getJsonObjectForResource(
String pathSearchString, boolean isHeadRequest) { String pathSearchString, boolean isHeadRequest) {
DateTime now = clock.nowUtc(); DateTime now = clock.nowUtc();
// RDAP syntax example: /rdap/entities?fn=Bobby%20Joe*. // RDAP syntax example: /rdap/entities?fn=Bobby%20Joe*.
// The pathSearchString is not used by search commands. // The pathSearchString is not used by search commands.
if (pathSearchString.length() > 0) { if (pathSearchString.length() > 0) {
@ -98,21 +131,54 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
if (Booleans.countTrue(fnParam.isPresent(), handleParam.isPresent()) != 1) { if (Booleans.countTrue(fnParam.isPresent(), handleParam.isPresent()) != 1) {
throw new BadRequestException("You must specify either fn=XXXX or handle=YYYY"); throw new BadRequestException("You must specify either fn=XXXX or handle=YYYY");
} }
// Decode the cursor token and extract the prefix and string portions.
decodeCursorToken();
CursorType cursorType;
Optional<String> cursorQueryString;
if (!cursorString.isPresent()) {
cursorType = CursorType.NONE;
cursorQueryString = Optional.empty();
} else {
if (cursorString.get().startsWith(CONTACT_CURSOR_PREFIX)) {
cursorType = CursorType.CONTACT;
cursorQueryString =
Optional.of(cursorString.get().substring(CONTACT_CURSOR_PREFIX.length()));
} else if (cursorString.get().startsWith(REGISTRAR_CURSOR_PREFIX)) {
cursorType = CursorType.REGISTRAR;
cursorQueryString =
Optional.of(cursorString.get().substring(REGISTRAR_CURSOR_PREFIX.length()));
} else {
throw new BadRequestException(String.format("invalid cursor: %s", cursorTokenParam));
}
}
// Search by name.
RdapSearchResults results; RdapSearchResults results;
if (fnParam.isPresent()) { if (fnParam.isPresent()) {
metricInformationBuilder.setSearchType(SearchType.BY_FULL_NAME); metricInformationBuilder.setSearchType(SearchType.BY_FULL_NAME);
// syntax: /rdap/entities?fn=Bobby%20Joe* // syntax: /rdap/entities?fn=Bobby%20Joe*
// The name is the contact name or registrar name (not registrar contact name). // The name is the contact name or registrar name (not registrar contact name).
results = results =
searchByName(recordWildcardType(RdapSearchPattern.create(fnParam.get(), false)), now); searchByName(
recordWildcardType(RdapSearchPattern.create(fnParam.get(), false)),
cursorType,
cursorQueryString,
now);
// Search by handle.
} else { } else {
metricInformationBuilder.setSearchType(SearchType.BY_HANDLE); metricInformationBuilder.setSearchType(SearchType.BY_HANDLE);
// syntax: /rdap/entities?handle=12345-* // syntax: /rdap/entities?handle=12345-*
// The handle is either the contact roid or the registrar clientId. // The handle is either the contact roid or the registrar clientId.
results = results =
searchByHandle( searchByHandle(
recordWildcardType(RdapSearchPattern.create(handleParam.get(), false)), now); recordWildcardType(RdapSearchPattern.create(handleParam.get(), false)),
cursorQueryString,
now);
} }
// Build the result object and return it.
if (results.jsonList().isEmpty()) { if (results.jsonList().isEmpty()) {
throw new NotFoundException("No entities found"); throw new NotFoundException("No entities found");
} }
@ -121,7 +187,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
rdapJsonFormatter.addTopLevelEntries( rdapJsonFormatter.addTopLevelEntries(
jsonBuilder, jsonBuilder,
BoilerplateType.ENTITY, BoilerplateType.ENTITY,
results.getIncompletenessWarnings(), getNotices(results),
ImmutableList.of(), ImmutableList.of(),
fullServletPath); fullServletPath);
return jsonBuilder.build(); return jsonBuilder.build();
@ -133,8 +199,8 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
* <p>As per Gustavo Lozano of ICANN, registrar name search should be by registrar name only, not * <p>As per Gustavo Lozano of ICANN, registrar name search should be by registrar name only, not
* by registrar contact name: * by registrar contact name:
* *
* <p>The search is by registrar name only. The profile is supporting the functionality defined * <p>The search is by registrar name only. The profile is supporting the functionality defined in
* in the Base Registry Agreement. * the Base Registry Agreement.
* *
* <p>According to RFC 7482 section 6.1, punycode is only used for domain name labels, so we can * <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. * assume that entity names are regular unicode.
@ -143,14 +209,19 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
* set to null when the contact is deleted, so a deleted contact can never have a name. * set to null when the contact is deleted, so a deleted contact can never have a name.
* *
* <p>Since we are restricting access to contact names, we don't want name searches to return * <p>Since we are restricting access to contact names, we don't want name searches to return
* contacts whose names are not visible. That would allow unscrupulous users to query by name * contacts whose names are not visible. That would allow unscrupulous users to query by name and
* and infer that all returned contacts contain that name string. So we check the authorization * infer that all returned contacts contain that name string. So we check the authorization level
* level to determine what to do. * to determine what to do.
* *
* @see <a href="https://newgtlds.icann.org/sites/default/files/agreements/agreement-approved-09jan14-en.htm">1.6 * @see <a
* href="https://newgtlds.icann.org/sites/default/files/agreements/agreement-approved-09jan14-en.htm">1.6
* of Section 4 of the Base Registry Agreement</a> * of Section 4 of the Base Registry Agreement</a>
*/ */
private RdapSearchResults searchByName(final RdapSearchPattern partialStringQuery, DateTime now) { private RdapSearchResults searchByName(
final RdapSearchPattern partialStringQuery,
CursorType cursorType,
Optional<String> cursorQueryString,
DateTime now) {
// For wildcard searches, make sure the initial string is long enough, and don't allow suffixes. // For wildcard searches, make sure the initial string is long enough, and don't allow suffixes.
if (partialStringQuery.getHasWildcard() && (partialStringQuery.getSuffix() != null)) { if (partialStringQuery.getHasWildcard() && (partialStringQuery.getSuffix() != null)) {
throw new UnprocessableEntityException( throw new UnprocessableEntityException(
@ -166,21 +237,27 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
? "Initial search string required in wildcard entity name searches" ? "Initial search string required in wildcard entity name searches"
: "Initial search string required when searching for deleted entities"); : "Initial search string required when searching for deleted entities");
} }
// Get the registrar matches. // 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<Registrar> registrars = ImmutableList<Registrar> registrars =
Streams.stream(Registrar.loadAllCached()) Streams.stream(Registrar.loadAllCached())
.filter( .filter(
registrar -> registrar ->
partialStringQuery.matches(registrar.getRegistrarName()) partialStringQuery.matches(registrar.getRegistrarName())
&& ((cursorType != CursorType.REGISTRAR)
|| (registrar.getRegistrarName().compareTo(cursorQueryString.get())
> 0))
&& shouldBeVisible(registrar)) && shouldBeVisible(registrar))
.limit(rdapResultSetMaxSize + 1) .limit(rdapResultSetMaxSize + 1)
.collect(toImmutableList()); .collect(toImmutableList());
// Get the contact matches and return the results, fetching an additional contact to detect // 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 // truncation. Don't bother searching for contacts by name if the request would not be able to
// see any names anyway. // 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.
RdapResultSet<ContactResource> resultSet; RdapResultSet<ContactResource> resultSet;
RdapAuthorization authorization = getAuthorization(); RdapAuthorization authorization = getAuthorization();
if (authorization.role() == RdapAuthorization.Role.PUBLIC) { if ((authorization.role() == RdapAuthorization.Role.PUBLIC)
|| (cursorType == CursorType.REGISTRAR)) {
resultSet = RdapResultSet.create(ImmutableList.of()); resultSet = RdapResultSet.create(ImmutableList.of());
} else { } else {
Query<ContactResource> query = Query<ContactResource> query =
@ -188,6 +265,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
ContactResource.class, ContactResource.class,
"searchName", "searchName",
partialStringQuery, partialStringQuery,
cursorQueryString, // if we get this far, and there's a cursor, it must be a contact
DeletedItemHandling.EXCLUDE, DeletedItemHandling.EXCLUDE,
rdapResultSetMaxSize + 1); rdapResultSetMaxSize + 1);
if (authorization.role() != RdapAuthorization.Role.ADMINISTRATOR) { if (authorization.role() != RdapAuthorization.Role.ADMINISTRATOR) {
@ -195,7 +273,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
} }
resultSet = getMatchingResources(query, false, now, rdapResultSetMaxSize + 1); resultSet = getMatchingResources(query, false, now, rdapResultSetMaxSize + 1);
} }
return makeSearchResults(resultSet, registrars, now); return makeSearchResults(resultSet, registrars, QueryType.FULL_NAME, now);
} }
/** /**
@ -209,7 +287,9 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
* there is no equivalent string suffix that can be used as a query filter, so we disallow use. * there is no equivalent string suffix that can be used as a query filter, so we disallow use.
*/ */
private RdapSearchResults searchByHandle( private RdapSearchResults searchByHandle(
final RdapSearchPattern partialStringQuery, DateTime now) { final RdapSearchPattern partialStringQuery,
Optional<String> cursorQueryString,
DateTime now) {
if (partialStringQuery.getSuffix() != null) { if (partialStringQuery.getSuffix() != null) {
throw new UnprocessableEntityException("Suffixes not allowed in entity handle searches"); throw new UnprocessableEntityException("Suffixes not allowed in entity handle searches");
} }
@ -228,6 +308,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
IncompletenessWarningType.COMPLETE, IncompletenessWarningType.COMPLETE,
contactResourceList.size(), contactResourceList.size(),
getMatchingRegistrars(partialStringQuery.getInitialString()), getMatchingRegistrars(partialStringQuery.getInitialString()),
QueryType.HANDLE,
now); now);
// Handle queries with a wildcard (or including deleted), but no suffix. Because the handle // Handle queries with a wildcard (or including deleted), but no suffix. Because the handle
// for registrars is the IANA identifier number, don't allow wildcard searches for registrars, // for registrars is the IANA identifier number, don't allow wildcard searches for registrars,
@ -240,14 +321,20 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
: getMatchingRegistrars(partialStringQuery.getInitialString()); : getMatchingRegistrars(partialStringQuery.getInitialString());
// Get the contact matches and return the results, fetching an additional contact to detect // Get the contact matches and return the results, fetching an additional contact to detect
// truncation. If we are including deleted entries, we must fetch more entries, in case some // truncation. If we are including deleted entries, we must fetch more entries, in case some
// get excluded due to permissioning. // 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(); int querySizeLimit = getStandardQuerySizeLimit();
Query<ContactResource> query = Query<ContactResource> query =
queryItemsByKey( queryItemsByKey(
ContactResource.class, partialStringQuery, getDeletedItemHandling(), querySizeLimit); ContactResource.class,
partialStringQuery,
cursorQueryString,
getDeletedItemHandling(),
querySizeLimit);
return makeSearchResults( return makeSearchResults(
getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit), getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit),
registrars, registrars,
QueryType.HANDLE,
now); now);
} }
} }
@ -271,12 +358,16 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
* properties of the {@link RdapResultSet} structure and passes them as separate arguments. * properties of the {@link RdapResultSet} structure and passes them as separate arguments.
*/ */
private RdapSearchResults makeSearchResults( private RdapSearchResults makeSearchResults(
RdapResultSet<ContactResource> resultSet, List<Registrar> registrars, DateTime now) { RdapResultSet<ContactResource> resultSet,
List<Registrar> registrars,
QueryType queryType,
DateTime now) {
return makeSearchResults( return makeSearchResults(
resultSet.resources(), resultSet.resources(),
resultSet.incompletenessWarningType(), resultSet.incompletenessWarningType(),
resultSet.numResourcesRetrieved(), resultSet.numResourcesRetrieved(),
registrars, registrars,
queryType,
now); now);
} }
@ -292,6 +383,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
* @param numContactsRetrieved the number of contacts retrieved in the process of generating the * @param numContactsRetrieved the number of contacts retrieved in the process of generating the
* results * results
* @param registrars the list of registrars which can be returned * @param registrars the list of registrars which can be returned
* @param queryType whether the query was by full name or by handle
* @param now the current date and time * @param now the current date and time
* @return an {@link RdapSearchResults} object * @return an {@link RdapSearchResults} object
*/ */
@ -300,6 +392,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
IncompletenessWarningType incompletenessWarningType, IncompletenessWarningType incompletenessWarningType,
int numContactsRetrieved, int numContactsRetrieved,
List<Registrar> registrars, List<Registrar> registrars,
QueryType queryType,
DateTime now) { DateTime now) {
metricInformationBuilder.setNumContactsRetrieved(numContactsRetrieved); metricInformationBuilder.setNumContactsRetrieved(numContactsRetrieved);
@ -314,12 +407,16 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
// so we can tell whether to display the truncation notification. // so we can tell whether to display the truncation notification.
RdapAuthorization authorization = getAuthorization(); RdapAuthorization authorization = getAuthorization();
List<ImmutableMap<String, Object>> jsonOutputList = new ArrayList<>(); List<ImmutableMap<String, Object>> jsonOutputList = new ArrayList<>();
// Each time we add a contact or registrar to the output data set, remember what the appropriate
// cursor would be if it were the last item returned. When we stop adding items, the last cursor
// value we remembered will be the right one to pass back.
Optional<String> newCursor = Optional.empty();
for (ContactResource contact : contacts) { for (ContactResource contact : contacts) {
if (jsonOutputList.size() >= rdapResultSetMaxSize) { if (jsonOutputList.size() >= rdapResultSetMaxSize) {
return RdapSearchResults.create( return RdapSearchResults.create(
ImmutableList.copyOf(jsonOutputList), ImmutableList.copyOf(jsonOutputList),
IncompletenessWarningType.TRUNCATED, IncompletenessWarningType.TRUNCATED,
Optional.empty()); newCursor);
} }
// As per Andy Newton on the regext mailing list, contacts by themselves have no role, since // 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. // they are global, and might have different roles for different domains.
@ -332,16 +429,28 @@ public class RdapEntitySearchAction extends RdapSearchActionBase {
now, now,
outputDataType, outputDataType,
authorization)); authorization));
newCursor =
Optional.of(
CONTACT_CURSOR_PREFIX
+ ((queryType == QueryType.FULL_NAME)
? contact.getSearchName()
: contact.getRepoId()));
} }
for (Registrar registrar : registrars) { for (Registrar registrar : registrars) {
if (jsonOutputList.size() >= rdapResultSetMaxSize) { if (jsonOutputList.size() >= rdapResultSetMaxSize) {
return RdapSearchResults.create( return RdapSearchResults.create(
ImmutableList.copyOf(jsonOutputList), ImmutableList.copyOf(jsonOutputList),
IncompletenessWarningType.TRUNCATED, IncompletenessWarningType.TRUNCATED,
Optional.empty()); newCursor);
} }
jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForRegistrar( jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForRegistrar(
registrar, false, fullServletPath, rdapWhoisServer, now, outputDataType)); registrar, false, fullServletPath, rdapWhoisServer, now, outputDataType));
newCursor =
Optional.of(
REGISTRAR_CURSOR_PREFIX
+ ((queryType == QueryType.FULL_NAME)
? registrar.getRegistrarName()
: registrar.getIanaIdentifier()));
} }
return RdapSearchResults.create( return RdapSearchResults.create(
ImmutableList.copyOf(jsonOutputList), ImmutableList.copyOf(jsonOutputList),

View file

@ -139,21 +139,12 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase {
ImmutableMap.Builder<String, Object> jsonBuilder = new ImmutableMap.Builder<>(); ImmutableMap.Builder<String, Object> jsonBuilder = new ImmutableMap.Builder<>();
jsonBuilder.put("nameserverSearchResults", results.jsonList()); jsonBuilder.put("nameserverSearchResults", results.jsonList());
ImmutableList<ImmutableMap<String, Object>> notices = results.getIncompletenessWarnings();
if (results.nextCursor().isPresent()) {
ImmutableList.Builder<ImmutableMap<String, Object>> noticesBuilder =
new ImmutableList.Builder<>();
noticesBuilder.addAll(notices);
noticesBuilder.add(
RdapJsonFormatter.makeRdapJsonNavigationLinkNotice(
Optional.of(
getRequestUrlWithExtraParameter(
"cursor", encodeCursorToken(results.nextCursor().get())))));
notices = noticesBuilder.build();
}
rdapJsonFormatter.addTopLevelEntries( rdapJsonFormatter.addTopLevelEntries(
jsonBuilder, BoilerplateType.NAMESERVER, notices, ImmutableList.of(), fullServletPath); jsonBuilder,
BoilerplateType.NAMESERVER,
getNotices(results),
ImmutableList.of(),
fullServletPath);
return jsonBuilder.build(); return jsonBuilder.build();
} }

View file

@ -18,6 +18,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import google.registry.request.Parameter; import google.registry.request.Parameter;
import google.registry.request.ParameterMap; import google.registry.request.ParameterMap;
import google.registry.request.RequestUrl; import google.registry.request.RequestUrl;
@ -112,4 +113,20 @@ public abstract class RdapSearchActionBase extends RdapActionBase {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
ImmutableList<ImmutableMap<String, Object>> getNotices(RdapSearchResults results) {
ImmutableList<ImmutableMap<String, Object>> notices = results.getIncompletenessWarnings();
if (results.nextCursor().isPresent()) {
ImmutableList.Builder<ImmutableMap<String, Object>> noticesBuilder =
new ImmutableList.Builder<>();
noticesBuilder.addAll(notices);
noticesBuilder.add(
RdapJsonFormatter.makeRdapJsonNavigationLinkNotice(
Optional.of(
getRequestUrlWithExtraParameter(
"cursor", encodeCursorToken(results.nextCursor().get())))));
notices = noticesBuilder.build();
}
return notices;
}
} }

View file

@ -34,8 +34,10 @@ import static org.mockito.Mockito.when;
import com.google.appengine.api.users.User; import com.google.appengine.api.users.User;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import google.registry.model.ImmutableObject; import google.registry.model.ImmutableObject;
import google.registry.model.contact.ContactResource;
import google.registry.model.ofy.Ofy; import google.registry.model.ofy.Ofy;
import google.registry.model.registrar.Registrar; import google.registry.model.registrar.Registrar;
import google.registry.rdap.RdapMetrics.EndpointType; import google.registry.rdap.RdapMetrics.EndpointType;
@ -50,12 +52,14 @@ import google.registry.testing.FakeClock;
import google.registry.testing.FakeResponse; import google.registry.testing.FakeResponse;
import google.registry.testing.InjectRule; import google.registry.testing.InjectRule;
import google.registry.ui.server.registrar.SessionUtils; import google.registry.ui.server.registrar.SessionUtils;
import java.net.URLDecoder;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.joda.time.DateTime; import org.joda.time.DateTime;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject; import org.json.simple.JSONObject;
import org.json.simple.JSONValue; import org.json.simple.JSONValue;
import org.junit.Before; import org.junit.Before;
@ -71,8 +75,12 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
@Rule public final AppEngineRule appEngine = AppEngineRule.builder().withDatastore().build(); @Rule public final AppEngineRule appEngine = AppEngineRule.builder().withDatastore().build();
@Rule public final InjectRule inject = new InjectRule(); @Rule public final InjectRule inject = new InjectRule();
private enum QueryType {
FULL_NAME,
HANDLE
}
private final HttpServletRequest request = mock(HttpServletRequest.class); private final HttpServletRequest request = mock(HttpServletRequest.class);
private final FakeResponse response = new FakeResponse();
private final FakeClock clock = new FakeClock(DateTime.parse("2000-01-01T00:00:00Z")); private final FakeClock clock = new FakeClock(DateTime.parse("2000-01-01T00:00:00Z"));
private final SessionUtils sessionUtils = mock(SessionUtils.class); private final SessionUtils sessionUtils = mock(SessionUtils.class);
private final User user = new User("rdap.user@example.com", "gmail.com", "12345"); private final User user = new User("rdap.user@example.com", "gmail.com", "12345");
@ -80,20 +88,44 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
private final UserAuthInfo adminUserAuthInfo = UserAuthInfo.create(user, true); private final UserAuthInfo adminUserAuthInfo = UserAuthInfo.create(user, true);
private final RdapEntitySearchAction action = new RdapEntitySearchAction(); private final RdapEntitySearchAction action = new RdapEntitySearchAction();
private FakeResponse response = new FakeResponse();
private Registrar registrarDeleted; private Registrar registrarDeleted;
private Registrar registrarInactive; private Registrar registrarInactive;
private Registrar registrarTest; private Registrar registrarTest;
private Object generateActualJsonWithFullName(String fn) { private Object generateActualJsonWithFullName(String fn) {
return generateActualJsonWithFullName(fn, null);
}
private Object generateActualJsonWithFullName(String fn, String cursor) {
metricSearchType = SearchType.BY_FULL_NAME; metricSearchType = SearchType.BY_FULL_NAME;
action.fnParam = Optional.of(fn); action.fnParam = Optional.of(fn);
if (cursor == null) {
action.parameterMap = ImmutableListMultimap.of("fn", fn);
action.cursorTokenParam = Optional.empty();
} else {
action.parameterMap = ImmutableListMultimap.of("fn", fn, "cursor", cursor);
action.cursorTokenParam = Optional.of(cursor);
}
action.run(); action.run();
return JSONValue.parse(response.getPayload()); return JSONValue.parse(response.getPayload());
} }
private Object generateActualJsonWithHandle(String handle) { private Object generateActualJsonWithHandle(String handle) {
return generateActualJsonWithHandle(handle, null);
}
private Object generateActualJsonWithHandle(String handle, String cursor) {
metricSearchType = SearchType.BY_HANDLE; metricSearchType = SearchType.BY_HANDLE;
action.handleParam = Optional.of(handle); action.handleParam = Optional.of(handle);
if (cursor == null) {
action.parameterMap = ImmutableListMultimap.of("handle", handle);
action.cursorTokenParam = Optional.empty();
} else {
action.parameterMap = ImmutableListMultimap.of("handle", handle, "cursor", cursor);
action.cursorTokenParam = Optional.of(cursor);
}
action.run(); action.run();
return JSONValue.parse(response.getPayload()); return JSONValue.parse(response.getPayload());
} }
@ -151,7 +183,9 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
action.request = request; action.request = request;
action.requestMethod = Action.Method.GET; action.requestMethod = Action.Method.GET;
action.fullServletPath = "https://example.com/rdap"; action.fullServletPath = "https://example.com/rdap";
action.requestUrl = "https://example.com/rdap/entities";
action.requestPath = RdapEntitySearchAction.PATH; action.requestPath = RdapEntitySearchAction.PATH;
action.parameterMap = ImmutableListMultimap.of();
action.response = response; action.response = response;
action.rdapJsonFormatter = RdapTestHelper.getTestRdapJsonFormatter(); action.rdapJsonFormatter = RdapTestHelper.getTestRdapJsonFormatter();
action.rdapResultSetMaxSize = 4; action.rdapResultSetMaxSize = 4;
@ -164,6 +198,7 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
action.sessionUtils = sessionUtils; action.sessionUtils = sessionUtils;
action.authResult = AuthResult.create(AuthLevel.USER, userAuthInfo); action.authResult = AuthResult.create(AuthLevel.USER, userAuthInfo);
action.rdapMetrics = rdapMetrics; action.rdapMetrics = rdapMetrics;
action.cursorTokenParam = Optional.empty();
} }
private void login(String registrar) { private void login(String registrar) {
@ -236,11 +271,17 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
int numContacts, int numRegistrars, Registrar contactRegistrar) { int numContacts, int numRegistrars, Registrar contactRegistrar) {
ImmutableList.Builder<ImmutableObject> resourcesBuilder = new ImmutableList.Builder<>(); ImmutableList.Builder<ImmutableObject> resourcesBuilder = new ImmutableList.Builder<>();
for (int i = 1; i <= numContacts; i++) { for (int i = 1; i <= numContacts; i++) {
resourcesBuilder.add(makeContactResource( // Set the ROIDs to a known value for later use.
ContactResource contact =
makeContactResource(
String.format("contact%d", i), String.format("contact%d", i),
String.format("Entity %d", i), String.format("Entity %d", i),
String.format("contact%d@gmail.com", i), String.format("contact%d@gmail.com", i),
contactRegistrar)); contactRegistrar)
.asBuilder()
.setRepoId(String.format("%04d-ROID", i))
.build();
resourcesBuilder.add(contact);
} }
persistResources(resourcesBuilder.build()); persistResources(resourcesBuilder.build());
for (int i = 1; i <= numRegistrars; i++) { for (int i = 1; i <= numRegistrars; i++) {
@ -374,6 +415,45 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
assertThat(response.getStatus()).isEqualTo(404); assertThat(response.getStatus()).isEqualTo(404);
} }
/**
* Checks multi-page result set navigation using the cursor.
*
* <p>If there are more results than the max result set size, the RDAP code returns a cursor token
* which can be used in a subsequent call to get the next chunk of results. This method starts by
* making the query without a cursor, then follows the chain of pages using each returned cursor
* to ask for the next one, and makes sure that the expected number of pages are fetched.
*
* @param queryType type of query being run
* @param queryString the full name or handle query string
* @param expectedPageCount how many pages we expect to retrieve; all but the last will have a
* cursor
*/
private void checkCursorNavigation(QueryType queryType, String queryString, int expectedPageCount)
throws Exception {
String cursor = null;
for (int i = 0; i < expectedPageCount; i++) {
Object results =
(queryType == QueryType.FULL_NAME)
? generateActualJsonWithFullName(queryString, cursor)
: generateActualJsonWithHandle(queryString, cursor);
assertThat(response.getStatus()).isEqualTo(200);
String linkToNext = RdapTestHelper.getLinkToNext(results);
if (i == expectedPageCount - 1) {
assertThat(linkToNext).isNull();
} else {
assertThat(linkToNext).isNotNull();
int pos = linkToNext.indexOf("cursor=");
assertThat(pos).isAtLeast(0);
cursor = URLDecoder.decode(linkToNext.substring(pos + 7), "UTF-8");
Object nameserverSearchResults = ((JSONObject) results).get("entitySearchResults");
assertThat(nameserverSearchResults).isInstanceOf(JSONArray.class);
assertThat(((JSONArray) nameserverSearchResults)).hasSize(action.rdapResultSetMaxSize);
response = new FakeResponse();
action.response = response;
}
}
}
@Test @Test
public void testInvalidPath_rejected() throws Exception { public void testInvalidPath_rejected() throws Exception {
action.requestPath = RdapEntitySearchAction.PATH + "/path"; action.requestPath = RdapEntitySearchAction.PATH + "/path";
@ -591,7 +671,9 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
createManyContactsAndRegistrars(5, 0, registrarTest); createManyContactsAndRegistrars(5, 0, registrarTest);
rememberWildcardType("Entity *"); rememberWildcardType("Entity *");
assertThat(generateActualJsonWithFullName("Entity *")) assertThat(generateActualJsonWithFullName("Entity *"))
.isEqualTo(generateExpectedJson("rdap_truncated_contacts.json")); .isEqualTo(
generateExpectedJson(
"fn=Entity+*&cursor=YzpFbnRpdHkgNA%3D%3D", "rdap_truncated_contacts.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(5); verifyMetrics(5);
} }
@ -602,12 +684,21 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
createManyContactsAndRegistrars(9, 0, registrarTest); createManyContactsAndRegistrars(9, 0, registrarTest);
rememberWildcardType("Entity *"); rememberWildcardType("Entity *");
assertThat(generateActualJsonWithFullName("Entity *")) assertThat(generateActualJsonWithFullName("Entity *"))
.isEqualTo(generateExpectedJson("rdap_truncated_contacts.json")); .isEqualTo(
generateExpectedJson(
"fn=Entity+*&cursor=YzpFbnRpdHkgNA%3D%3D", "rdap_truncated_contacts.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
// For contacts, we only need to fetch one result set's worth (plus one). // For contacts, we only need to fetch one result set's worth (plus one).
verifyMetrics(5); verifyMetrics(5);
} }
@Test
public void testNameMatchContacts_cursorNavigation() throws Exception {
login("2-RegistrarTest");
createManyContactsAndRegistrars(9, 0, registrarTest);
checkCursorNavigation(QueryType.FULL_NAME, "Entity *", 3);
}
@Test @Test
public void testNameMatchRegistrars_nonTruncated() throws Exception { public void testNameMatchRegistrars_nonTruncated() throws Exception {
createManyContactsAndRegistrars(0, 4, registrarTest); createManyContactsAndRegistrars(0, 4, registrarTest);
@ -623,7 +714,9 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
createManyContactsAndRegistrars(0, 5, registrarTest); createManyContactsAndRegistrars(0, 5, registrarTest);
rememberWildcardType("Entity *"); rememberWildcardType("Entity *");
assertThat(generateActualJsonWithFullName("Entity *")) assertThat(generateActualJsonWithFullName("Entity *"))
.isEqualTo(generateExpectedJson("rdap_truncated_registrars.json")); .isEqualTo(
generateExpectedJson(
"fn=Entity+*&cursor=cjpFbnRpdHkgNA%3D%3D", "rdap_truncated_registrars.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(0); verifyMetrics(0);
} }
@ -633,22 +726,39 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
createManyContactsAndRegistrars(0, 9, registrarTest); createManyContactsAndRegistrars(0, 9, registrarTest);
rememberWildcardType("Entity *"); rememberWildcardType("Entity *");
assertThat(generateActualJsonWithFullName("Entity *")) assertThat(generateActualJsonWithFullName("Entity *"))
.isEqualTo(generateExpectedJson("rdap_truncated_registrars.json")); .isEqualTo(
generateExpectedJson(
"fn=Entity+*&cursor=cjpFbnRpdHkgNA%3D%3D", "rdap_truncated_registrars.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(0); verifyMetrics(0);
} }
@Test
public void testNameMatchRegistrars_cursorNavigation() throws Exception {
createManyContactsAndRegistrars(0, 13, registrarTest);
checkCursorNavigation(QueryType.FULL_NAME, "Entity *", 4);
}
@Test @Test
public void testNameMatchMix_truncated() throws Exception { public void testNameMatchMix_truncated() throws Exception {
login("2-RegistrarTest"); login("2-RegistrarTest");
createManyContactsAndRegistrars(3, 3, registrarTest); createManyContactsAndRegistrars(3, 3, registrarTest);
rememberWildcardType("Entity *"); rememberWildcardType("Entity *");
assertThat(generateActualJsonWithFullName("Entity *")) assertThat(generateActualJsonWithFullName("Entity *"))
.isEqualTo(generateExpectedJson("rdap_truncated_mixed_entities.json")); .isEqualTo(
generateExpectedJson(
"fn=Entity+*&cursor=cjpFbnRpdHkgNA%3D%3D", "rdap_truncated_mixed_entities.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(3); verifyMetrics(3);
} }
@Test
public void testNameMatchMix_cursorNavigation() throws Exception {
login("2-RegistrarTest");
createManyContactsAndRegistrars(3, 3, registrarTest);
checkCursorNavigation(QueryType.FULL_NAME, "Entity *", 2);
}
@Test @Test
public void testNameMatchRegistrar_notFound_inactive() throws Exception { public void testNameMatchRegistrar_notFound_inactive() throws Exception {
runNotFoundNameTest("No Way"); runNotFoundNameTest("No Way");
@ -890,6 +1000,18 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
verifyErrorMetrics(0); verifyErrorMetrics(0);
} }
@Test
public void testHandleMatchContact_cursorNavigationWithFullLastPage() throws Exception {
createManyContactsAndRegistrars(12, 0, registrarTest);
checkCursorNavigation(QueryType.HANDLE, "00*", 3);
}
@Test
public void testHandleMatchContact_cursorNavigationWithPartialLastPage() throws Exception {
createManyContactsAndRegistrars(13, 0, registrarTest);
checkCursorNavigation(QueryType.HANDLE, "00*", 4);
}
@Test @Test
public void testHandleMatchRegistrar_notFound_wildcard() throws Exception { public void testHandleMatchRegistrar_notFound_wildcard() throws Exception {
runNotFoundHandleTest("3test*"); runNotFoundHandleTest("3test*");
@ -898,9 +1020,9 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase {
@Test @Test
public void testHandleMatchMix_found_truncated() throws Exception { public void testHandleMatchMix_found_truncated() throws Exception {
createManyContactsAndRegistrars(300, 0, registrarTest); createManyContactsAndRegistrars(30, 0, registrarTest);
rememberWildcardType("10*"); rememberWildcardType("00*");
Object obj = generateActualJsonWithHandle("10*"); Object obj = generateActualJsonWithHandle("00*");
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
checkNumberOfEntitiesInResult(obj, 4); checkNumberOfEntitiesInResult(obj, 4);
verifyMetrics(5); verifyMetrics(5);

View file

@ -709,34 +709,6 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
verifyErrorMetrics(); verifyErrorMetrics();
} }
private String getLinkToNext(Object results) {
assertThat(results).isInstanceOf(JSONObject.class);
Object notices = ((JSONObject) results).get("notices");
assertThat(notices).isInstanceOf(JSONArray.class);
for (Object notice : (JSONArray) notices) {
assertThat(notice).isInstanceOf(JSONObject.class);
Object title = ((JSONObject) notice).get("title");
assertThat(title).isInstanceOf(String.class);
if (!title.equals("Navigation Links")) {
continue;
}
Object links = ((JSONObject) notice).get("links");
assertThat(links).isInstanceOf(JSONArray.class);
for (Object link : (JSONArray) links) {
assertThat(link).isInstanceOf(JSONObject.class);
Object rel = ((JSONObject) link).get("rel");
assertThat(rel).isInstanceOf(String.class);
if (!rel.equals("next")) {
continue;
}
Object href = ((JSONObject) link).get("href");
assertThat(href).isInstanceOf(String.class);
return (String) href;
}
}
return null;
}
/** /**
* Checks multi-page result set navigation using the cursor. * Checks multi-page result set navigation using the cursor.
* *
@ -757,7 +729,7 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
? generateActualJsonWithName(queryString, cursor) ? generateActualJsonWithName(queryString, cursor)
: generateActualJsonWithIp(queryString, cursor); : generateActualJsonWithIp(queryString, cursor);
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
String linkToNext = getLinkToNext(results); String linkToNext = RdapTestHelper.getLinkToNext(results);
if (i == expectedPageCount - 1) { if (i == expectedPageCount - 1) {
assertThat(linkToNext).isNull(); assertThat(linkToNext).isNull();
} else { } else {

View file

@ -14,6 +14,8 @@
package google.registry.rdap; package google.registry.rdap;
import static com.google.common.truth.Truth.assertThat;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import google.registry.config.RdapNoticeDescriptor; import google.registry.config.RdapNoticeDescriptor;
@ -21,6 +23,8 @@ import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.Set; import java.util.Set;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
public class RdapTestHelper { public class RdapTestHelper {
@ -245,4 +249,32 @@ public class RdapTestHelper {
.build()); .build());
return rdapJsonFormatter; return rdapJsonFormatter;
} }
static String getLinkToNext(Object results) {
assertThat(results).isInstanceOf(JSONObject.class);
Object notices = ((JSONObject) results).get("notices");
assertThat(notices).isInstanceOf(JSONArray.class);
for (Object notice : (JSONArray) notices) {
assertThat(notice).isInstanceOf(JSONObject.class);
Object title = ((JSONObject) notice).get("title");
assertThat(title).isInstanceOf(String.class);
if (!title.equals("Navigation Links")) {
continue;
}
Object links = ((JSONObject) notice).get("links");
assertThat(links).isInstanceOf(JSONArray.class);
for (Object link : (JSONArray) links) {
assertThat(link).isInstanceOf(JSONObject.class);
Object rel = ((JSONObject) link).get("rel");
assertThat(rel).isInstanceOf(String.class);
if (!rel.equals("next")) {
continue;
}
Object href = ((JSONObject) link).get("href");
assertThat(href).isInstanceOf(String.class);
return (String) href;
}
}
return null;
}
} }

View file

@ -3,14 +3,14 @@
[ [
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "9-ROID", "handle" : "0001-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/9-ROID", "value" : "https://example.com/rdap/entity/0001-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/9-ROID", "href": "https://example.com/rdap/entity/0001-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -49,14 +49,14 @@
}, },
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "A-ROID", "handle" : "0002-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/A-ROID", "value" : "https://example.com/rdap/entity/0002-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/A-ROID", "href": "https://example.com/rdap/entity/0002-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -95,14 +95,14 @@
}, },
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "B-ROID", "handle" : "0003-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/B-ROID", "value" : "https://example.com/rdap/entity/0003-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/B-ROID", "href": "https://example.com/rdap/entity/0003-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -141,14 +141,14 @@
}, },
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "C-ROID", "handle" : "0004-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/C-ROID", "value" : "https://example.com/rdap/entity/0004-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/C-ROID", "href": "https://example.com/rdap/entity/0004-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],

View file

@ -3,14 +3,14 @@
[ [
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "9-ROID", "handle" : "0001-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/9-ROID", "value" : "https://example.com/rdap/entity/0001-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/9-ROID", "href": "https://example.com/rdap/entity/0001-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -49,14 +49,14 @@
}, },
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "A-ROID", "handle" : "0002-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/A-ROID", "value" : "https://example.com/rdap/entity/0002-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/A-ROID", "href": "https://example.com/rdap/entity/0002-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -95,14 +95,14 @@
}, },
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "B-ROID", "handle" : "0003-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/B-ROID", "value" : "https://example.com/rdap/entity/0003-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/B-ROID", "href": "https://example.com/rdap/entity/0003-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -141,14 +141,14 @@
}, },
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "C-ROID", "handle" : "0004-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/C-ROID", "value" : "https://example.com/rdap/entity/0004-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/C-ROID", "href": "https://example.com/rdap/entity/0004-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -197,6 +197,18 @@
"Search results per query are limited." "Search results per query are limited."
] ]
}, },
{
"title" : "Navigation Links",
"links" :
[
{
"type" : "application/rdap+json",
"href" : "https://example.com/rdap/entities?%NAME%",
"rel" : "next"
}
],
"description" : [ "Links to related pages." ],
},
{ {
"title" : "RDAP Terms of Service", "title" : "RDAP Terms of Service",
"description" : "description" :

View file

@ -3,14 +3,14 @@
[ [
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "9-ROID", "handle" : "0001-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/9-ROID", "value" : "https://example.com/rdap/entity/0001-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/9-ROID", "href": "https://example.com/rdap/entity/0001-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -49,14 +49,14 @@
}, },
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "A-ROID", "handle" : "0002-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/A-ROID", "value" : "https://example.com/rdap/entity/0002-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/A-ROID", "href": "https://example.com/rdap/entity/0002-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -95,14 +95,14 @@
}, },
{ {
"objectClassName" : "entity", "objectClassName" : "entity",
"handle" : "B-ROID", "handle" : "0003-ROID",
"status" : ["active"], "status" : ["active"],
"links" : "links" :
[ [
{ {
"value" : "https://example.com/rdap/entity/B-ROID", "value" : "https://example.com/rdap/entity/0003-ROID",
"rel" : "self", "rel" : "self",
"href": "https://example.com/rdap/entity/B-ROID", "href": "https://example.com/rdap/entity/0003-ROID",
"type" : "application/rdap+json" "type" : "application/rdap+json"
} }
], ],
@ -204,6 +204,18 @@
"Search results per query are limited." "Search results per query are limited."
] ]
}, },
{
"title" : "Navigation Links",
"links" :
[
{
"type" : "application/rdap+json",
"href" : "https://example.com/rdap/entities?%NAME%",
"rel" : "next"
}
],
"description" : [ "Links to related pages." ],
},
{ {
"title" : "RDAP Terms of Service", "title" : "RDAP Terms of Service",
"description" : "description" :

View file

@ -225,6 +225,18 @@
"Search results per query are limited." "Search results per query are limited."
] ]
}, },
{
"title" : "Navigation Links",
"links" :
[
{
"type" : "application/rdap+json",
"href" : "https://example.com/rdap/entities?%NAME%",
"rel" : "next"
}
],
"description" : [ "Links to related pages." ],
},
{ {
"title" : "RDAP Terms of Service", "title" : "RDAP Terms of Service",
"description" : "description" :