mirror of
https://github.com/google/nomulus.git
synced 2025-05-13 16:07:15 +02:00
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:
parent
c8059d4d8a
commit
e619ea1bff
12 changed files with 398 additions and 111 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
@ -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" :
|
||||||
|
|
|
@ -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" :
|
||||||
|
|
|
@ -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" :
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue