Add next page navigation for RDAP nameserver searches

Domain and entity searches will be handled in future CLs.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=178912832
This commit is contained in:
mountford 2017-12-13 09:28:30 -08:00 committed by Ben McIlwain
parent 7dc224627f
commit 359bab291b
12 changed files with 486 additions and 62 deletions

View file

@ -62,7 +62,7 @@ import org.joda.time.DateTime;
import org.json.simple.JSONValue; import org.json.simple.JSONValue;
/** /**
* Base RDAP (new WHOIS) action for single-item domain, nameserver and entity requests. * Base RDAP (new WHOIS) action for all requests.
* *
* @see <a href="https://tools.ietf.org/html/rfc7482"> * @see <a href="https://tools.ietf.org/html/rfc7482">
* RFC 7482: Registration Data Access Protocol (RDAP) Query Format</a> * RFC 7482: Registration Data Access Protocol (RDAP) Query Format</a>
@ -81,6 +81,12 @@ public abstract class RdapActionBase implements Runnable {
private static final MediaType RESPONSE_MEDIA_TYPE = private static final MediaType RESPONSE_MEDIA_TYPE =
MediaType.create("application", "rdap+json").withCharset(UTF_8); MediaType.create("application", "rdap+json").withCharset(UTF_8);
/** Whether to include or exclude deleted items from a query. */
protected enum DeletedItemHandling {
EXCLUDE,
INCLUDE
}
@Inject HttpServletRequest request; @Inject HttpServletRequest request;
@Inject Response response; @Inject Response response;
@Inject @RequestMethod Action.Method requestMethod; @Inject @RequestMethod Action.Method requestMethod;
@ -243,6 +249,10 @@ public abstract class RdapActionBase implements Runnable {
return true; return true;
} }
DeletedItemHandling getDeletedItemHandling() {
return shouldIncludeDeleted() ? DeletedItemHandling.INCLUDE : DeletedItemHandling.EXCLUDE;
}
/** /**
* Returns true if the request is authorized to see the resource. * Returns true if the request is authorized to see the resource.
* *
@ -271,7 +281,9 @@ public abstract class RdapActionBase implements Runnable {
} }
/** /**
* Returns true if the registrar should be visible. This is true iff: * Returns true if the registrar should be visible.
*
* <p>This is true iff:
* 1. The resource is active and publicly visible, or the request wants to see deleted items, and * 1. The resource is active and publicly visible, or the request wants to see deleted items, and
* is authorized to do so, and: * is authorized to do so, and:
* 2. The request did not specify a registrar to filter on, or the registrar matches. * 2. The request did not specify a registrar to filter on, or the registrar matches.
@ -297,11 +309,28 @@ public abstract class RdapActionBase implements Runnable {
: (rdapResultSetMaxSize + 1); : (rdapResultSetMaxSize + 1);
} }
static <T extends EppResource> Query<T> queryItems(
Class<T> clazz,
String filterField,
RdapSearchPattern partialStringQuery,
DeletedItemHandling deletedItemHandling,
int resultSetMaxSize) {
return queryItems(
clazz,
filterField,
partialStringQuery,
Optional.empty(),
deletedItemHandling,
resultSetMaxSize);
}
/** /**
* Handles prefix searches in cases where, if we need to filter out deleted items, there are no * Handles prefix searches in cases where, if we need to filter out deleted items, there are no
* pending deletes. In such cases, it is sufficient to check whether {@code deletionTime} is equal * pending deletes.
* to {@code END_OF_TIME}, because any other value means it has already been deleted. This allows *
* us to use an equality query for the deletion time. * <p>In such cases, it is sufficient to check whether {@code deletionTime} is equal to
* {@code END_OF_TIME}, because any other value means it has already been deleted. This allows us
* to use an equality query for the deletion time.
* *
* @param clazz the type of resource to be queried * @param clazz the type of resource to be queried
* @param filterField the database field of interest * @param filterField the database field of interest
@ -309,15 +338,18 @@ public abstract class RdapActionBase implements Runnable {
* equality query is used; if there is a wildcard, a range query is used instead; the * equality query is used; if there is a wildcard, a range query is used instead; the
* initial string should not be empty, and any search suffix will be ignored, so the caller * initial string should not be empty, and any search suffix will be ignored, so the caller
* must filter the results if a suffix is specified * must filter the results if a suffix is specified
* @param includeDeleted whether to search for deleted items as well * @param cursorString if a cursor is present, this parameter should specify the cursor string, to
* skip any results up to and including the string; empty() if there is no cursor
* @param deletedItemHandling whether to include or exclude deleted items
* @param resultSetMaxSize the maximum number of results to return * @param resultSetMaxSize the maximum number of results to return
* @return the results of the query * @return the query object
*/ */
static <T extends EppResource> Query<T> queryItems( static <T extends EppResource> Query<T> queryItems(
Class<T> clazz, Class<T> clazz,
String filterField, String filterField,
RdapSearchPattern partialStringQuery, RdapSearchPattern partialStringQuery,
boolean includeDeleted, Optional<String> cursorString,
DeletedItemHandling deletedItemHandling,
int resultSetMaxSize) { int resultSetMaxSize) {
if (partialStringQuery.getInitialString().length() if (partialStringQuery.getInitialString().length()
< RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) { < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) {
@ -335,18 +367,36 @@ public abstract class RdapActionBase implements Runnable {
.filter(filterField + " >=", partialStringQuery.getInitialString()) .filter(filterField + " >=", partialStringQuery.getInitialString())
.filter(filterField + " <", partialStringQuery.getNextInitialString()); .filter(filterField + " <", partialStringQuery.getNextInitialString());
} }
if (!includeDeleted) { if (cursorString.isPresent()) {
query = query.filter("deletionTime", END_OF_TIME); query = query.filter(filterField + " >", cursorString.get());
} }
return query.limit(resultSetMaxSize); return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize);
} }
/** Variant of queryItems using a simple string rather than an {@link RdapSearchPattern}. */ /**
* Handles searches using a simple string rather than an {@link RdapSearchPattern}.
*
* <p>Since the filter is not an inequality, we can support also checking a cursor string against
* a different field (which involves an inequality on that field).
*
* @param clazz the type of resource to be queried
* @param filterField the database field of interest
* @param queryString the search string
* @param cursorField the field which should be compared to the cursor string, or empty() if the
* key should be compared to a key created from the cursor string
* @param cursorString if a cursor is present, this parameter should specify the cursor string, to
* skip any results up to and including the string; empty() if there is no cursor
* @param deletedItemHandling whether to include or exclude deleted items
* @param resultSetMaxSize the maximum number of results to return
* @return the query object
*/
static <T extends EppResource> Query<T> queryItems( static <T extends EppResource> Query<T> queryItems(
Class<T> clazz, Class<T> clazz,
String filterField, String filterField,
String queryString, String queryString,
boolean includeDeleted, Optional<String> cursorField,
Optional<String> cursorString,
DeletedItemHandling deletedItemHandling,
int resultSetMaxSize) { int resultSetMaxSize) {
if (queryString.length() < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) { if (queryString.length() < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) {
throw new UnprocessableEntityException( throw new UnprocessableEntityException(
@ -355,14 +405,21 @@ public abstract class RdapActionBase implements Runnable {
RdapSearchPattern.MIN_INITIAL_STRING_LENGTH)); RdapSearchPattern.MIN_INITIAL_STRING_LENGTH));
} }
Query<T> query = ofy().load().type(clazz).filter(filterField, queryString); Query<T> query = ofy().load().type(clazz).filter(filterField, queryString);
return setOtherQueryAttributes(query, includeDeleted, resultSetMaxSize); if (cursorString.isPresent()) {
if (cursorField.isPresent()) {
query = query.filter(cursorField.get() + " >", cursorString.get());
} else {
query = query.filterKey(">", Key.create(clazz, cursorString.get()));
}
}
return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize);
} }
/** Variant of queryItems where the field to be searched is the key. */ /** Handles searches where the field to be searched is the key. */
static <T extends EppResource> Query<T> queryItemsByKey( static <T extends EppResource> Query<T> queryItemsByKey(
Class<T> clazz, Class<T> clazz,
RdapSearchPattern partialStringQuery, RdapSearchPattern partialStringQuery,
boolean includeDeleted, DeletedItemHandling deletedItemHandling,
int resultSetMaxSize) { int resultSetMaxSize) {
if (partialStringQuery.getInitialString().length() if (partialStringQuery.getInitialString().length()
< RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) { < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) {
@ -380,14 +437,14 @@ 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()));
} }
return setOtherQueryAttributes(query, includeDeleted, resultSetMaxSize); return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize);
} }
/** Variant of queryItems searching for a key by a simple string. */ /** Handles searches by key using a simple string. */
static <T extends EppResource> Query<T> queryItemsByKey( static <T extends EppResource> Query<T> queryItemsByKey(
Class<T> clazz, Class<T> clazz,
String queryString, String queryString,
boolean includeDeleted, DeletedItemHandling deletedItemHandling,
int resultSetMaxSize) { int resultSetMaxSize) {
if (queryString.length() < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) { if (queryString.length() < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) {
throw new UnprocessableEntityException( throw new UnprocessableEntityException(
@ -396,12 +453,12 @@ public abstract class RdapActionBase implements Runnable {
RdapSearchPattern.MIN_INITIAL_STRING_LENGTH)); RdapSearchPattern.MIN_INITIAL_STRING_LENGTH));
} }
Query<T> query = ofy().load().type(clazz).filterKey("=", Key.create(clazz, queryString)); Query<T> query = ofy().load().type(clazz).filterKey("=", Key.create(clazz, queryString));
return setOtherQueryAttributes(query, includeDeleted, resultSetMaxSize); return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize);
} }
private static <T extends EppResource> Query<T> setOtherQueryAttributes( private static <T extends EppResource> Query<T> setOtherQueryAttributes(
Query<T> query, boolean includeDeleted, int resultSetMaxSize) { Query<T> query, DeletedItemHandling deletedItemHandling, int resultSetMaxSize) {
if (!includeDeleted) { if (deletedItemHandling != DeletedItemHandling.INCLUDE) {
query = query.filter("deletionTime", END_OF_TIME); query = query.filter("deletionTime", END_OF_TIME);
} }
return query.limit(resultSetMaxSize); return query.limit(resultSetMaxSize);

View file

@ -70,7 +70,7 @@ import org.joda.time.DateTime;
method = {GET, HEAD}, method = {GET, HEAD},
auth = Auth.AUTH_PUBLIC auth = Auth.AUTH_PUBLIC
) )
public class RdapDomainSearchAction extends RdapActionBase { public class RdapDomainSearchAction extends RdapSearchActionBase {
public static final String PATH = "/rdap/domains"; public static final String PATH = "/rdap/domains";
@ -320,7 +320,7 @@ public class RdapDomainSearchAction extends RdapActionBase {
HostResource.class, HostResource.class,
"fullyQualifiedHostName", "fullyQualifiedHostName",
partialStringQuery, partialStringQuery,
false, /* includeDeleted */ DeletedItemHandling.EXCLUDE,
MAX_NAMESERVERS_IN_FIRST_STAGE); MAX_NAMESERVERS_IN_FIRST_STAGE);
Optional<String> desiredRegistrar = getDesiredRegistrar(); Optional<String> desiredRegistrar = getDesiredRegistrar();
if (desiredRegistrar.isPresent()) { if (desiredRegistrar.isPresent()) {
@ -423,7 +423,9 @@ public class RdapDomainSearchAction extends RdapActionBase {
HostResource.class, HostResource.class,
"inetAddresses", "inetAddresses",
inetAddress.getHostAddress(), inetAddress.getHostAddress(),
false, Optional.empty(),
Optional.empty(),
DeletedItemHandling.EXCLUDE,
MAX_NAMESERVERS_IN_FIRST_STAGE); MAX_NAMESERVERS_IN_FIRST_STAGE);
Optional<String> desiredRegistrar = getDesiredRegistrar(); Optional<String> desiredRegistrar = getDesiredRegistrar();
if (desiredRegistrar.isPresent()) { if (desiredRegistrar.isPresent()) {
@ -530,6 +532,7 @@ public class RdapDomainSearchAction extends RdapActionBase {
? IncompletenessWarningType.TRUNCATED ? IncompletenessWarningType.TRUNCATED
: incompletenessWarningType; : incompletenessWarningType;
metricInformationBuilder.setIncompletenessWarningType(finalIncompletenessWarningType); metricInformationBuilder.setIncompletenessWarningType(finalIncompletenessWarningType);
return RdapSearchResults.create(ImmutableList.copyOf(jsonList), finalIncompletenessWarningType); return RdapSearchResults.create(
ImmutableList.copyOf(jsonList), finalIncompletenessWarningType, Optional.empty());
} }
} }

View file

@ -61,7 +61,7 @@ import org.joda.time.DateTime;
method = {GET, HEAD}, method = {GET, HEAD},
auth = Auth.AUTH_PUBLIC auth = Auth.AUTH_PUBLIC
) )
public class RdapEntitySearchAction extends RdapActionBase { public class RdapEntitySearchAction extends RdapSearchActionBase {
public static final String PATH = "/rdap/entities"; public static final String PATH = "/rdap/entities";
@ -188,7 +188,7 @@ public class RdapEntitySearchAction extends RdapActionBase {
ContactResource.class, ContactResource.class,
"searchName", "searchName",
partialStringQuery, partialStringQuery,
false, DeletedItemHandling.EXCLUDE,
rdapResultSetMaxSize + 1); rdapResultSetMaxSize + 1);
if (authorization.role() != RdapAuthorization.Role.ADMINISTRATOR) { if (authorization.role() != RdapAuthorization.Role.ADMINISTRATOR) {
query = query.filter("currentSponsorClientId in", authorization.clientIds()); query = query.filter("currentSponsorClientId in", authorization.clientIds());
@ -244,7 +244,7 @@ public class RdapEntitySearchAction extends RdapActionBase {
int querySizeLimit = getStandardQuerySizeLimit(); int querySizeLimit = getStandardQuerySizeLimit();
Query<ContactResource> query = Query<ContactResource> query =
queryItemsByKey( queryItemsByKey(
ContactResource.class, partialStringQuery, shouldIncludeDeleted(), querySizeLimit); ContactResource.class, partialStringQuery, getDeletedItemHandling(), querySizeLimit);
return makeSearchResults( return makeSearchResults(
getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit), getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit),
registrars, registrars,
@ -317,7 +317,9 @@ public class RdapEntitySearchAction extends RdapActionBase {
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), IncompletenessWarningType.TRUNCATED); ImmutableList.copyOf(jsonOutputList),
IncompletenessWarningType.TRUNCATED,
Optional.empty());
} }
// 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.
@ -334,7 +336,9 @@ public class RdapEntitySearchAction extends RdapActionBase {
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), IncompletenessWarningType.TRUNCATED); ImmutableList.copyOf(jsonOutputList),
IncompletenessWarningType.TRUNCATED,
Optional.empty());
} }
jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForRegistrar( jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForRegistrar(
registrar, false, fullServletPath, rdapWhoisServer, now, outputDataType)); registrar, false, fullServletPath, rdapWhoisServer, now, outputDataType));
@ -343,6 +347,7 @@ public class RdapEntitySearchAction extends RdapActionBase {
ImmutableList.copyOf(jsonOutputList), ImmutableList.copyOf(jsonOutputList),
(jsonOutputList.size() < rdapResultSetMaxSize) (jsonOutputList.size() < rdapResultSetMaxSize)
? incompletenessWarningType ? incompletenessWarningType
: IncompletenessWarningType.COMPLETE); : IncompletenessWarningType.COMPLETE,
Optional.empty());
} }
} }

