Skip to content

Commit

Permalink
[4233] Add Cursor-based pagination in Project related GET REST APIs
Browse files Browse the repository at this point in the history
Bug: #4233
Signed-off-by: Axel RICHARD <axel.richard@obeo.fr>
  • Loading branch information
AxelRICHARD committed Nov 27, 2024
1 parent bcfad1b commit e5c5d89
Show file tree
Hide file tree
Showing 17 changed files with 642 additions and 73 deletions.
16 changes: 16 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ A new `IProjectDataVersioningRestServiceDelegate` interface is available, allowi
Specifiers can implement this new interface with a spring `Service`.
A new `IRestDataVersionPayloadSerializerService` interface is available, allowing to customize the default implementation of the JSON serialization of the payload object of `RestDataVersion`.
Specifiers are also encouraged to implement their own `IRestDataVersionPayloadSerializerService` for their domains, as the default one may not return expected results.
- https://github.com/eclipse-sirius/sirius-web/issues/4233[#4233] [core] Add cursor-based pagination for Project related GET REST APIs.
New optional attributes are available on all Project related GET REST APIs returning a list of objects, allowing to paginate the data returned by those APIs.
The following APIs are concerned:
** getProjects (`GET /api/rest/projects`)
The new optional attributes are:
** `page[size]` specifies the maximum number of records that will be returned per page in the response
** `page[before]` specifies the URL of the page succeeding the page being requested
** `page[after]` specifies the URL of a page preceding the page being requested
If neither `page[before]` nor `page[after]` is specified, the first page is returned with the same number of records as specified in the `page[size]` query parameter.
If the `page[size]` parameter is not specified, then the default page size is used, which is 20.
Example:
** `http://my-sirius-web-server:8080/api/rest/projects?
page[after]=MTYxODg2MTQ5NjYzMnwyMDEwOWY0MC00ODI1LTQxNmEtODZmNi03NTA4YWM0MmEwMjE&
page[size]=3` will ask for the 3 projects following the one identified by the URL of the page succeeding the page being requested `MTYxODg2MTQ5NjYzMnwyMDEwOWY0MC00ODI1LTQxNmEtODZmNi03NTA4YWM0MmEwMjE`.
Note that you can retrieve the URL of a page in the response header of `GET /api/rest/projects`.
Note that you may need to encode special characters like `[`(by `%5B`) and `]` (by `%5D`) in your requests.

=== Improvements

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
Expand All @@ -30,9 +31,12 @@
import org.eclipse.sirius.web.application.project.dto.RenameProjectSuccessPayload;
import org.eclipse.sirius.web.application.project.dto.RestProject;
import org.eclipse.sirius.web.application.project.services.api.IProjectApplicationService;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.KeysetScrollPosition;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -41,6 +45,10 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponents;

import graphql.relay.Relay;

/**
* REST Controller for the Project Endpoint.
Expand All @@ -51,6 +59,8 @@
@RequestMapping("/api/rest/projects")
public class ProjectRestController {

private static final int DEFAULT_PAGE_SIZE = 20;

private static final OffsetDateTime DEFAULT_CREATED = Instant.EPOCH.atOffset(ZoneOffset.UTC);

private final IProjectApplicationService projectApplicationService;
Expand All @@ -60,12 +70,26 @@ public ProjectRestController(IProjectApplicationService projectApplicationServic
}

@GetMapping
public ResponseEntity<List<RestProject>> getProjects() {
var restProjects = this.projectApplicationService.findAll(PageRequest.of(0, 20))
public ResponseEntity<List<RestProject>> getProjects(@RequestParam(name = "page[size]") Optional<Integer> pageSize, @RequestParam(name = "page[after]") Optional<String> pageAfter, @RequestParam(name = "page[before]") Optional<String> pageBefore) {
final KeysetScrollPosition position;
if (pageAfter.isPresent() && pageBefore.isEmpty()) {
var cursorProjectId = new Relay().fromGlobalId(pageAfter.get()).getId();
position = ScrollPosition.forward(Map.of("id", cursorProjectId));
} else if (pageBefore.isPresent() && pageAfter.isEmpty()) {
var cursorProjectId = new Relay().fromGlobalId(pageBefore.get()).getId();
position = ScrollPosition.backward(Map.of("id", cursorProjectId));
} else if (pageBefore.isPresent() && pageAfter.isPresent()) {
position = ScrollPosition.keyset();
} else {
position = ScrollPosition.keyset();
}
int limit = pageSize.orElse(DEFAULT_PAGE_SIZE);
var window = this.projectApplicationService.findAll(position, limit);
var restProjects = window
.map(project -> new RestProject(project.id(), DEFAULT_CREATED, new Identified(project.id()), null, project.name()))
.toList();

return new ResponseEntity<>(restProjects, HttpStatus.OK);
var headers = this.handleLinkResponseHeader(restProjects, position, window.hasNext(), limit);
return new ResponseEntity<>(restProjects, headers, HttpStatus.OK);
}

@GetMapping(path = "/{projectId}")
Expand Down Expand Up @@ -123,4 +147,33 @@ public ResponseEntity<RestProject> deleteProject(@PathVariable UUID projectId) {

return new ResponseEntity<>(restProject, HttpStatus.OK);
}

private MultiValueMap<String, String> handleLinkResponseHeader(List<RestProject> projects, KeysetScrollPosition position, boolean hasNext, int limit) {
MultiValueMap<String, String> headers = new HttpHeaders();
int projectsSize = projects.size();
if (projectsSize > 0 && position.scrollsForward() && hasNext) {
var headerLink = this.createHeaderLink(projects, limit, "after", "next");
headers.add(HttpHeaders.LINK, headerLink);
} else if (projectsSize > 0 && position.scrollsBackward() && hasNext) {
var headerLink = this.createHeaderLink(projects, limit, "before", "prev");
headers.add(HttpHeaders.LINK, headerLink);
}
return headers;
}

private String createHeaderLink(List<RestProject> projects, int limit, String beforeOrAfterPage, String relationType) {
var header = new StringBuilder();
var lastProject = projects.get(Math.min(projects.size() - 1, limit - 1));
var cursorId = new Relay().toGlobalId("Project", lastProject.id().toString());
UriComponents uriComponents = ServletUriComponentsBuilder.fromCurrentRequestUri()
.queryParam("page[" + beforeOrAfterPage + "]", cursorId)
.queryParam("page[size]", limit)
.build();
header.append("<");
header.append(uriComponents.toUriString());
header.append(">; rel=\"");
header.append(relationType);
header.append("\"");
return header.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*******************************************************************************/
package org.eclipse.sirius.web.application.project.controllers;

