Add a console action to retrieve a paged list of domains (#2193)

In the future we'll want to add searching capability but for now we can
go with straightforward pagination.
This commit is contained in:
gbrodman 2023-10-30 17:01:31 -04:00 committed by GitHub
parent 8158f761c8
commit 1d6b119340
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 403 additions and 11 deletions

View file

@ -128,14 +128,14 @@ public class DomainBase extends EppResource
String tld;
/** References to hosts that are the nameservers for the domain. */
@Expose @Transient Set<VKey<Host>> nsHosts;
@Transient Set<VKey<Host>> nsHosts;
/** Contacts. */
@Expose VKey<Contact> adminContact;
VKey<Contact> adminContact;
@Expose VKey<Contact> billingContact;
@Expose VKey<Contact> techContact;
@Expose VKey<Contact> registrantContact;
VKey<Contact> billingContact;
VKey<Contact> techContact;
VKey<Contact> registrantContact;
/** Authorization info (aka transfer secret) of the domain. */
@Embedded

View file

@ -26,6 +26,7 @@ import google.registry.request.RequestComponentBuilder;
import google.registry.request.RequestModule;
import google.registry.request.RequestScope;
import google.registry.ui.server.console.ConsoleDomainGetAction;
import google.registry.ui.server.console.ConsoleDomainListAction;
import google.registry.ui.server.console.ConsoleUserDataAction;
import google.registry.ui.server.console.RegistrarsAction;
import google.registry.ui.server.console.settings.ContactAction;
@ -55,6 +56,8 @@ import google.registry.ui.server.registrar.RegistryLockVerifyAction;
interface FrontendRequestComponent {
ConsoleDomainGetAction consoleDomainGetAction();
ConsoleDomainListAction consoleDomainListAction();
ConsoleOteSetupAction consoleOteSetupAction();
ConsoleRegistrarCreatorAction consoleRegistrarCreatorAction();
ConsoleUiAction consoleUiAction();

View file

@ -0,0 +1,148 @@
// Copyright 2023 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.ui.server.console;
import static google.registry.model.console.ConsolePermission.DOWNLOAD_DOMAINS;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import com.google.api.client.http.HttpStatusCodes;
import com.google.common.annotations.VisibleForTesting;
import com.google.gson.Gson;
import com.google.gson.annotations.Expose;
import google.registry.model.CreateAutoTimestamp;
import google.registry.model.console.User;
import google.registry.model.domain.Domain;
import google.registry.request.Action;
import google.registry.request.Parameter;
import google.registry.request.Response;
import google.registry.request.auth.Auth;
import google.registry.request.auth.AuthResult;
import google.registry.ui.server.registrar.JsonGetAction;
import java.util.List;
import java.util.Optional;
import javax.inject.Inject;
import org.joda.time.DateTime;
/** Returns a (paginated) list of domains for a particular registrar. */
@Action(
service = Action.Service.DEFAULT,
path = ConsoleDomainListAction.PATH,
method = Action.Method.GET,
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class ConsoleDomainListAction implements JsonGetAction {
public static final String PATH = "/console-api/domain-list";
private static final int DEFAULT_RESULTS_PER_PAGE = 50;
private static final String DOMAIN_QUERY_TEMPLATE =
"FROM Domain WHERE currentSponsorRegistrarId = :registrarId AND deletionTime >"
+ " :deletedAfterTime AND creationTime <= :createdBeforeTime";
private final AuthResult authResult;
private final Response response;
private final Gson gson;
private final String registrarId;
private final Optional<DateTime> checkpointTime;
private final int pageNumber;
private final int resultsPerPage;
private final Optional<Long> totalResults;
@Inject
public ConsoleDomainListAction(
AuthResult authResult,
Response response,
Gson gson,
@Parameter("registrarId") String registrarId,
@Parameter("checkpointTime") Optional<DateTime> checkpointTime,
@Parameter("pageNumber") Optional<Integer> pageNumber,
@Parameter("resultsPerPage") Optional<Integer> resultsPerPage,
@Parameter("totalResults") Optional<Long> totalResults) {
this.authResult = authResult;
this.response = response;
this.gson = gson;
this.registrarId = registrarId;
this.checkpointTime = checkpointTime;
this.pageNumber = pageNumber.orElse(0);
this.resultsPerPage = resultsPerPage.orElse(DEFAULT_RESULTS_PER_PAGE);
this.totalResults = totalResults;
}
@Override
public void run() {
User user = authResult.userAuthInfo().get().consoleUser().get();
if (!user.getUserRoles().hasPermission(registrarId, DOWNLOAD_DOMAINS)) {
response.setStatus(HttpStatusCodes.STATUS_CODE_FORBIDDEN);
return;
}
if (resultsPerPage < 1 || resultsPerPage > 500) {
writeBadRequest("Results per page must be between 1 and 500 inclusive");
return;
}
if (pageNumber < 0) {
writeBadRequest("Page number must be non-negative");
return;
}
tm().transact(this::runInTransaction);
}
private void runInTransaction() {
int numResultsToSkip = resultsPerPage * pageNumber;
// We have to use a constant checkpoint time in order to have stable pagination, since domains
// can be constantly created or deleted
DateTime checkpoint = checkpointTime.orElseGet(tm()::getTransactionTime);
CreateAutoTimestamp checkpointTimestamp = CreateAutoTimestamp.create(checkpoint);
// Don't compute the number of total results over and over if we don't need to
long actualTotalResults =
totalResults.orElseGet(
() ->
tm().query("SELECT COUNT(*) " + DOMAIN_QUERY_TEMPLATE, Long.class)
.setParameter("registrarId", registrarId)
.setParameter("createdBeforeTime", checkpointTimestamp)
.setParameter("deletedAfterTime", checkpoint)
.getSingleResult());
List<Domain> domains =
tm().query(DOMAIN_QUERY_TEMPLATE + " ORDER BY creationTime DESC", Domain.class)
.setParameter("registrarId", registrarId)
.setParameter("createdBeforeTime", checkpointTimestamp)
.setParameter("deletedAfterTime", checkpoint)
.setFirstResult(numResultsToSkip)
.setMaxResults(resultsPerPage)
.getResultList();
response.setPayload(gson.toJson(new DomainListResult(domains, checkpoint, actualTotalResults)));
response.setStatus(HttpStatusCodes.STATUS_CODE_OK);
}
private void writeBadRequest(String message) {
response.setPayload(message);
response.setStatus(HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
}
/** Container result class that allows for pagination. */
@VisibleForTesting
static final class DomainListResult {
@Expose List<Domain> domains;
@Expose DateTime checkpointTime;
@Expose long totalResults;
private DomainListResult(List<Domain> domains, DateTime checkpointTime, long totalResults) {
this.domains = domains;
this.checkpointTime = checkpointTime;
this.totalResults = totalResults;
}
}
}

View file

@ -30,6 +30,7 @@ import google.registry.request.OptionalJsonPayload;
import google.registry.request.Parameter;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import org.joda.time.DateTime;
/** Dagger module for the Registrar Console parameters. */
@Module
@ -188,4 +189,28 @@ public final class RegistrarConsoleModule {
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
return payload.map(s -> gson.fromJson(s, Registrar.class));
}
@Provides
@Parameter("checkpointTime")
public static Optional<DateTime> provideCheckpointTime(HttpServletRequest req) {
return extractOptionalParameter(req, "checkpointTime").map(DateTime::parse);
}
@Provides
@Parameter("pageNumber")
public static Optional<Integer> providePageNumber(HttpServletRequest req) {
return extractOptionalIntParameter(req, "pageNumber");
}
@Provides
@Parameter("resultsPerPage")
public static Optional<Integer> provideResultsPerPage(HttpServletRequest req) {
return extractOptionalIntParameter(req, "resultsPerPage");
}
@Provides
@Parameter("totalResults")
public static Optional<Long> provideTotalResults(HttpServletRequest req) {
return extractOptionalParameter(req, "totalResults").map(Long::valueOf);
}
}

View file

@ -66,12 +66,12 @@ public class ConsoleDomainGetActionTest {
assertThat(RESPONSE.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK);
assertThat(RESPONSE.getPayload())
.isEqualTo(
"{\"domainName\":\"exists.tld\",\"adminContact\":{\"key\":\"3-ROID\"},\"techContact\":"
+ "{\"key\":\"3-ROID\"},\"registrantContact\":{\"key\":\"3-ROID\"},\"registrationExpirationTime\":"
+ "\"294247-01-10T04:00:54.775Z\",\"lastTransferTime\":\"null\",\"repoId\":\"2-TLD\","
+ "\"currentSponsorRegistrarId\":\"TheRegistrar\",\"creationRegistrarId\":\"TheRegistrar\","
+ "\"creationTime\":{\"creationTime\":\"1970-01-01T00:00:00.000Z\"},\"lastEppUpdateTime\":\"null\","
+ "\"statuses\":[\"INACTIVE\"]}");
"{\"domainName\":\"exists.tld\",\"registrationExpirationTime\":"
+ "\"294247-01-10T04:00:54.775Z\",\"lastTransferTime\":\"null\",\"repoId\":"
+ "\"2-TLD\",\"currentSponsorRegistrarId\":\"TheRegistrar\",\"creationRegistrarId\""
+ ":\"TheRegistrar\",\"creationTime\":{\"creationTime\":"
+ "\"1970-01-01T00:00:00.000Z\"},\"lastEppUpdateTime\":\"null\",\"statuses\":"
+ "[\"INACTIVE\"]}");
}
@Test

View file

@ -0,0 +1,215 @@
// Copyright 2023 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.ui.server.console;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.testing.DatabaseHelper.createTld;
import static google.registry.testing.DatabaseHelper.persistActiveDomain;
import static google.registry.testing.DatabaseHelper.persistDomainAsDeleted;
import com.google.api.client.http.HttpStatusCodes;
import com.google.gson.Gson;
import google.registry.model.EppResourceUtils;
import google.registry.model.console.GlobalRole;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
import google.registry.model.domain.Domain;
import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.request.auth.AuthResult;
import google.registry.request.auth.UserAuthInfo;
import google.registry.testing.DatabaseHelper;
import google.registry.testing.FakeClock;
import google.registry.testing.FakeResponse;
import google.registry.tools.GsonUtils;
import google.registry.ui.server.console.ConsoleDomainListAction.DomainListResult;
import java.util.Optional;
import javax.annotation.Nullable;
import org.joda.time.DateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
/** Tests for {@link ConsoleDomainListAction}. */
public class ConsoleDomainListActionTest {
private static final Gson GSON = GsonUtils.provideGson();
private final FakeClock clock = new FakeClock(DateTime.parse("2023-10-20T00:00:00.000Z"));
private FakeResponse response;
@RegisterExtension
final JpaTestExtensions.JpaIntegrationTestExtension jpa =
new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension();
@BeforeEach
void beforeEach() {
createTld("tld");
for (int i = 0; i < 10; i++) {
DatabaseHelper.persistActiveDomain(i + "exists.tld", clock.nowUtc());
clock.advanceOneMilli();
}
DatabaseHelper.persistDeletedDomain("deleted.tld", clock.nowUtc().minusDays(1));
}
@Test
void testSuccess_allDomains() {
ConsoleDomainListAction action = createAction("TheRegistrar");
action.run();
DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains).hasSize(10);
assertThat(result.totalResults).isEqualTo(10);
assertThat(result.checkpointTime).isEqualTo(clock.nowUtc());
assertThat(result.domains.stream().anyMatch(d -> d.getDomainName().equals("deleted.tld")))
.isFalse();
}
@Test
void testSuccess_noDomains() {
ConsoleDomainListAction action = createAction("NewRegistrar");
action.run();
DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains).hasSize(0);
assertThat(result.totalResults).isEqualTo(0);
assertThat(result.checkpointTime).isEqualTo(clock.nowUtc());
}
@Test
void testSuccess_pages() {
// Two pages of results should go in reverse chronological order
ConsoleDomainListAction action = createAction("TheRegistrar", null, 0, 5, null);
action.run();
DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains.stream().map(Domain::getDomainName).collect(toImmutableList()))
.containsExactly("9exists.tld", "8exists.tld", "7exists.tld", "6exists.tld", "5exists.tld");
assertThat(result.totalResults).isEqualTo(10);
// Now do the second page
action = createAction("TheRegistrar", result.checkpointTime, 1, 5, 10L);
action.run();
result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains.stream().map(Domain::getDomainName).collect(toImmutableList()))
.containsExactly("4exists.tld", "3exists.tld", "2exists.tld", "1exists.tld", "0exists.tld");
}
@Test
void testSuccess_partialPage() {
ConsoleDomainListAction action = createAction("TheRegistrar", null, 1, 8, null);
action.run();
DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains.stream().map(Domain::getDomainName).collect(toImmutableList()))
.containsExactly("1exists.tld", "0exists.tld");
}
@Test
void testSuccess_checkpointTime_createdBefore() {
ConsoleDomainListAction action = createAction("TheRegistrar", null, 0, 10, null);
action.run();
DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains).hasSize(10);
assertThat(result.totalResults).isEqualTo(10);
clock.advanceOneMilli();
persistActiveDomain("newdomain.tld", clock.nowUtc());
// Even though we persisted a new domain, the old checkpoint should return no more results
action = createAction("TheRegistrar", result.checkpointTime, 1, 10, null);
action.run();
result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains).isEmpty();
assertThat(result.totalResults).isEqualTo(10);
}
@Test
void testSuccess_checkpointTime_deletion() {
ConsoleDomainListAction action = createAction("TheRegistrar", null, 0, 5, null);
action.run();
DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class);
clock.advanceOneMilli();
Domain toDelete =
EppResourceUtils.loadByForeignKey(Domain.class, "0exists.tld", clock.nowUtc()).get();
persistDomainAsDeleted(toDelete, clock.nowUtc());
// Second page should include the domain that is now deleted due to the checkpoint time
action = createAction("TheRegistrar", result.checkpointTime, 1, 5, null);
action.run();
result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains.stream().map(Domain::getDomainName).collect(toImmutableList()))
.containsExactly("4exists.tld", "3exists.tld", "2exists.tld", "1exists.tld", "0exists.tld");
}
@Test
void testPartialSuccess_pastEnd() {
ConsoleDomainListAction action = createAction("TheRegistrar", null, 5, 5, null);
action.run();
DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class);
assertThat(result.domains).isEmpty();
}
@Test
void testFailure_invalidResultsPerPage() {
ConsoleDomainListAction action = createAction("TheRegistrar", null, 0, 0, null);
action.run();
assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
assertThat(response.getPayload())
.isEqualTo("Results per page must be between 1 and 500 inclusive");
action = createAction("TheRegistrar", null, 0, 501, null);
action.run();
assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
assertThat(response.getPayload())
.isEqualTo("Results per page must be between 1 and 500 inclusive");
}
@Test
void testFailure_invalidPageNumber() {
ConsoleDomainListAction action = createAction("TheRegistrar", null, -1, 10, null);
action.run();
assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
assertThat(response.getPayload()).isEqualTo("Page number must be non-negative");
}
private ConsoleDomainListAction createAction(String registrarId) {
return createAction(registrarId, null, null, null, null);
}
private ConsoleDomainListAction createAction(
String registrarId,
@Nullable DateTime checkpointTime,
@Nullable Integer pageNumber,
@Nullable Integer resultsPerPage,
@Nullable Long totalResults) {
response = new FakeResponse();
AuthResult authResult =
AuthResult.createUser(
UserAuthInfo.create(
new User.Builder()
.setEmailAddress("email@email.example")
.setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build())
.build()));
return new ConsoleDomainListAction(
authResult,
response,
GSON,
registrarId,
Optional.ofNullable(checkpointTime),
Optional.ofNullable(pageNumber),
Optional.ofNullable(resultsPerPage),
Optional.ofNullable(totalResults));
}
}

View file

@ -1,6 +1,7 @@
PATH CLASS METHODS OK AUTH_METHODS MIN USER_POLICY
/_dr/epp EppTlsAction POST n API APP ADMIN
/console-api/domain ConsoleDomainGetAction GET n API,LEGACY USER PUBLIC
/console-api/domain-list ConsoleDomainListAction GET n API,LEGACY USER PUBLIC
/console-api/registrars RegistrarsAction GET,POST n API,LEGACY USER PUBLIC
/console-api/settings/contacts ContactAction GET,POST n API,LEGACY USER PUBLIC
/console-api/settings/security SecurityAction POST n API,LEGACY USER PUBLIC