View file

@ -436,6 +436,33 @@ public class RdapJsonFormatter {
return jsonBuilder.build(); return jsonBuilder.build();
} }
/**
* Creates a JSON object containing a notice with a next page navigation link, which can then be
* inserted into a notices array.
*
* <p>At the moment, only a next page link is supported. Other types of links (e.g. previous page)
* could be added in the future, but it's not clear how to generate such links, given the way we
* are querying the database.
*
* @param nextPageUrl URL string used to navigate to next page, or empty if there is no next
*/
static ImmutableMap<String, Object> makeRdapJsonNavigationLinkNotice(
Optional<String> nextPageUrl) {
ImmutableMap.Builder<String, Object> jsonBuilder = new ImmutableMap.Builder<>();
jsonBuilder.put("title", "Navigation Links");
jsonBuilder.put("description", ImmutableList.of("Links to related pages."));
if (nextPageUrl.isPresent()) {
jsonBuilder.put(
"links",
ImmutableList.of(
ImmutableMap.of(
"rel", "next",
"href", nextPageUrl.get(),
"type", "application/rdap+json")));
}
return jsonBuilder.build();
}
/** /**
* Creates a JSON object for a {@link DomainResource}. * Creates a JSON object for a {@link DomainResource}.
* *

View file

@ -78,4 +78,10 @@ public final class RdapModule {
static Optional<Boolean> provideFormatOutput(HttpServletRequest req) { static Optional<Boolean> provideFormatOutput(HttpServletRequest req) {
return RequestParameters.extractOptionalBooleanParameter(req, "formatOutput"); return RequestParameters.extractOptionalBooleanParameter(req, "formatOutput");
} }
@Provides
@Parameter("cursor")
static Optional<String> provideCursor(HttpServletRequest req) {
return RequestParameters.extractOptionalParameter(req, "cursor");
}
} }

View file

@ -62,7 +62,7 @@ import org.joda.time.DateTime;
method = {GET, HEAD}, method = {GET, HEAD},
auth = Auth.AUTH_PUBLIC_ANONYMOUS auth = Auth.AUTH_PUBLIC_ANONYMOUS
) )
public class RdapNameserverSearchAction extends RdapActionBase { public class RdapNameserverSearchAction extends RdapSearchActionBase {
public static final String PATH = "/rdap/nameservers"; public static final String PATH = "/rdap/nameservers";
@ -86,6 +86,11 @@ public class RdapNameserverSearchAction extends RdapActionBase {
return PATH; return PATH;
} }
private enum CursorType {
NAME,
ADDRESS
}
/** /**
* Parses the parameters and calls the appropriate search function. * Parses the parameters and calls the appropriate search function.
* *
@ -103,6 +108,7 @@ public class RdapNameserverSearchAction extends RdapActionBase {
if (Booleans.countTrue(nameParam.isPresent(), ipParam.isPresent()) != 1) { if (Booleans.countTrue(nameParam.isPresent(), ipParam.isPresent()) != 1) {
throw new BadRequestException("You must specify either name=XXXX or ip=YYYY"); throw new BadRequestException("You must specify either name=XXXX or ip=YYYY");
} }
decodeCursorToken();
RdapSearchResults results; RdapSearchResults results;
if (nameParam.isPresent()) { if (nameParam.isPresent()) {
// syntax: /rdap/nameservers?name=exam*.com // syntax: /rdap/nameservers?name=exam*.com
@ -132,12 +138,22 @@ public class RdapNameserverSearchAction extends RdapActionBase {
} }
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, jsonBuilder, BoilerplateType.NAMESERVER, notices, ImmutableList.of(), fullServletPath);
BoilerplateType.NAMESERVER,
results.getIncompletenessWarnings(),
ImmutableList.of(),
fullServletPath);
return jsonBuilder.build(); return jsonBuilder.build();
} }
@ -207,6 +223,9 @@ public class RdapNameserverSearchAction extends RdapActionBase {
} }
List<HostResource> hostList = new ArrayList<>(); List<HostResource> hostList = new ArrayList<>();
for (String fqhn : ImmutableSortedSet.copyOf(domainResource.getSubordinateHosts())) { for (String fqhn : ImmutableSortedSet.copyOf(domainResource.getSubordinateHosts())) {
if (cursorString.isPresent() && (fqhn.compareTo(cursorString.get()) <= 0)) {
continue;
}
// We can't just check that the host name starts with the initial query string, because // We can't just check that the host name starts with the initial query string, because
// then the query ns.exam*.example.com would match against nameserver ns.example.com. // then the query ns.exam*.example.com would match against nameserver ns.example.com.
if (partialStringQuery.matches(fqhn)) { if (partialStringQuery.matches(fqhn)) {
@ -223,6 +242,7 @@ public class RdapNameserverSearchAction extends RdapActionBase {
hostList, hostList,
IncompletenessWarningType.COMPLETE, IncompletenessWarningType.COMPLETE,
domainResource.getSubordinateHosts().size(), domainResource.getSubordinateHosts().size(),
CursorType.NAME,
now); now);
} }
@ -240,10 +260,13 @@ public class RdapNameserverSearchAction extends RdapActionBase {
HostResource.class, HostResource.class,
"fullyQualifiedHostName", "fullyQualifiedHostName",
partialStringQuery, partialStringQuery,
shouldIncludeDeleted(), cursorString,
getDeletedItemHandling(),
querySizeLimit); querySizeLimit);
return makeSearchResults( return makeSearchResults(
getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit), now); getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit),
CursorType.NAME,
now);
} }
/** Searches for nameservers by IP address, returning a JSON array of nameserver info maps. */ /** Searches for nameservers by IP address, returning a JSON array of nameserver info maps. */
@ -252,21 +275,27 @@ public class RdapNameserverSearchAction extends RdapActionBase {
int querySizeLimit = getStandardQuerySizeLimit(); int querySizeLimit = getStandardQuerySizeLimit();
Query<HostResource> query = Query<HostResource> query =
queryItems( queryItems(
HostResource.class, HostResource.class,
"inetAddresses", "inetAddresses",
inetAddress.getHostAddress(), inetAddress.getHostAddress(),
shouldIncludeDeleted(), Optional.empty(),
querySizeLimit); cursorString,
getDeletedItemHandling(),
querySizeLimit);
return makeSearchResults( return makeSearchResults(
getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit), now); getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit),
CursorType.ADDRESS,
now);
} }
/** Output JSON for a lists of hosts contained in an {@link RdapResultSet}. */ /** Output JSON for a lists of hosts contained in an {@link RdapResultSet}. */
private RdapSearchResults makeSearchResults(RdapResultSet<HostResource> resultSet, DateTime now) { private RdapSearchResults makeSearchResults(
RdapResultSet<HostResource> resultSet, CursorType cursorType, DateTime now) {
return makeSearchResults( return makeSearchResults(
resultSet.resources(), resultSet.resources(),
resultSet.incompletenessWarningType(), resultSet.incompletenessWarningType(),
resultSet.numResourcesRetrieved(), resultSet.numResourcesRetrieved(),
cursorType,
now); now);
} }
@ -275,22 +304,29 @@ public class RdapNameserverSearchAction extends RdapActionBase {
List<HostResource> hosts, List<HostResource> hosts,
IncompletenessWarningType incompletenessWarningType, IncompletenessWarningType incompletenessWarningType,
int numHostsRetrieved, int numHostsRetrieved,
CursorType cursorType,
DateTime now) { DateTime now) {
metricInformationBuilder.setNumHostsRetrieved(numHostsRetrieved); metricInformationBuilder.setNumHostsRetrieved(numHostsRetrieved);
OutputDataType outputDataType = OutputDataType outputDataType =
(hosts.size() > 1) ? OutputDataType.SUMMARY : OutputDataType.FULL; (hosts.size() > 1) ? OutputDataType.SUMMARY : OutputDataType.FULL;
ImmutableList.Builder<ImmutableMap<String, Object>> jsonListBuilder = ImmutableList.Builder<ImmutableMap<String, Object>> jsonListBuilder =
new ImmutableList.Builder<>(); new ImmutableList.Builder<>();
Optional<String> newCursor = Optional.empty();
for (HostResource host : Iterables.limit(hosts, rdapResultSetMaxSize)) { for (HostResource host : Iterables.limit(hosts, rdapResultSetMaxSize)) {
newCursor =
Optional.of(
(cursorType == CursorType.NAME)
? host.getFullyQualifiedHostName()
: host.getRepoId());
jsonListBuilder.add( jsonListBuilder.add(
rdapJsonFormatter.makeRdapJsonForHost( rdapJsonFormatter.makeRdapJsonForHost(
host, false, fullServletPath, rdapWhoisServer, now, outputDataType)); host, false, fullServletPath, rdapWhoisServer, now, outputDataType));
} }
ImmutableList<ImmutableMap<String, Object>> jsonList = jsonListBuilder.build(); ImmutableList<ImmutableMap<String, Object>> jsonList = jsonListBuilder.build();
return RdapSearchResults.create( if (jsonList.size() < hosts.size()) {
jsonList, return RdapSearchResults.create(jsonList, IncompletenessWarningType.TRUNCATED, newCursor);
(jsonList.size() < hosts.size()) } else {
? IncompletenessWarningType.TRUNCATED return RdapSearchResults.create(jsonList, incompletenessWarningType, Optional.empty());
: incompletenessWarningType); }
} }
} }

