// 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 google.registry.model.ofy.ObjectifyService.ofy;
import static google.registry.rdap.RdapUtils.getRegistrarByIanaIdentifier;
import static google.registry.request.Action.Method.GET;
import static google.registry.request.Action.Method.HEAD;
import static google.registry.util.DateTimeUtils.END_OF_TIME;
import com.google.common.base.Optional;
import com.google.common.base.Predicate;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.primitives.Booleans;
import com.google.common.primitives.Longs;
import com.googlecode.objectify.Key;
import google.registry.model.contact.ContactResource;
import google.registry.model.domain.DesignatedContact;
import google.registry.model.registrar.Registrar;
import google.registry.rdap.RdapJsonFormatter.BoilerplateType;
import google.registry.rdap.RdapJsonFormatter.OutputDataType;
import google.registry.rdap.RdapSearchResults.IncompletenessWarningType;
import google.registry.request.Action;
import google.registry.request.HttpException.BadRequestException;
import google.registry.request.HttpException.NotFoundException;
import google.registry.request.HttpException.UnprocessableEntityException;
import google.registry.request.Parameter;
import google.registry.request.auth.Auth;
import google.registry.util.Clock;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import org.joda.time.DateTime;
/**
* RDAP (new WHOIS) action for entity (contact and registrar) search requests.
*
*
All commands and responses conform to the RDAP spec as defined in RFCs 7480 through 7485.
*
* @see RFC 7482: Registration Data Access Protocol
* (RDAP) Query Format
* @see RFC 7483: JSON Responses for the Registration
* Data Access Protocol (RDAP)
*/
@Action(
path = RdapEntitySearchAction.PATH,
method = {GET, HEAD},
auth = Auth.AUTH_PUBLIC
)
public class RdapEntitySearchAction extends RdapActionBase {
public static final String PATH = "/rdap/entities";
@Inject Clock clock;
@Inject @Parameter("fn") Optional fnParam;
@Inject @Parameter("handle") Optional handleParam;
@Inject RdapEntitySearchAction() {}
@Override
public String getHumanReadableObjectTypeName() {
return "entity search";
}
@Override
public String getActionPath() {
return PATH;
}
/** Parses the parameters and calls the appropriate search function. */
@Override
public ImmutableMap getJsonObjectForResource(
String pathSearchString, boolean isHeadRequest, String linkBase) {
DateTime now = clock.nowUtc();
// RDAP syntax example: /rdap/entities?fn=Bobby%20Joe*.
// The pathSearchString is not used by search commands.
if (pathSearchString.length() > 0) {
throw new BadRequestException("Unexpected path");
}
if (Booleans.countTrue(fnParam.isPresent(), handleParam.isPresent()) != 1) {
throw new BadRequestException("You must specify either fn=XXXX or handle=YYYY");
}
RdapSearchResults results;
if (fnParam.isPresent()) {
// syntax: /rdap/entities?fn=Bobby%20Joe*
// The name is the contact name or registrar name (not registrar contact name).
results = searchByName(RdapSearchPattern.create(fnParam.get(), false), now);
} else {
// syntax: /rdap/entities?handle=12345-*
// The handle is either the contact roid or the registrar clientId.
results = searchByHandle(RdapSearchPattern.create(handleParam.get(), false), now);
}
if (results.jsonList().isEmpty()) {
throw new NotFoundException("No entities found");
}
ImmutableMap.Builder jsonBuilder = new ImmutableMap.Builder<>();
jsonBuilder.put("entitySearchResults", results.jsonList());
rdapJsonFormatter.addTopLevelEntries(
jsonBuilder,
BoilerplateType.ENTITY,
results.getIncompletenessWarnings(),
ImmutableList.>of(),
rdapLinkBase);
return jsonBuilder.build();
}
/**
* Searches for entities by name, returning a JSON array of entity info maps.
*
* As per Gustavo Lozano of ICANN, registrar name search should be by registrar name only, not
* by registrar contact name:
*
*
The search is by registrar name only. The profile is supporting the functionality defined
* in the Base Registry Agreement (see 1.6 of Section 4 of the Base Registry Agreement,
* https://newgtlds.icann.org/sites/default/files/agreements/
* agreement-approved-09jan14-en.htm).
*
*
According to RFC 7482 section 6.1, punycode is only used for domain name labels, so we can
* assume that entity names are regular unicode.
*/
private RdapSearchResults searchByName(final RdapSearchPattern partialStringQuery, DateTime now) {
// For wildcard searches, make sure the initial string is long enough, and don't allow suffixes.
if (partialStringQuery.getHasWildcard()) {
if (partialStringQuery.getSuffix() != null) {
throw new UnprocessableEntityException(
"Suffixes not allowed in wildcard entity name searches");
}
if (partialStringQuery.getInitialString().length()
< RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) {
throw new UnprocessableEntityException(
"Initial search string required in wildcard entity name searches");
}
}
// Get the registrar matches, depending on whether there's a wildcard.
ImmutableList registrarMatches =
FluentIterable.from(Registrar.loadAllCached())
.filter(
new Predicate() {
@Override
public boolean apply(Registrar registrar) {
return partialStringQuery.matches(registrar.getRegistrarName());
}})
.limit(rdapResultSetMaxSize + 1)
.toList();
// Get the contact matches and return the results, fetching an additional contact to detect
// truncation.
return makeSearchResults(
queryItems(
ContactResource.class,
"searchName",
partialStringQuery,
false /* includeDeleted */,
rdapResultSetMaxSize + 1)
.list(),
registrarMatches,
now);
}
/** Searches for entities by handle, returning a JSON array of entity info maps. */
private RdapSearchResults searchByHandle(
final RdapSearchPattern partialStringQuery, DateTime now) {
// Handle queries without a wildcard -- load by ID.
if (!partialStringQuery.getHasWildcard()) {
ContactResource contactResource = ofy().load()
.type(ContactResource.class)
.id(partialStringQuery.getInitialString())
.now();
ImmutableList registrars =
getMatchingRegistrars(partialStringQuery.getInitialString());
return makeSearchResults(
((contactResource == null) || !contactResource.getDeletionTime().isEqual(END_OF_TIME))
? ImmutableList.of() : ImmutableList.of(contactResource),
registrars,
now);
// Handle queries with a wildcard, but no suffix. For contact resources, the deletion time will
// always be END_OF_TIME for non-deleted records; unlike domain resources, we don't need to
// worry about deletion times in the future. That allows us to use an equality query for the
// deletion time. Because the handle for registrars is the IANA identifier number, don't allow
// wildcard searches for registrars, by simply not searching for registrars if a wildcard is
// present. Fetch an extra contact to detect result set truncation.
} else if (partialStringQuery.getSuffix() == null) {
if (partialStringQuery.getInitialString().length()
< RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) {
throw new UnprocessableEntityException(
"Initial search string required in wildcard entity handle searches");
}
return makeSearchResults(
ofy().load()
.type(ContactResource.class)
.filterKey(
">=", Key.create(ContactResource.class, partialStringQuery.getInitialString()))
.filterKey(
"<", Key.create(ContactResource.class, partialStringQuery.getNextInitialString()))
.filter("deletionTime", END_OF_TIME)
.limit(rdapResultSetMaxSize + 1)
.list(),
ImmutableList.of(),
now);
// Don't allow suffixes in entity handle search queries.
} else {
throw new UnprocessableEntityException("Suffixes not allowed in entity handle searches");
}
}
/** Looks up registrars by handle (i.e. IANA identifier). */
private ImmutableList getMatchingRegistrars(final String ianaIdentifierString) {
Long ianaIdentifier = Longs.tryParse(ianaIdentifierString);
if (ianaIdentifier != null) {
Optional registrar = getRegistrarByIanaIdentifier(ianaIdentifier);
if (registrar.isPresent()) {
return ImmutableList.of(registrar.get());
}
}
return ImmutableList.of();
}
/** Builds a JSON array of entity info maps based on the specified contacts and registrars. */
private RdapSearchResults makeSearchResults(
List contacts, List registrars, DateTime now) {
// Determine what output data type to use, depending on whether more than one entity will be
// returned.
int numEntities = contacts.size();
OutputDataType outputDataType;
// If there's more than one contact, then we know already we need SUMMARY mode.
if (numEntities > 1) {
outputDataType = OutputDataType.SUMMARY;
// If there are fewer than two contacts, loop through and compute the total number of contacts
// and registrars, stopping as soon as we find two.
} else {
outputDataType = OutputDataType.FULL;
for (Registrar registrar : registrars) {
if (registrar.isLiveAndPubliclyVisible()) {
numEntities++;
if (numEntities > 1) {
outputDataType = OutputDataType.SUMMARY;
break;
}
}
}
}
// There can be more results than our max size, partially because we have two pools to draw from
// (contacts and registrars), and partially because we try to fetch one more than the max size,
// so we can tell whether to display the truncation notification.
RdapAuthorization authorization = getAuthorization();
List> jsonOutputList = new ArrayList<>();
for (ContactResource contact : contacts) {
if (jsonOutputList.size() >= rdapResultSetMaxSize) {
return RdapSearchResults.create(
ImmutableList.copyOf(jsonOutputList), IncompletenessWarningType.TRUNCATED);
}
// 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.
jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForContact(
contact,
false,
Optional.absent(),
rdapLinkBase,
rdapWhoisServer,
now,
outputDataType,
authorization));
}
for (Registrar registrar : registrars) {
if (registrar.isLiveAndPubliclyVisible()) {
if (jsonOutputList.size() >= rdapResultSetMaxSize) {
return RdapSearchResults.create(
ImmutableList.copyOf(jsonOutputList), IncompletenessWarningType.TRUNCATED);
}
jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForRegistrar(
registrar, false, rdapLinkBase, rdapWhoisServer, now, outputDataType));
}
}
return RdapSearchResults.create(ImmutableList.copyOf(jsonOutputList));
}
}