import java.util.Map;
import java.util.Objects;
import java.util.Optional;

Expand All @@ -20,8 +21,9 @@
import org.eclipse.sirius.components.graphql.api.IDataFetcherWithFieldCoordinates;
import org.eclipse.sirius.web.application.project.dto.ProjectDTO;
import org.eclipse.sirius.web.application.project.services.api.IProjectApplicationService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.KeysetScrollPosition;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Window;

import graphql.relay.Connection;
import graphql.relay.ConnectionCursor;
Expand All @@ -40,9 +42,15 @@
@QueryDataFetcher(type = "Viewer", field = "projects")
public class ViewerProjectsDataFetcher implements IDataFetcherWithFieldCoordinates<Connection<ProjectDTO>> {

private static final String PAGE_ARGUMENT = "page";
private static final int DEFAULT_PAGE_SIZE = 20;

private static final String LIMIT_ARGUMENT = "limit";
private static final String FIRST_ARGUMENT = "first";

private static final String LAST_ARGUMENT = "last";

private static final String AFTER_ARGUMENT = "after";

private static final String BEFORE_ARGUMENT = "before";

private final IProjectApplicationService projectApplicationService;

Expand All @@ -52,19 +60,56 @@ public ViewerProjectsDataFetcher(IProjectApplicationService projectApplicationSe

@Override
public Connection<ProjectDTO> get(DataFetchingEnvironment environment) throws Exception {
int page = Optional.<Integer> ofNullable(environment.getArgument(PAGE_ARGUMENT))
.filter(pageArgument -> pageArgument > 0)
.orElse(0);
int limit = Optional.<Integer> ofNullable(environment.getArgument(LIMIT_ARGUMENT))
.filter(limitArgument -> limitArgument > 0)
.orElse(20);

var pageable = PageRequest.of(page, limit);
var projectPage = this.projectApplicationService.findAll(pageable);
return this.toConnection(projectPage);
Optional<Integer> first = Optional.<Integer> ofNullable(environment.getArgument(FIRST_ARGUMENT));
Optional<Integer> last = Optional.<Integer> ofNullable(environment.getArgument(LAST_ARGUMENT));
Optional<String> after = Optional.<String> ofNullable(environment.getArgument(AFTER_ARGUMENT));
Optional<String> before = Optional.<String> ofNullable(environment.getArgument(BEFORE_ARGUMENT));

final KeysetScrollPosition position;
final int limit;
if (after.isPresent() && before.isEmpty()) {
var projectId = after.get();
var cursorProjectId = new Relay().fromGlobalId(projectId).getId();
position = ScrollPosition.forward(Map.of("id", cursorProjectId));
if (last.isPresent()) {
limit = 0;
} else if (first.isPresent()) {
limit = first.get();
} else {
limit = DEFAULT_PAGE_SIZE;
}
} else if (before.isPresent() && after.isEmpty()) {
var projectId = before.get();
var cursorProjectId = new Relay().fromGlobalId(projectId).getId();
position = ScrollPosition.backward(Map.of("id", cursorProjectId));
if (first.isPresent()) {
limit = 0;
} else if (last.isPresent()) {
limit = last.get();
} else {
limit = DEFAULT_PAGE_SIZE;
}
} else if (before.isPresent() && after.isPresent()) {
position = ScrollPosition.keyset();
limit = 0;
} else {
position = ScrollPosition.keyset();
if (first.isPresent() && last.isPresent()) {
limit = 0;
} else if (first.isPresent()) {
limit = first.get();
} else if (last.isPresent()) {
limit = last.get();
} else {
limit = DEFAULT_PAGE_SIZE;
}
}

var projectPage = this.projectApplicationService.findAll(position, limit);
return this.toConnection(projectPage, position);
}

private Connection<ProjectDTO> toConnection(Page<ProjectDTO> projectPage) {
private Connection<ProjectDTO> toConnection(Window<ProjectDTO> projectPage, KeysetScrollPosition position) {
var edges = projectPage.stream().map(projectDTO -> {
var globalId = new Relay().toGlobalId("Project", projectDTO.id().toString());
var cursor = new DefaultConnectionCursor(globalId);
Expand All @@ -78,7 +123,15 @@ private Connection<ProjectDTO> toConnection(Page<ProjectDTO> projectPage) {
if (!edges.isEmpty()) {
endCursor = edges.get(edges.size() - 1).getCursor();
}
var pageInfo = new PageInfoWithCount(startCursor, endCursor, projectPage.hasPrevious(), projectPage.hasNext(), projectPage.getTotalElements());
boolean hasPreviousPage = false;
if (position.scrollsBackward()) {
hasPreviousPage = projectPage.hasNext();
}
boolean hasNextPage = false;
if (position.scrollsForward()) {
hasNextPage = projectPage.hasNext();
}
var pageInfo = new PageInfoWithCount(startCursor, endCursor, hasPreviousPage, hasNextPage, projectPage.size());
return new DefaultConnection<>(edges, pageInfo);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
import org.eclipse.sirius.web.domain.boundedcontexts.project.services.api.IProjectUpdateService;
import org.eclipse.sirius.web.domain.services.Failure;
import org.eclipse.sirius.web.domain.services.Success;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.KeysetScrollPosition;
import org.springframework.data.domain.Window;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -72,8 +72,8 @@ public Optional<ProjectDTO> findById(UUID projectId) {

@Override
@Transactional(readOnly = true)
public Page<ProjectDTO> findAll(Pageable pageable) {
return this.projectSearchService.findAll(pageable).map(this.projectMapper::toDTO);
public Window<ProjectDTO> findAll(KeysetScrollPosition position, int limit) {
return this.projectSearchService.findAll(position, limit).map(this.projectMapper::toDTO);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
import org.eclipse.sirius.web.application.project.dto.DeleteProjectInput;
import org.eclipse.sirius.web.application.project.dto.ProjectDTO;
import org.eclipse.sirius.web.application.project.dto.RenameProjectInput;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.KeysetScrollPosition;
import org.springframework.data.domain.Window;

/**
* Application services used to manipulate projects.
Expand All @@ -31,7 +31,7 @@
public interface IProjectApplicationService {
Optional<ProjectDTO> findById(UUID id);

Page<ProjectDTO> findAll(Pageable pageable);
Window<ProjectDTO> findAll(KeysetScrollPosition position, int limit);

IPayload createProject(CreateProjectInput input);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
extend type Viewer {
project(projectId: ID!): Project
projects(page: Int!, limit: Int!): ViewerProjectsConnection!
projects(after: String, before: String, first: Int, last: Int): ViewerProjectsConnection!
projectTemplates(page: Int!, limit: Int!): ViewerProjectTemplatesConnection!
}

Expand All @@ -11,6 +11,7 @@ type ViewerProjectsConnection {

type ViewerProjectsEdge {
node: Project!
cursor: String!
}

type Project {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@

import org.eclipse.sirius.web.domain.boundedcontexts.project.Project;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.ListPagingAndSortingRepository;
import org.springframework.stereotype.Repository;


/**
* Repository used to persist the project aggregate.
*
* @author sbegaudeau
*/
@Repository
public interface IProjectRepository extends ListPagingAndSortingRepository<Project, UUID>, ListCrudRepository<Project, UUID>, ProjectSearchRepository<Project, UUID> {
public interface IProjectRepository extends ListCrudRepository<Project, UUID>, ProjectSearchRepository<Project, UUID> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
*******************************************************************************/
package org.eclipse.sirius.web.domain.boundedcontexts.project.repositories;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

import org.eclipse.sirius.components.annotations.RepositoryFragment;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.eclipse.sirius.web.domain.boundedcontexts.project.Project;

/**
* Fragment interface used to search projects.
Expand All @@ -32,5 +33,7 @@ public interface ProjectSearchRepository<T, ID> {

Optional<T> findById(ID id);

Page<T> findAll(Pageable pageable);
List<Project> findAllBefore(UUID cursorProjectId, int limit);

List<Project> findAllAfter(UUID cursorProjectId, int limit);
}
Loading

0 comments on commit e5c5d89

Please sign in to comment.