View file

@ -0,0 +1,115 @@
// Copyright 2017 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.rdap;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import google.registry.request.Parameter;
import google.registry.request.ParameterMap;
import google.registry.request.RequestUrl;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.inject.Inject;
/**
* Base RDAP (new WHOIS) action for domain, nameserver and entity search requests.
*
* @see <a href="https://tools.ietf.org/html/rfc7482">
* RFC 7482: Registration Data Access Protocol (RDAP) Query Format</a>
*/
public abstract class RdapSearchActionBase extends RdapActionBase {
@Inject @RequestUrl String requestUrl;
@Inject @ParameterMap ImmutableListMultimap<String, String> parameterMap;
@Inject @Parameter("cursor") Optional<String> cursorTokenParam;
protected Optional<String> cursorString;
/**
* Decodes the cursor token passed in the HTTP request.
*
* <p>The cursor token is just the Base 64 encoded value of the last data item returned. To fetch
* the next page, the code can just decode the cursor, and return only data whose value is greater
* than the cursor value.
*/
protected void decodeCursorToken() {
if (!cursorTokenParam.isPresent()) {
cursorString = Optional.empty();
} else {
cursorString =
Optional.of(
new String(
Base64.getDecoder().decode(cursorTokenParam.get().getBytes(UTF_8)), UTF_8));
}
}
/** Returns an encoded cursor token to pass back in the RDAP JSON link strings. */
protected String encodeCursorToken(String nextCursorString) {
return new String(Base64.getEncoder().encode(nextCursorString.getBytes(UTF_8)), UTF_8);
}
/** Returns the original request URL, but with the specified parameter added or overridden. */
protected String getRequestUrlWithExtraParameter(String parameterName, String parameterValue) {
return getRequestUrlWithExtraParameter(parameterName, ImmutableList.of(parameterValue));
}
/**
* Returns the original request URL, but with the specified parameter added or overridden.
*
* <p>This version handles a list of parameter values, all associated with the same name.
*
* <p>Example: If the original parameters were "a=w&a=x&b=y&c=z", and this method is called with
* parameterName = "b" and parameterValues of "p" and "q", the result will be
* "a=w&a=x&c=z&b=p&b=q". The new values of parameter "b" replace the old ones.
*
*/
protected String getRequestUrlWithExtraParameter(
String parameterName, List<String> parameterValues) {
StringBuilder stringBuilder = new StringBuilder(requestUrl);
boolean first = true;
// Step one: loop through the existing parameters, copying all of them except for the parameter
// we want to explicitly set.
for (Map.Entry<String, String> entry : parameterMap.entries()) {
if (!entry.getKey().equals(parameterName)) {
appendParameter(stringBuilder, entry.getKey(), entry.getValue(), first);
first = false;
}
}
// Step two: tack on all values of the explicit parameter.
for (String parameterValue : parameterValues) {
appendParameter(stringBuilder, parameterName, parameterValue, first);
first = false;
}
return stringBuilder.toString();
}
private void appendParameter(
StringBuilder stringBuilder, String name, String value, boolean first) {
try {
stringBuilder.append(first ? '?' : '&');
stringBuilder.append(URLEncoder.encode(name, "UTF-8"));
stringBuilder.append('=');
stringBuilder.append(URLEncoder.encode(value, "UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -20,6 +20,7 @@ import static google.registry.rdap.RdapIcannStandardInformation.TRUNCATION_NOTIC
import com.google.auto.value.AutoValue; import com.google.auto.value.AutoValue;
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 java.util.Optional;
/** /**
* Holds domain, nameserver and entity search results. * Holds domain, nameserver and entity search results.
@ -46,13 +47,14 @@ abstract class RdapSearchResults {
} }
static RdapSearchResults create(ImmutableList<ImmutableMap<String, Object>> jsonList) { static RdapSearchResults create(ImmutableList<ImmutableMap<String, Object>> jsonList) {
return create(jsonList, IncompletenessWarningType.COMPLETE); return create(jsonList, IncompletenessWarningType.COMPLETE, Optional.empty());
} }
static RdapSearchResults create( static RdapSearchResults create(
ImmutableList<ImmutableMap<String, Object>> jsonList, ImmutableList<ImmutableMap<String, Object>> jsonList,
IncompletenessWarningType incompletenessWarningType) { IncompletenessWarningType incompletenessWarningType,
return new AutoValue_RdapSearchResults(jsonList, incompletenessWarningType); Optional<String> nextCursor) {
return new AutoValue_RdapSearchResults(jsonList, incompletenessWarningType, nextCursor);
} }
/** List of JSON result object representations. */ /** List of JSON result object representations. */
@ -61,6 +63,9 @@ abstract class RdapSearchResults {
/** Type of warning to display regarding possible incomplete data. */ /** Type of warning to display regarding possible incomplete data. */
abstract IncompletenessWarningType incompletenessWarningType(); abstract IncompletenessWarningType incompletenessWarningType();
/** Cursor for fetching the next page of results, or empty() if there are no more. */
abstract Optional<String> nextCursor();
/** Convenience method to get the appropriate warnings for the incompleteness warning type. */ /** Convenience method to get the appropriate warnings for the incompleteness warning type. */
ImmutableList<ImmutableMap<String, Object>> getIncompletenessWarnings() { ImmutableList<ImmutableMap<String, Object>> getIncompletenessWarnings() {
if (incompletenessWarningType() == IncompletenessWarningType.TRUNCATED) { if (incompletenessWarningType() == IncompletenessWarningType.TRUNCATED) {

View file

@ -84,6 +84,12 @@ public final class RequestModule {
return authResult; return authResult;
} }
@Provides
@RequestUrl
static String provideRequestUrl(HttpServletRequest req) {
return req.getRequestURL().toString();
}
@Provides @Provides
@RequestPath @RequestPath
static String provideRequestPath(HttpServletRequest req) { static String provideRequestPath(HttpServletRequest req) {

View file

@ -0,0 +1,31 @@
// Copyright 2017 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.request;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import javax.inject.Qualifier;
/**
* Dagger qualifier for the HTTP request URL.
*
* @see javax.servlet.http.HttpServletRequest#getRequestURL()
*/
@Retention(RUNTIME)
@Qualifier
@Documented
public @interface RequestUrl {}

View file

@ -34,6 +34,7 @@ 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 com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import com.googlecode.objectify.Key; import com.googlecode.objectify.Key;
@ -54,10 +55,13 @@ 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.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.JSONValue; import org.json.simple.JSONValue;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
@ -74,7 +78,7 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
@Rule public final InjectRule inject = new InjectRule(); @Rule public final InjectRule inject = new InjectRule();
private final HttpServletRequest request = mock(HttpServletRequest.class); private final HttpServletRequest request = mock(HttpServletRequest.class);
private final FakeResponse response = new FakeResponse(); private 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");
@ -87,16 +91,39 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
private HostResource hostNs2CatLol; private HostResource hostNs2CatLol;
private Object generateActualJsonWithName(String name) { private Object generateActualJsonWithName(String name) {
return generateActualJsonWithName(name, null);
}
private Object generateActualJsonWithName(String name, String cursor) {
metricSearchType = SearchType.BY_NAMESERVER_NAME; metricSearchType = SearchType.BY_NAMESERVER_NAME;
rememberWildcardType(name); rememberWildcardType(name);
action.nameParam = Optional.of(name); action.nameParam = Optional.of(name);
if (cursor == null) {
action.parameterMap = ImmutableListMultimap.of("name", name);
action.cursorTokenParam = Optional.empty();
} else {
action.parameterMap = ImmutableListMultimap.of("name", name, "cursor", cursor);
action.cursorTokenParam = Optional.of(cursor);
}
action.run(); action.run();
return JSONValue.parse(response.getPayload()); return JSONValue.parse(response.getPayload());
} }
private Object generateActualJsonWithIp(String ipString) { private Object generateActualJsonWithIp(String ipString) {
return generateActualJsonWithIp(ipString, null);
}
private Object generateActualJsonWithIp(String ipString, String cursor) {
metricSearchType = SearchType.BY_NAMESERVER_ADDRESS; metricSearchType = SearchType.BY_NAMESERVER_ADDRESS;
action.parameterMap = ImmutableListMultimap.of("ip", ipString);
action.ipParam = Optional.of(ipString); action.ipParam = Optional.of(ipString);
if (cursor == null) {
action.parameterMap = ImmutableListMultimap.of("ip", ipString);
action.cursorTokenParam = Optional.empty();
} else {
action.parameterMap = ImmutableListMultimap.of("ip", ipString, "cursor", cursor);
action.cursorTokenParam = Optional.of(cursor);
}
action.run(); action.run();
return JSONValue.parse(response.getPayload()); return JSONValue.parse(response.getPayload());
} }
@ -153,7 +180,9 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
inject.setStaticField(Ofy.class, "clock", clock); inject.setStaticField(Ofy.class, "clock", clock);
action.clock = clock; action.clock = clock;
action.fullServletPath = "https://example.tld/rdap"; action.fullServletPath = "https://example.tld/rdap";
action.requestUrl = "https://example.tld/rdap/nameservers";
action.requestPath = RdapNameserverSearchAction.PATH; action.requestPath = RdapNameserverSearchAction.PATH;
action.parameterMap = ImmutableListMultimap.of();
action.request = request; action.request = request;
action.requestMethod = Action.Method.GET; action.requestMethod = Action.Method.GET;
action.response = response; action.response = response;
@ -168,6 +197,7 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
action.authResult = AuthResult.create(AuthLevel.USER, userAuthInfo); action.authResult = AuthResult.create(AuthLevel.USER, userAuthInfo);
action.sessionUtils = sessionUtils; action.sessionUtils = sessionUtils;
action.rdapMetrics = rdapMetrics; action.rdapMetrics = rdapMetrics;
action.cursorTokenParam = Optional.empty();
} }
private void login(String clientId) { private void login(String clientId) {
@ -555,7 +585,9 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
public void testNameMatch_truncatedResultSet() throws Exception { public void testNameMatch_truncatedResultSet() throws Exception {
createManyHosts(5); createManyHosts(5);
assertThat(generateActualJsonWithName("ns*.cat.lol")) assertThat(generateActualJsonWithName("ns*.cat.lol"))
.isEqualTo(generateExpectedJson("rdap_truncated_hosts.json")); .isEqualTo(
generateExpectedJson(
"name=ns*.cat.lol&cursor=bnM0LmNhdC5sb2w%3D", "rdap_truncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(5); verifyMetrics(5);
} }
@ -564,7 +596,9 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
public void testNameMatch_reallyTruncatedResultSet() throws Exception { public void testNameMatch_reallyTruncatedResultSet() throws Exception {
createManyHosts(9); createManyHosts(9);
assertThat(generateActualJsonWithName("ns*.cat.lol")) assertThat(generateActualJsonWithName("ns*.cat.lol"))
.isEqualTo(generateExpectedJson("rdap_truncated_hosts.json")); .isEqualTo(
generateExpectedJson(
"name=ns*.cat.lol&cursor=bnM0LmNhdC5sb2w%3D", "rdap_truncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
// When searching names, we look for additional matches, in case some are not visible. // When searching names, we look for additional matches, in case some are not visible.
verifyMetrics(9); verifyMetrics(9);
@ -675,6 +709,83 @@ 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.
*
* <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.
*
* @param byName true if we are searching by name; false if we are searching by address
* @param queryString the name or address query string
* @param expectedPageCount how many pages we expect to retrieve; all but the last will have a
* cursor
*/
private void checkCursorNavigation(boolean byName, String queryString, int expectedPageCount)
throws Exception {
String cursor = null;
for (int i = 0; i < expectedPageCount; i++) {
Object results =
byName
? generateActualJsonWithName(queryString, cursor)
: generateActualJsonWithIp(queryString, cursor);
assertThat(response.getStatus()).isEqualTo(200);
String linkToNext = 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("nameserverSearchResults");
assertThat(nameserverSearchResults).isInstanceOf(JSONArray.class);
assertThat(((JSONArray) nameserverSearchResults)).hasSize(action.rdapResultSetMaxSize);
response = new FakeResponse();
action.response = response;
}
}
}
@Test
public void testNameMatch_cursorNavigationWithSuperordinateDomain() throws Exception {
createManyHosts(9);
checkCursorNavigation(true, "ns*.cat.lol", 3);
}
@Test
public void testNameMatch_cursorNavigationWithPrefix() throws Exception {
createManyHosts(9);
checkCursorNavigation(true, "ns*", 4);
}
@Test @Test
public void testAddressMatch_invalidAddress() throws Exception { public void testAddressMatch_invalidAddress() throws Exception {
generateActualJsonWithIp("It is to laugh"); generateActualJsonWithIp("It is to laugh");
@ -736,7 +847,7 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
public void testAddressMatch_nontruncatedResultSet() throws Exception { public void testAddressMatch_nontruncatedResultSet() throws Exception {
createManyHosts(4); createManyHosts(4);
assertThat(generateActualJsonWithIp("5.5.5.1")) assertThat(generateActualJsonWithIp("5.5.5.1"))
.isEqualTo(generateExpectedJson("rdap_nontruncated_hosts.json")); .isEqualTo(generateExpectedJson("rdap_nontruncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(4); verifyMetrics(4);
} }
@ -745,7 +856,9 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
public void testAddressMatch_truncatedResultSet() throws Exception { public void testAddressMatch_truncatedResultSet() throws Exception {
createManyHosts(5); createManyHosts(5);
assertThat(generateActualJsonWithIp("5.5.5.1")) assertThat(generateActualJsonWithIp("5.5.5.1"))
.isEqualTo(generateExpectedJson("rdap_truncated_hosts.json")); .isEqualTo(
generateExpectedJson(
"ip=5.5.5.1&cursor=MTctUk9JRA%3D%3D", "rdap_truncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(5); verifyMetrics(5);
} }
@ -754,7 +867,9 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
public void testAddressMatch_reallyTruncatedResultSet() throws Exception { public void testAddressMatch_reallyTruncatedResultSet() throws Exception {
createManyHosts(9); createManyHosts(9);
assertThat(generateActualJsonWithIp("5.5.5.1")) assertThat(generateActualJsonWithIp("5.5.5.1"))
.isEqualTo(generateExpectedJson("rdap_truncated_hosts.json")); .isEqualTo(
generateExpectedJson(
"ip=5.5.5.1&cursor=MTctUk9JRA%3D%3D", "rdap_truncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
// When searching by address and not including deleted, we don't need to search for extra // When searching by address and not including deleted, we don't need to search for extra
// matches. // matches.
@ -830,4 +945,10 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase {
assertThat(response.getStatus()).isEqualTo(404); assertThat(response.getStatus()).isEqualTo(404);
verifyErrorMetrics(); verifyErrorMetrics();
} }
@Test
public void testAddressMatch_cursorNavigation() throws Exception {
createManyHosts(9);
checkCursorNavigation(false, "5.5.5.1", 3);
}
} }

View file

@ -125,6 +125,18 @@
"Search results per query are limited." "Search results per query are limited."
] ]
}, },
{
"title" : "Navigation Links",
"description" : [ "Links to related pages." ],
"links" :
[
{
"type" : "application/rdap+json",
"rel" : "next",
"href" : "https://example.tld/rdap/nameservers?%NAME%"
}
]
},
{ {
"title" : "RDAP Terms of Service", "title" : "RDAP Terms of Service",
"description" : "description" :
@ -149,7 +161,7 @@
"type" : "text/html" "type" : "text/html"
} }
] ]
} },
], ],
"remarks" : "remarks" :
[ [