Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.opensharing.http;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.function.Function;

/**
* Pages list endpoints. A page holds {@link #MAX_RESULTS} items when the request sets no
* maxResults or sets 0, and never more. A page token is an opaque encoding of the offset of the
* next page in the list's order, so that order must be total.
*/
public final class Pagination {

public static final int MAX_RESULTS = 500;

private static final String TOKEN_PREFIX = "os1:";

private Pagination() {}

/** Rows {@code offset} to {@code offset + limit - 1} of a list. */
@FunctionalInterface
public interface Slice<E> {
List<E> fetch(int offset, int limit);
}

/** One page of {@code slice} starting at {@code pageToken}. */
public static <E, T> ListResponse<T> page(
Integer maxResults, String pageToken, Slice<E> slice, Function<E, T> mapper) {
int size = pageSize(maxResults);
int offset = offsetOf(pageToken);
// One extra row tells whether another page follows.
List<E> rows = slice.fetch(offset, size + 1);
List<T> items = rows.stream().limit(size).map(mapper).toList();
return new ListResponse<>(items, rows.size() > size ? encode(offset + size) : null);
}

private static int pageSize(Integer maxResults) {
Comment thread
littlegrasscao marked this conversation as resolved.
if (maxResults == null || maxResults == 0) {
return MAX_RESULTS;
}
if (maxResults < 0) {
throw ApiException.invalidParameter("maxResults must not be negative");
}
return Math.min(maxResults, MAX_RESULTS);
}

private static int offsetOf(String pageToken) {
if (pageToken == null || pageToken.isEmpty()) {
return 0;
}
try {
String decoded = new String(Base64.getUrlDecoder().decode(pageToken), StandardCharsets.UTF_8);
if (decoded.startsWith(TOKEN_PREFIX)) {
int offset = Integer.parseInt(decoded.substring(TOKEN_PREFIX.length()));
if (offset >= 0) {
return offset;
}
}
} catch (IllegalArgumentException invalid) {
// Reported below like any other token this server did not issue.
}
throw ApiException.invalidParameter("pageToken is invalid");
}

private static String encode(int offset) {
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString((TOKEN_PREFIX + offset).getBytes(StandardCharsets.UTF_8));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.opensharing.catalog.CatalogConnector;
import io.opensharing.http.ApiException;
import io.opensharing.http.ListResponse;
import io.opensharing.http.Pagination;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
Expand Down Expand Up @@ -71,12 +72,12 @@ public RecipientResponse create(
return toResponse(recipient, activationBaseUrl);
}

/** Lists every recipient by name, unpaged. */
public ListResponse<RecipientResponse> list(String activationBaseUrl) {
// TODO: page with maxResults and pageToken.
/** Lists one page of recipients by name. */
public ListResponse<RecipientResponse> list(
Integer maxResults, String pageToken, String activationBaseUrl) {
// TODO: introduce USE_RECIPIENT and list only the caller's recipients unless they hold it.
return ListResponse.of(
recipients.list().stream().map(r -> toResponse(r, activationBaseUrl)).toList());
return Pagination.page(
maxResults, pageToken, recipients::list, r -> toResponse(r, activationBaseUrl));
}

/** Gets a recipient by name in any case. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,15 @@ public String findActivationCode(RecipientEntity recipient) {
});
}

/** Every recipient, regardless of owner, ordered by name. */
public List<RecipientEntity> list() {
/** Recipients of any owner, ordered by name; names are unique, so the order is total. */
public List<RecipientEntity> list(int offset, int limit) {
return tx.inTransaction(
true,
em ->
em.createQuery(
"select r from RecipientEntity r order by r.name", RecipientEntity.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,22 @@ public void revoke(
em.remove(permission);
}

/** The share's permissions, ordered by recipient name. */
public List<SharePermissionEntity> list(ShareEntity share) {
/**
* The share's permissions, ordered by recipient name and then privilege. A recipient holds
* each privilege at most once, so the order is total.
*/
public List<SharePermissionEntity> list(ShareEntity share, int offset, int limit) {
return tx.inTransaction(
true,
em ->
em.createQuery(
"select p from SharePermissionEntity p join fetch p.share"
+ " join fetch p.recipient r where p.share.id = :shareId"
+ " order by r.name",
+ " order by r.name, p.privilege",
SharePermissionEntity.class)
.setParameter("shareId", share.getId())
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import io.opensharing.exception.CatalogException;
import io.opensharing.http.ApiException;
import io.opensharing.http.ListResponse;
import io.opensharing.http.Pagination;
import io.opensharing.recipient.RecipientEntity;
import io.opensharing.recipient.RecipientStore;
import jakarta.persistence.EntityManager;
Expand Down Expand Up @@ -65,11 +66,10 @@ public ShareResponse create(UserContext user, CreateShareRequest request) {
validateProperties(request.properties())));
}

/** Lists every share by name, unpaged. */
public ListResponse<ShareResponse> list() {
// TODO: page with maxResults and pageToken.
/** Lists one page of shares by name. */
public ListResponse<ShareResponse> list(Integer maxResults, String pageToken) {
// TODO: check USE_SHARE through catalog.authorize.
return ListResponse.of(shares.list().stream().map(ShareResponse::from).toList());
return Pagination.page(maxResults, pageToken, shares::list, ShareResponse::from);
}

/** Gets a share by name in any case, with the objects in it when {@code includeSharedData}. */
Expand Down Expand Up @@ -135,18 +135,20 @@ public void delete(UserContext user, String share) {
shares.delete(share, user);
}

/** Lists who holds which privilege on the share, by recipient name, unpaged. */
public ListResponse<SharePermissionResponse> listPermissions(String share) {
// TODO: page with maxResults and pageToken.
return ListResponse.of(
permissions.list(shares.getOrThrow(share)).stream()
.map(SharePermissionResponse::from)
.toList());
/** Lists one page of who holds which privilege on the share, by recipient name. */
public ListResponse<SharePermissionResponse> listPermissions(
String share, Integer maxResults, String pageToken) {
ShareEntity entity = shares.getOrThrow(share);
return Pagination.page(
maxResults,
pageToken,
(offset, limit) -> permissions.list(entity, offset, limit),
SharePermissionResponse::from);
}

/**
* Applies the changes in order, each revoking then granting, all in one transaction: either every
* change commits or none does. Returns the share's permissions. Owner only.
* change commits or none does. Returns the first page of the share's permissions. Owner only.
*/
public ListResponse<SharePermissionResponse> updatePermissions(
UserContext user, String share, UpdateSharePermissionsRequest request) {
Expand Down Expand Up @@ -178,7 +180,7 @@ public ListResponse<SharePermissionResponse> updatePermissions(
}
return null;
});
return listPermissions(share);
return listPermissions(share, null, null);
}

// Resolves the object in the catalog on behalf of the caller and checks the declared type.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,14 @@ public ShareEntity getOwnedOrThrow(String name, UserContext user) {
return tx.inTransaction(true, em -> checkOwner(getOrThrow(em, name, LockModeType.NONE), user));
}

/** Every share, regardless of owner, ordered by name. */
public List<ShareEntity> list() {
/** Shares of any owner, ordered by name; names are unique, so the order is total. */
public List<ShareEntity> list(int offset, int limit) {
return tx.inTransaction(
true,
em ->
em.createQuery("select s from ShareEntity s order by s.name", ShareEntity.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.opensharing.http;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.List;
import java.util.function.Function;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;

class PaginationTest {

private static final List<Integer> ROWS = IntStream.range(0, 1200).boxed().toList();

@Test
void pagesThroughTheListWithTokens() {
ListResponse<Integer> first = page(null, null);
assertEquals(ROWS.subList(0, 500), first.items());

ListResponse<Integer> second = page(null, first.nextPageToken());
assertEquals(ROWS.subList(500, 1000), second.items());

ListResponse<Integer> last = page(null, second.nextPageToken());
assertEquals(ROWS.subList(1000, 1200), last.items());
assertNull(last.nextPageToken());
}

@Test
void capsRequestedPageSizesAtTheMaximum() {
assertEquals(500, page(1000, null).items().size());
assertEquals(List.of(0, 1), page(2, null).items());
}

@Test
void treatsAZeroPageSizeAsUnset() {
ListResponse<Integer> first = page(0, null);
assertEquals(ROWS.subList(0, 500), first.items());
assertEquals(ROWS.subList(500, 1000), page(0, first.nextPageToken()).items());
}

@Test
void rejectsNegativePageSizesAndForeignTokens() {
assertThrows(ApiException.class, () -> page(-1, null));
assertThrows(ApiException.class, () -> page(null, "not-a-token"));
assertThrows(ApiException.class, () -> page(null, "b3MxOi0x"));
}

private static ListResponse<Integer> page(Integer maxResults, String pageToken) {
return Pagination.page(maxResults, pageToken, PaginationTest::slice, Function.identity());
}

private static List<Integer> slice(int offset, int limit) {
int end = Math.min(offset + limit, ROWS.size());
return ROWS.subList(Math.min(offset, end), end);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.server.ServiceRequestContext;
import com.linecorp.armeria.server.annotation.Blocking;
import com.linecorp.armeria.server.annotation.Delete;
Expand Down Expand Up @@ -45,10 +46,11 @@ public RecipientResponse create(ServiceRequestContext ctx, CreateRecipientReques
return recipients.create(ProviderTokenDecorator.user(ctx), request, activationBaseUrl);
}

/** {@code GET /recipients}: lists every recipient by name, unpaged. */
/** {@code GET /recipients}: lists one page of recipients by name. */
@Get("/recipients")
public ListResponse<RecipientResponse> list() {
return recipients.list(activationBaseUrl);
public ListResponse<RecipientResponse> list(
@Param @Nullable Integer maxResults, @Param @Nullable String pageToken) {
return recipients.list(maxResults, pageToken, activationBaseUrl);
}

/** {@code GET /recipients/{recipient}}: gets a recipient by name in any case. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.server.ServiceRequestContext;
import com.linecorp.armeria.server.annotation.Blocking;
import com.linecorp.armeria.server.annotation.Default;
Expand Down Expand Up @@ -36,10 +37,11 @@ public ShareResponse create(ServiceRequestContext ctx, CreateShareRequest reques
return shares.create(ProviderTokenDecorator.user(ctx), request);
}

/** {@code GET /shares}: lists every share by name, unpaged. */
/** {@code GET /shares}: lists one page of shares by name. */
@Get("/shares")
public ListResponse<ShareResponse> list() {
return shares.list();
public ListResponse<ShareResponse> list(
@Param @Nullable Integer maxResults, @Param @Nullable String pageToken) {
return shares.list(maxResults, pageToken);
}

/**
Expand Down Expand Up @@ -70,15 +72,21 @@ public HttpResponse delete(ServiceRequestContext ctx, @Param String share) {
return HttpResponse.of(HttpStatus.NO_CONTENT);
}

/** {@code GET /shares/{share}/permissions}: lists who holds which privilege on the share. */
/**
* {@code GET /shares/{share}/permissions}: lists one page of who holds which privilege on the
* share.
*/
@Get("/shares/{share}/permissions")
public ListResponse<SharePermissionResponse> listPermissions(@Param String share) {
return shares.listPermissions(share);
public ListResponse<SharePermissionResponse> listPermissions(
@Param String share,
@Param @Nullable Integer maxResults,
@Param @Nullable String pageToken) {
return shares.listPermissions(share, maxResults, pageToken);
}

/**
* {@code PATCH /shares/{share}/permissions}: grants and revokes privileges in order, all or
* nothing, then returns the share's permissions. Owner only.
* nothing, then returns the first page of the share's permissions. Owner only.
*/
@Patch("/shares/{share}/permissions")
public ListResponse<SharePermissionResponse> updatePermissions(
Expand Down
Loading
Loading