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 <[email protected]>
  • Loading branch information
AxelRICHARD committed Nov 25, 2024
1 parent bcfad1b commit b859b37
Show file tree
Hide file tree
Showing 16 changed files with 346 additions and 64 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ 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
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 data of `GET /api/rest/projects` or `GET /api/rest/projects/{projectId}`.

=== Improvements

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
*******************************************************************************/
package org.eclipse.sirius.web.application.project.controllers;

import java.net.URI;
import java.time.Instant;
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 +32,13 @@
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.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.ResponseEntity.HeadersBuilder;
import org.springframework.http.server.ServerHttpRequest;
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 +47,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 +61,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 +72,23 @@ 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()) {
position = ScrollPosition.forward(Map.of("id", pageAfter.get()));
} else if (pageBefore.isPresent() && pageAfter.isEmpty()) {
position = ScrollPosition.backward(Map.of("id", pageBefore.get()));
} else if (pageBefore.isPresent() && pageAfter.isPresent()) {
position = ScrollPosition.keyset();
} else {
position = ScrollPosition.keyset();
}
int limit = pageSize.orElse(DEFAULT_PAGE_SIZE);
var restProjects = this.projectApplicationService.findAll(position, limit)
.map(project -> new RestProject(project.id(), DEFAULT_CREATED, new Identified(project.id()), null, project.name()))
.toList();

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

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

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

private MultiValueMap<String, String> handleLinkResponseHeader(ServerHttpRequest request, List<RestProject> projects, KeysetScrollPosition position, int limit) {
MultiValueMap<String, String>
UriComponents uriComponents = ServletUriComponentsBuilder.fromCurrentRequestUri().build();
uriComponents.toUriString()
int projectsSize = projects.size();
if (projectsSize > 0 && position.scrollsForward()) {
boolean hasNext = projectsSize > limit;
if (hasNext) {
var lastProject = projects.get(Math.min(projectsSize - 1, limit - 1));
var cursorId = new Relay().toGlobalId("Project", lastProject.id().toString());
URI uri = request.getURI();
var headerValue = new StringBuilder();
headerValue.append("<");
headerValue.append(uri.toString());
headerValue.append("/projects");
headerValue.append("?page[after]=");
headerValue.append(cursorId);
headerValue.append("&page[size]=");
headerValue.append(limit);
headerValue.append(">;");
headerValue.append("rel = \"next\"");
request.getHeaders().add("link", headerValue.toString());
}
} else if (projectsSize > 0 && position.scrollsBackward()) {
boolean hasPrevious = projectsSize > limit;
if (hasPrevious) {
var lastProject = projects.get(Math.min(projectsSize - 1, limit - 1));
var cursorId = new Relay().toGlobalId("Project", lastProject.id().toString());
URI uri = request.getURI();
var headerValue = new StringBuilder();
headerValue.append("<");
headerValue.append(uri.toString());
headerValue.append("/projects");
headerValue.append("?page[before]=");
headerValue.append(cursorId);
headerValue.append("&page[size]=");
headerValue.append(limit);
headerValue.append(">;");
headerValue.append("rel = \"prev\"");
request.getHeaders().add("link", headerValue.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,13 @@
@QueryDataFetcher(type = "Viewer", field = "projects")
public class ViewerProjectsDataFetcher implements IDataFetcherWithFieldCoordinates<Connection<ProjectDTO>> {

private static final String PAGE_ARGUMENT = "page";
private static final String FIRST_ARGUMENT = "first";

private static final String LIMIT_ARGUMENT = "limit";
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 +58,48 @@ 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)
int first = Optional.<Integer> ofNullable(environment.getArgument(FIRST_ARGUMENT))
.filter(firstArgument -> firstArgument > 0)
.orElse(0);
int limit = Optional.<Integer> ofNullable(environment.getArgument(LIMIT_ARGUMENT))
.filter(limitArgument -> limitArgument > 0)
.orElse(20);
int last = Optional.<Integer> ofNullable(environment.getArgument(LAST_ARGUMENT))
.filter(lastArgument -> lastArgument > 0)
.orElse(0);
Optional<String> after = Optional.<String> ofNullable(environment.getArgument(AFTER_ARGUMENT));
Optional<String> before = Optional.<String> ofNullable(environment.getArgument(BEFORE_ARGUMENT));

var pageable = PageRequest.of(page, limit);
var projectPage = this.projectApplicationService.findAll(pageable);
return this.toConnection(projectPage);
final KeysetScrollPosition position;
final int limit;
if (after.isPresent() && before.isEmpty() && first > 0) {
var projectId = after.get();
var cursorProjectId = new Relay().fromGlobalId(projectId).getId();
position = ScrollPosition.forward(Map.of("id", cursorProjectId));
limit = first;
} else if (before.isPresent() && after.isEmpty() && last > 0) {
var projectId = before.get();
var cursorProjectId = new Relay().fromGlobalId(projectId).getId();
position = ScrollPosition.backward(Map.of("id", cursorProjectId));
limit = last;
} else if (before.isPresent() && after.isPresent()) {
position = ScrollPosition.keyset();
if (first > 0) {
limit = first;
} else {
limit = 20;
}
} else {
position = ScrollPosition.keyset();
if (first > 0) {
limit = first;
} else {
limit = 20;
}
}

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 +113,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 @@ -12,18 +12,61 @@
*******************************************************************************/
package org.eclipse.sirius.web.domain.boundedcontexts.project.repositories;

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

import org.eclipse.sirius.web.domain.boundedcontexts.project.Project;
import org.springframework.data.jdbc.repository.query.Query;
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> {
@Query("""
select
p.*
from
project p
where
(cast(:cursorProjectId as uuid) is null
or p.created_on < (
select
created_on
from
project
where
project.id = :cursorProjectId)
)
order by
p.created_on desc
limit :limit;
""")
List<Project> findAllBefore(UUID cursorProjectId, int limit);

@Query("""
select
p.*
from
project p
where
(cast(:cursorProjectId as uuid) is null
or p.created_on >= (
select
created_on
from
project
where
project.id = :cursorProjectId)
)
order by
p.created_on asc
limit :limit;
""")
List<Project> findAllAfter(UUID cursorProjectId, int limit);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
import java.util.Optional;

import org.eclipse.sirius.components.annotations.RepositoryFragment;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.KeysetScrollPosition;
import org.springframework.data.domain.Window;

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

Optional<T> findById(ID id);

Page<T> findAll(Pageable pageable);
Window<T> findAll(KeysetScrollPosition position, int limit);
}
Loading

0 comments on commit b859b37

Please sign in to comment.