RDAP: Display truncation notice for large nameserver result sets

The ICAAN Operational Profile dictates that a notice be added to the RDAP search results response when there are more objects than the server's chosen result set size. This CL handles the fixes for nameserver searches.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=135411617
This commit is contained in:
mountford 2016-10-06 15:17:25 -07:00 committed by Ben McIlwain
parent 43c67403fa
commit 5c5499d598
5 changed files with 456 additions and 57 deletions

View file

@ -573,11 +573,11 @@ public class RdapJsonFormatter {
new ImmutableMap.Builder<>();
ImmutableList<String> v4Addresses = v4AddressesBuilder.build();
if (!v4Addresses.isEmpty()) {
ipAddressesBuilder.put("v4", v4Addresses);
ipAddressesBuilder.put("v4", Ordering.natural().immutableSortedCopy(v4Addresses));
}
ImmutableList<String> v6Addresses = v6AddressesBuilder.build();
if (!v6Addresses.isEmpty()) {
ipAddressesBuilder.put("v6", v6Addresses);
ipAddressesBuilder.put("v6", Ordering.natural().immutableSortedCopy(v6Addresses));
}
ImmutableMap<String, ImmutableList<String>> ipAddresses = ipAddressesBuilder.build();
if (!ipAddresses.isEmpty()) {

View file

@ -16,6 +16,7 @@ package google.registry.rdap;
import static google.registry.model.EppResourceUtils.loadByForeignKey;
import static google.registry.model.ofy.ObjectifyService.ofy;
import static google.registry.rdap.RdapIcannStandardInformation.TRUNCATION_NOTICES;
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;
@ -24,6 +25,7 @@ import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Iterables;
import com.google.common.primitives.Booleans;
import google.registry.config.ConfigModule.Config;
import google.registry.model.domain.DomainResource;
@ -85,7 +87,7 @@ public class RdapNameserverSearchAction extends RdapActionBase {
if (Booleans.countTrue(nameParam.isPresent(), ipParam.isPresent()) != 1) {
throw new BadRequestException("You must specify either name=XXXX or ip=YYYY");
}
ImmutableList<ImmutableMap<String, Object>> results;
RdapSearchResults results;
if (nameParam.isPresent()) {
// syntax: /rdap/nameservers?name=exam*.com
if (!LDH_PATTERN.matcher(nameParam.get()).matches()) {
@ -98,23 +100,24 @@ public class RdapNameserverSearchAction extends RdapActionBase {
// syntax: /rdap/nameservers?ip=1.2.3.4
results = searchByIp(ipParam.get(), now);
}
if (results.isEmpty()) {
if (results.jsonList().isEmpty()) {
throw new NotFoundException("No nameservers found");
}
ImmutableMap.Builder<String, Object> jsonBuilder = new ImmutableMap.Builder<>();
jsonBuilder.put("nameserverSearchResults", results);
jsonBuilder.put("nameserverSearchResults", results.jsonList());
RdapJsonFormatter.addTopLevelEntries(
jsonBuilder,
BoilerplateType.NAMESERVER,
ImmutableList.<ImmutableMap<String, Object>>of(),
results.isTruncated()
? TRUNCATION_NOTICES : ImmutableList.<ImmutableMap<String, Object>>of(),
ImmutableList.<ImmutableMap<String, Object>>of(),
rdapLinkBase);
return jsonBuilder.build();
}
/** Searches for nameservers by name, returning a JSON array of nameserver info maps. */
private ImmutableList<ImmutableMap<String, Object>>
searchByName(final RdapSearchPattern partialStringQuery, final DateTime now) {
private RdapSearchResults searchByName(
final RdapSearchPattern partialStringQuery, final DateTime now) {
// Handle queries without a wildcard -- just load by foreign key.
if (!partialStringQuery.getHasWildcard()) {
HostResource hostResource =
@ -122,18 +125,21 @@ public class RdapNameserverSearchAction extends RdapActionBase {
if (hostResource == null) {
throw new NotFoundException("No nameservers found");
}
return ImmutableList.of(
RdapJsonFormatter.makeRdapJsonForHost(
hostResource, false, rdapLinkBase, rdapWhoisServer, now, OutputDataType.FULL));
return RdapSearchResults.create(
ImmutableList.of(
RdapJsonFormatter.makeRdapJsonForHost(
hostResource, false, rdapLinkBase, rdapWhoisServer, now, OutputDataType.FULL)),
false);
// Handle queries with a wildcard, but no suffix. There are no pending deletes for hosts, so we
// can call queryUndeleted.
} else if (partialStringQuery.getSuffix() == null) {
return makeSearchResults(
// Add 1 so we can detect truncation.
queryUndeleted(
HostResource.class,
"fullyQualifiedHostName",
partialStringQuery,
rdapResultSetMaxSize)
rdapResultSetMaxSize + 1)
.list(),
now);
// Handle queries with a wildcard and a suffix. In this case, it is more efficient to do things
@ -161,29 +167,30 @@ public class RdapNameserverSearchAction extends RdapActionBase {
}
/** Searches for nameservers by IP address, returning a JSON array of nameserver info maps. */
private ImmutableList<ImmutableMap<String, Object>>
searchByIp(final InetAddress inetAddress, DateTime now) {
private RdapSearchResults searchByIp(final InetAddress inetAddress, DateTime now) {
return makeSearchResults(
// Add 1 so we can detect truncation.
ofy().load()
.type(HostResource.class)
.filter("inetAddresses", inetAddress.getHostAddress())
.filter("deletionTime", END_OF_TIME)
.limit(rdapResultSetMaxSize)
.limit(rdapResultSetMaxSize + 1)
.list(),
now);
}
/** Output JSON for a list of hosts. */
private ImmutableList<ImmutableMap<String, Object>> makeSearchResults(
List<HostResource> hosts, DateTime now) {
private RdapSearchResults makeSearchResults(List<HostResource> hosts, DateTime now) {
OutputDataType outputDataType =
(hosts.size() > 1) ? OutputDataType.SUMMARY : OutputDataType.FULL;
ImmutableList.Builder<ImmutableMap<String, Object>> jsonBuilder = new ImmutableList.Builder<>();
for (HostResource host : hosts) {
jsonBuilder.add(
ImmutableList.Builder<ImmutableMap<String, Object>> jsonListBuilder =
new ImmutableList.Builder<>();
for (HostResource host : Iterables.limit(hosts, rdapResultSetMaxSize)) {
jsonListBuilder.add(
RdapJsonFormatter.makeRdapJsonForHost(
host, false, rdapLinkBase, rdapWhoisServer, now, outputDataType));
}
return jsonBuilder.build();
ImmutableList<ImmutableMap<String, Object>> jsonList = jsonListBuilder.build();
return RdapSearchResults.create(jsonList, jsonList.size() < hosts.size());
}
}

View file

@ -17,10 +17,12 @@ package google.registry.rdap;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.testing.DatastoreHelper.createTld;
import static google.registry.testing.DatastoreHelper.persistResource;
import static google.registry.testing.DatastoreHelper.persistResources;
import static google.registry.testing.DatastoreHelper.persistSimpleResources;
import static google.registry.testing.FullFieldsTestEntityHelper.makeAndPersistHostResource;
import static google.registry.testing.FullFieldsTestEntityHelper.makeContactResource;
import static google.registry.testing.FullFieldsTestEntityHelper.makeDomainResource;
import static google.registry.testing.FullFieldsTestEntityHelper.makeHostResource;
import static google.registry.testing.FullFieldsTestEntityHelper.makeRegistrar;
import static google.registry.testing.FullFieldsTestEntityHelper.makeRegistrarContacts;
import static google.registry.testing.TestDataHelper.loadFileWithSubstitutions;
@ -39,6 +41,7 @@ import google.registry.testing.AppEngineRule;
import google.registry.testing.FakeClock;
import google.registry.testing.FakeResponse;
import google.registry.testing.InjectRule;
import javax.annotation.Nullable;
import org.joda.time.DateTime;
import org.json.simple.JSONValue;
import org.junit.Before;
@ -60,6 +63,7 @@ public class RdapNameserverSearchActionTest {
private final RdapNameserverSearchAction action = new RdapNameserverSearchAction();
private DomainResource domainCatLol;
private HostResource hostNs1CatLol;
private HostResource hostNs2CatLol;
private HostResource hostNs1Cat2Lol;
@ -105,21 +109,20 @@ public class RdapNameserverSearchActionTest {
makeAndPersistHostResource("ns1.cat.1.test", "1.2.3.6", clock.nowUtc().minusYears(1));
// create a domain so that we can use it as a test nameserver search string suffix
DomainResource domainCatLol =
persistResource(
makeDomainResource(
"cat.lol",
persistResource(
makeContactResource("5372808-ERL", "Goblin Market", "lol@cat.lol")),
persistResource(
makeContactResource("5372808-IRL", "Santa Claus", "BOFH@cat.lol")),
persistResource(makeContactResource("5372808-TRL", "The Raven", "bog@cat.lol")),
hostNs1CatLol,
hostNs2CatLol,
registrar)
.asBuilder()
.setSubordinateHosts(ImmutableSet.of("ns1.cat.lol", "ns2.cat.lol"))
.build());
domainCatLol = persistResource(
makeDomainResource(
"cat.lol",
persistResource(
makeContactResource("5372808-ERL", "Goblin Market", "lol@cat.lol")),
persistResource(
makeContactResource("5372808-IRL", "Santa Claus", "BOFH@cat.lol")),
persistResource(makeContactResource("5372808-TRL", "The Raven", "bog@cat.lol")),
hostNs1CatLol,
hostNs2CatLol,
registrar)
.asBuilder()
.setSubordinateHosts(ImmutableSet.of("ns1.cat.lol", "ns2.cat.lol"))
.build());
persistResource(
hostNs1CatLol.asBuilder().setSuperordinateDomain(Key.create(domainCatLol)).build());
persistResource(
@ -129,27 +132,35 @@ public class RdapNameserverSearchActionTest {
action.clock = clock;
action.requestPath = RdapNameserverSearchAction.PATH;
action.response = response;
action.rdapResultSetMaxSize = 100;
action.rdapResultSetMaxSize = 4;
action.rdapLinkBase = "https://example.tld/rdap/";
action.rdapWhoisServer = null;
action.ipParam = Optional.absent();
action.nameParam = Optional.absent();
}
private Object generateExpectedJson(String expectedOutputFile) {
return generateExpectedJson(null, null, null, null, null, expectedOutputFile);
}
private Object generateExpectedJson(String name, String expectedOutputFile) {
return generateExpectedJson(name, null, null, null, null, expectedOutputFile);
}
private Object generateExpectedJson(
String name,
String punycodeName,
String handle,
String ipAddressType,
String ipAddress,
@Nullable String name,
@Nullable String punycodeName,
@Nullable String handle,
@Nullable String ipAddressType,
@Nullable String ipAddress,
String expectedOutputFile) {
ImmutableMap.Builder<String, String> builder = new ImmutableMap.Builder<>();
builder.put("NAME", name);
builder.put("PUNYCODENAME", (punycodeName == null) ? name : punycodeName);
if (name != null) {
builder.put("NAME", name);
}
if ((name != null) || (punycodeName != null)) {
builder.put("PUNYCODENAME", (punycodeName == null) ? name : punycodeName);
}
if (handle != null) {
builder.put("HANDLE", handle);
}
@ -182,6 +193,21 @@ public class RdapNameserverSearchActionTest {
return builder.build();
}
private void createManyHosts(int numHosts) {
ImmutableList.Builder<HostResource> hostsBuilder = new ImmutableList.Builder<>();
ImmutableSet.Builder<String> subordinateHostsBuilder = new ImmutableSet.Builder<>();
for (int i = 1; i <= numHosts; i++) {
String hostName = String.format("ns%d.cat.lol", i);
subordinateHostsBuilder.add(hostName);
hostsBuilder.add(makeHostResource(hostName, "5.5.5.1", "5.5.5.2"));
}
persistResources(hostsBuilder.build());
domainCatLol = persistResource(
domainCatLol.asBuilder()
.setSubordinateHosts(subordinateHostsBuilder.build())
.build());
}
@Test
public void testInvalidPath_rejected() throws Exception {
action.requestPath = RdapDomainSearchAction.PATH + "/path";
@ -328,6 +354,46 @@ public class RdapNameserverSearchActionTest {
generateActualJsonWithName("dog*");
assertThat(response.getStatus()).isEqualTo(404);
}
@Test
public void testNameMatch_nontruncatedResultSet() throws Exception {
createManyHosts(4);
assertThat(generateActualJsonWithName("ns*.cat.lol"))
.isEqualTo(generateExpectedJson("rdap_nontruncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
public void testNameMatch_truncatedResultSet() throws Exception {
createManyHosts(5);
assertThat(generateActualJsonWithName("ns*.cat.lol"))
.isEqualTo(generateExpectedJson("rdap_truncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
public void testNameMatch_reallyTruncatedResultSet() throws Exception {
createManyHosts(9);
assertThat(generateActualJsonWithName("ns*.cat.lol"))
.isEqualTo(generateExpectedJson("rdap_truncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
public void testNameMatchDeletedHost_foundTheOtherHost() throws Exception {
persistResource(
hostNs1Cat2Lol.asBuilder().setDeletionTime(clock.nowUtc().minusDays(1)).build());
assertThat(generateActualJsonWithIp("bad:f00d:cafe::15:beef"))
.isEqualTo(
generateExpectedJsonForNameserver(
"ns2.cat.lol",
null,
"4-ROID",
"v6",
"bad:f00d:cafe::15:beef",
"rdap_host.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
public void testAddressMatchV4Address_found() throws Exception {
@ -341,7 +407,7 @@ public class RdapNameserverSearchActionTest {
@Test
public void testAddressMatchV6Address_foundMultiple() throws Exception {
assertThat(generateActualJsonWithIp("bad:f00d:cafe::15:beef"))
.isEqualTo(generateExpectedJson("ns1.cat.external", "rdap_multiple_hosts.json"));
.isEqualTo(generateExpectedJson("rdap_multiple_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
@ -376,18 +442,26 @@ public class RdapNameserverSearchActionTest {
}
@Test
public void testNameMatchDeletedHost_foundTheOtherHost() throws Exception {
persistResource(
hostNs1Cat2Lol.asBuilder().setDeletionTime(clock.nowUtc().minusDays(1)).build());
assertThat(generateActualJsonWithIp("bad:f00d:cafe::15:beef"))
.isEqualTo(
generateExpectedJsonForNameserver(
"ns2.cat.lol",
null,
"4-ROID",
"v6",
"bad:f00d:cafe::15:beef",
"rdap_host.json"));
public void testAddressMatch_nontruncatedResultSet() throws Exception {
createManyHosts(4);
assertThat(generateActualJsonWithIp("5.5.5.1"))
.isEqualTo(generateExpectedJson("rdap_nontruncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
public void testAddressMatch_truncatedResultSet() throws Exception {
createManyHosts(5);
assertThat(generateActualJsonWithIp("5.5.5.1"))
.isEqualTo(generateExpectedJson("rdap_truncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
public void testAddressMatch_reallyTruncatedResultSet() throws Exception {
createManyHosts(9);
assertThat(generateActualJsonWithIp("5.5.5.1"))
.isEqualTo(generateExpectedJson("rdap_truncated_hosts.json"));
assertThat(response.getStatus()).isEqualTo(200);
}
}

View file

@ -0,0 +1,155 @@
{
"nameserverSearchResults" :
[
{
"objectClassName" : "nameserver",
"handle" : "14-ROID",
"status" : ["active"],
"ldhName" : "ns1.cat.lol",
"links" :
[
{
"value" : "https://example.tld/rdap/nameserver/ns1.cat.lol",
"rel" : "self",
"type" : "application/rdap+json",
"href" : "https://example.tld/rdap/nameserver/ns1.cat.lol"
}
],
"ipAddresses" :
{
"v4" : ["5.5.5.1", "5.5.5.2"]
},
"remarks": [
{
"title": "Incomplete Data",
"description": [
"Summary data only. For complete data, send a specific query for the object."
],
"type": "object truncated due to unexplainable reasons"
}
]
},
{
"objectClassName" : "nameserver",
"handle" : "15-ROID",
"status" : ["active"],
"ldhName" : "ns2.cat.lol",
"links" :
[
{
"value" : "https://example.tld/rdap/nameserver/ns2.cat.lol",
"rel" : "self",
"type" : "application/rdap+json",
"href" : "https://example.tld/rdap/nameserver/ns2.cat.lol"
}
],
"ipAddresses" :
{
"v4" : ["5.5.5.1", "5.5.5.2"]
},
"remarks": [
{
"title": "Incomplete Data",
"description": [
"Summary data only. For complete data, send a specific query for the object."
],
"type": "object truncated due to unexplainable reasons"
}
]
},
{
"objectClassName" : "nameserver",
"handle" : "16-ROID",
"status" : ["active"],
"ldhName" : "ns3.cat.lol",
"links" :
[
{
"value" : "https://example.tld/rdap/nameserver/ns3.cat.lol",
"rel" : "self",
"type" : "application/rdap+json",
"href" : "https://example.tld/rdap/nameserver/ns3.cat.lol"
}
],
"ipAddresses" :
{
"v4" : ["5.5.5.1", "5.5.5.2"]
},
"remarks": [
{
"title": "Incomplete Data",
"description": [
"Summary data only. For complete data, send a specific query for the object."
],
"type": "object truncated due to unexplainable reasons"
}
]
},
{
"objectClassName" : "nameserver",
"handle" : "17-ROID",
"status" : ["active"],
"ldhName" : "ns4.cat.lol",
"links" :
[
{
"value" : "https://example.tld/rdap/nameserver/ns4.cat.lol",
"rel" : "self",
"type" : "application/rdap+json",
"href" : "https://example.tld/rdap/nameserver/ns4.cat.lol"
}
],
"ipAddresses" :
{
"v4" : ["5.5.5.1", "5.5.5.2"]
},
"remarks": [
{
"title": "Incomplete Data",
"description": [
"Summary data only. For complete data, send a specific query for the object."
],
"type": "object truncated due to unexplainable reasons"
}
]
}
],
"rdapConformance" : ["rdap_level_0"],
"notices" :
[
{
"title" : "RDAP Terms of Service",
"description" :
[
"By querying our Domain Database, you are agreeing to comply with these terms so please read them carefully.",
"Any information provided is 'as is' without any guarantee of accuracy.",
"Please do not misuse the Domain Database. It is intended solely for query-based access.",
"Don't use the Domain Database to allow, enable, or otherwise support the transmission of mass unsolicited, commercial advertising or solicitations.",
"Don't access our Domain Database through the use of high volume, automated electronic processes that send queries or data to the systems of Charleston Road Registry or any ICANN-accredited registrar.",
"You may only use the information contained in the Domain Database for lawful purposes.",
"Do not compile, repackage, disseminate, or otherwise use the information contained in the Domain Database in its entirety, or in any substantial portion, without our prior written permission.",
"We may retain certain details about queries to our Domain Database for the purposes of detecting and preventing misuse.",
"We reserve the right to restrict or deny your access to the database if we suspect that you have failed to comply with these terms.",
"We reserve the right to modify this agreement at any time."
],
"links" :
[
{
"value" : "https://example.tld/rdap/help/tos",
"rel" : "alternate",
"href" : "https://www.registry.google/about/rdap/tos.html",
"type" : "text/html"
}
]
}
],
"remarks" :
[
{
"description" :
[
"This response conforms to the RDAP Operational Profile for gTLD Registries and Registrars version 1.0"
]
}
]
}

View file

@ -0,0 +1,163 @@
{
"nameserverSearchResults" :
[
{
"objectClassName" : "nameserver",
"handle" : "14-ROID",
"status" : ["active"],
"ldhName" : "ns1.cat.lol",
"links" :
[
{
"value" : "https://example.tld/rdap/nameserver/ns1.cat.lol",
"rel" : "self",
"type" : "application/rdap+json",
"href" : "https://example.tld/rdap/nameserver/ns1.cat.lol"
}
],
"ipAddresses" :
{
"v4" : ["5.5.5.1", "5.5.5.2"]
},
"remarks": [
{
"title": "Incomplete Data",
"description": [
"Summary data only. For complete data, send a specific query for the object."
],
"type": "object truncated due to unexplainable reasons"
}
]
},
{
"objectClassName" : "nameserver",
"handle" : "15-ROID",
"status" : ["active"],
"ldhName" : "ns2.cat.lol",
"links" :
[
{
"value" : "https://example.tld/rdap/nameserver/ns2.cat.lol",
"rel" : "self",
"type" : "application/rdap+json",
"href" : "https://example.tld/rdap/nameserver/ns2.cat.lol"
}
],
"ipAddresses" :
{
"v4" : ["5.5.5.1", "5.5.5.2"]
},
"remarks": [
{
"title": "Incomplete Data",
"description": [
"Summary data only. For complete data, send a specific query for the object."
],
"type": "object truncated due to unexplainable reasons"
}
]
},
{
"objectClassName" : "nameserver",
"handle" : "16-ROID",
"status" : ["active"],
"ldhName" : "ns3.cat.lol",
"links" :
[
{
"value" : "https://example.tld/rdap/nameserver/ns3.cat.lol",
"rel" : "self",
"type" : "application/rdap+json",
"href" : "https://example.tld/rdap/nameserver/ns3.cat.lol"
}
],
"ipAddresses" :
{
"v4" : ["5.5.5.1", "5.5.5.2"]
},
"remarks": [
{
"title": "Incomplete Data",
"description": [
"Summary data only. For complete data, send a specific query for the object."
],
"type": "object truncated due to unexplainable reasons"
}
]
},
{
"objectClassName" : "nameserver",
"handle" : "17-ROID",
"status" : ["active"],
"ldhName" : "ns4.cat.lol",
"links" :
[
{
"value" : "https://example.tld/rdap/nameserver/ns4.cat.lol",
"rel" : "self",
"type" : "application/rdap+json",
"href" : "https://example.tld/rdap/nameserver/ns4.cat.lol"
}
],
"ipAddresses" :
{
"v4" : ["5.5.5.1", "5.5.5.2"]
},
"remarks": [
{
"title": "Incomplete Data",
"description": [
"Summary data only. For complete data, send a specific query for the object."
],
"type": "object truncated due to unexplainable reasons"
}
]
}
],
"rdapConformance" : ["rdap_level_0"],
"notices" :
[
{
"title" : "Search Policy",
"type" : "result set truncated due to unexplainable reasons",
"description" :
[
"Search results per query are limited."
]
},
{
"title" : "RDAP Terms of Service",
"description" :
[
"By querying our Domain Database, you are agreeing to comply with these terms so please read them carefully.",
"Any information provided is 'as is' without any guarantee of accuracy.",
"Please do not misuse the Domain Database. It is intended solely for query-based access.",
"Don't use the Domain Database to allow, enable, or otherwise support the transmission of mass unsolicited, commercial advertising or solicitations.",
"Don't access our Domain Database through the use of high volume, automated electronic processes that send queries or data to the systems of Charleston Road Registry or any ICANN-accredited registrar.",
"You may only use the information contained in the Domain Database for lawful purposes.",
"Do not compile, repackage, disseminate, or otherwise use the information contained in the Domain Database in its entirety, or in any substantial portion, without our prior written permission.",
"We may retain certain details about queries to our Domain Database for the purposes of detecting and preventing misuse.",
"We reserve the right to restrict or deny your access to the database if we suspect that you have failed to comply with these terms.",
"We reserve the right to modify this agreement at any time."
],
"links" :
[
{
"value" : "https://example.tld/rdap/help/tos",
"rel" : "alternate",
"href" : "https://www.registry.google/about/rdap/tos.html",
"type" : "text/html"
}
]
}
],
"remarks" :
[
{
"description" :
[
"This response conforms to the RDAP Operational Profile for gTLD Registries and Registrars version 1.0"
]
}
]
}