From 124ec305d79f1bb8b343f62df904a769ff53ac77 Mon Sep 17 00:00:00 2001 From: Jose Castro Date: Fri, 10 Mar 2023 11:14:34 -0600 Subject: [PATCH] Fix #23870 : Add pagination info to the API response. (#24287) * #23870 : Add pagination info to the API response. * As per Freddy Montes' feedback, removing the unnecessary value for the `x-pagination-link-pages` header. * Implementing SonarQube feedback. --- .../UserResource.postman_collection.json | 28 ++--- .../main/java/com/dotcms/rest/EntityView.java | 18 ++- .../main/java/com/dotcms/rest/Pagination.java | 111 +++++++++++++++++ .../com/dotcms/rest/ResponseEntityView.java | 113 +++++++++++------- .../java/com/dotcms/util/PaginationUtil.java | 55 +++++++-- 5 files changed, 246 insertions(+), 79 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/rest/Pagination.java diff --git a/dotCMS/src/curl-test/UserResource.postman_collection.json b/dotCMS/src/curl-test/UserResource.postman_collection.json index e1ec8c74d5fd..de17637da0e8 100644 --- a/dotCMS/src/curl-test/UserResource.postman_collection.json +++ b/dotCMS/src/curl-test/UserResource.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "baa5cd8a-ee19-4f3e-9398-f28dcc176d02", + "_postman_id": "a9eac856-e8ee-47da-96a9-f2e68e81fd72", "name": "UserResource", "description": "Verifies that commonly-used routines for interacting with User data are working as expected. Most of these are related to filtering operations and for back-end use only.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", @@ -20,14 +20,6 @@ "", "var jsonData = pm.response.json().entity;", "", - "/*pm.test(\"Contains anonymous\", function () {", - " pm.expect(containsAttrAndValue(jsonData, \"userId\" ,\"anonymous\")).to.be.true;", - "});", - "", - "pm.test(\"Contains default\", function () {", - " pm.expect(containsAttrAndValue(jsonData, \"userId\" ,\"dotcms.org.default\")).to.be.true;", - "});*/", - "", "pm.test(\"Contains anonymous\", function () {", " containsAttrAndValue = eval(pm.collectionVariables.get(\"containsAttrAndValue\", containsAttrAndValue.toString()));", " pm.expect(containsAttrAndValue(jsonData, \"userId\" ,\"anonymous\")).to.be.true;", @@ -38,19 +30,13 @@ " pm.expect(containsAttrAndValue(jsonData, \"userId\" ,\"dotcms.org.default\")).to.be.true;", "});", "", - "", - "/*", - "function _isContains(json, keyname, value) {", - " return Object.keys(json).some(key => {", - " return typeof json[key] === 'object' ? ", - " _isContains(json[key], keyname, value) : key === keyname && json[key] === value;", - " });", - "}", - "", - "pm.test(\"Contains default\", function () {", - " pm.expect(containsUserId(jsonData, \"userId\" ,\"dotcms.org.default\")).to.be.true;", + "pm.test(\"Check pagination data\", function () {", + " var paginationData = pm.response.json().pagination;", + " pm.expect(paginationData.currentPage).to.equal(1);", + " pm.expect(paginationData.perPage).to.equal(100);", + " pm.expect(paginationData.totalEntries).to.equal(2);", "});", - "*/" + "" ], "type": "text/javascript" } diff --git a/dotCMS/src/main/java/com/dotcms/rest/EntityView.java b/dotCMS/src/main/java/com/dotcms/rest/EntityView.java index 1ea26bde9ef8..85224f7163a1 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/EntityView.java +++ b/dotCMS/src/main/java/com/dotcms/rest/EntityView.java @@ -9,13 +9,21 @@ */ public interface EntityView { - public List getErrors(); + List getErrors(); - public T getEntity(); + T getEntity(); - public List getMessages() ; + List getMessages() ; - public Map getI18nMessagesMap(); + Map getI18nMessagesMap(); + + List getPermissions(); + + /** + * Returns the pagination parameters associated to the current data request. + * + * @return The {@link Pagination} instance. + */ + Pagination getPagination(); - public List getPermissions(); } diff --git a/dotCMS/src/main/java/com/dotcms/rest/Pagination.java b/dotCMS/src/main/java/com/dotcms/rest/Pagination.java new file mode 100644 index 000000000000..217ae36fcccc --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/Pagination.java @@ -0,0 +1,111 @@ +package com.dotcms.rest; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import java.io.Serializable; + +/** + * Provides pagination data associated to the entity returned by any dotCMS REST Endpoint. This way, developers can + * still access pagination data even when accessing our APIs through proxies that may remove the already existing + * pagination headers. + * + * @author Jose Castro + * @since Mar 3rd, 2023 + */ +@JsonDeserialize(builder = Pagination.Builder.class) +public class Pagination implements Serializable { + + private final int currentPage; + private final int perPage; + private final long totalEntries; + + /** + * Private constructor used to create an instance of this class. + * + * @param builder The {@link Builder} class for the pagination object. + */ + private Pagination(final Builder builder) { + this.currentPage = builder.currentPage; + this.perPage = builder.perPage; + this.totalEntries = builder.totalEntries; + } + + public int getCurrentPage() { + return this.currentPage; + } + + public int getPerPage() { + return this.perPage; + } + + public long getTotalEntries() { + return this.totalEntries; + } + + @Override + public String toString() { + return "Pagination{" + "currentPage=" + this.currentPage + ", perPage=" + this.perPage + ", totalEntries=" + this.totalEntries + '}'; + } + + /** + * This builder allows you to create an instance of the {@link Pagination} class. + */ + public static class Builder { + + @JsonProperty + private int currentPage; + @JsonProperty + private int perPage; + @JsonProperty + private long totalEntries; + + /** + * Returns the currently selected results page, or the first one if not specified. + * + * @param currentPage The current results page. + * + * @return The current {@link Builder} instance. + */ + public Builder currentPage(int currentPage) { + this.currentPage = currentPage; + return this; + } + + /** + * The maximum number of items that are included in a results page. + * + * @param perPage The maximum number of returned items. + * + * @return The current {@link Builder} instance. + */ + public Builder perPage(int perPage) { + this.perPage = perPage; + return this; + } + + /** + * The total number of results for a given data query. That is, the total list of unfiltered items for a + * given query. + * + * @param totalEntries The total number of results. + * + * @return The current {@link Builder} instance. + */ + public Builder totalEntries(long totalEntries) { + this.totalEntries = totalEntries; + return this; + } + + /** + * Creates an instance of the {@link Pagination} class. + * + * @return A new instance of the {@link Pagination} class. + */ + public Pagination build() { + return new Pagination(this); + } + + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityView.java b/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityView.java index 21937187f475..92c7d635e9eb 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityView.java +++ b/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityView.java @@ -7,10 +7,14 @@ import java.util.Map; /** - * Response Entity View encapsulates the response to include errors and the entity to be returned as part of the Jersey response + * This class encapsulates the {@link javax.ws.rs.core.Response} object to include the expected entity and related + * information such as pagination parameters, errors, i18n messages, etc. for them to be returned as part of the Jersey + * response + * * @author jsanca + * @since Jul 7th, 2016 */ -public class ResponseEntityView implements EntityView, Serializable { +public class ResponseEntityView implements EntityView, Serializable { public static final String OK = "Ok"; @@ -21,120 +25,138 @@ public class ResponseEntityView implements EntityView, Ser private final List messages; private final Map i18nMessagesMap; private final List permissions; - + private final Pagination pagination; public ResponseEntityView(final List errors) { - this.errors = errors; - this.messages = Collections.EMPTY_LIST; + this.messages = Collections.emptyList(); this.entity = (T)EMPTY_ENTITY; - this.i18nMessagesMap = Collections.EMPTY_MAP; - this.permissions = Collections.EMPTY_LIST; + this.i18nMessagesMap = Collections.emptyMap(); + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final List errors, final Map i18nMessagesMap) { - this.errors = errors; - this.messages = Collections.EMPTY_LIST; + this.messages = Collections.emptyList(); this.entity = (T)EMPTY_ENTITY; this.i18nMessagesMap = i18nMessagesMap; - this.permissions = Collections.EMPTY_LIST; + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final List errors, final T entity) { - this.errors = errors; - this.messages = Collections.EMPTY_LIST; + this.messages = Collections.emptyList(); this.entity = entity; - this.i18nMessagesMap = Collections.EMPTY_MAP; - this.permissions = Collections.EMPTY_LIST; + this.i18nMessagesMap = Collections.emptyMap(); + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final List errors, final T entity, final Map i18nMessagesMap) { - this.errors = errors; - this.messages = Collections.EMPTY_LIST; + this.messages = Collections.emptyList(); this.entity = entity; this.i18nMessagesMap = i18nMessagesMap; - this.permissions = Collections.EMPTY_LIST; + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final T entity) { + this.errors = Collections.emptyList(); + this.messages = Collections.emptyList(); + this.entity = entity; + this.i18nMessagesMap = Collections.emptyMap(); + this.permissions = Collections.emptyList(); + this.pagination = null; + } - this.errors = Collections.EMPTY_LIST; - this.messages = Collections.EMPTY_LIST; + public ResponseEntityView(final T entity, final Pagination pagination) { + this.errors = Collections.emptyList(); + this.messages = Collections.emptyList(); this.entity = entity; - this.i18nMessagesMap = Collections.EMPTY_MAP; - this.permissions = Collections.EMPTY_LIST; + this.i18nMessagesMap = Collections.emptyMap(); + this.permissions = Collections.emptyList(); + this.pagination = pagination; } public ResponseEntityView(final T entity, final String... permissions) { - - this.errors = Collections.EMPTY_LIST; - this.messages = Collections.EMPTY_LIST; + this.errors = Collections.emptyList(); + this.messages = Collections.emptyList(); this.entity = entity; - this.i18nMessagesMap = Collections.EMPTY_MAP; + this.i18nMessagesMap = Collections.emptyMap(); this.permissions = Arrays.asList(permissions); + this.pagination = null; } public ResponseEntityView(final T entity, final Map i18nMessagesMap) { - - this.errors = Collections.EMPTY_LIST; - this.messages = Collections.EMPTY_LIST; + this.errors = Collections.emptyList(); + this.messages = Collections.emptyList(); this.entity = entity; this.i18nMessagesMap = i18nMessagesMap; - this.permissions = Collections.EMPTY_LIST; + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final T entity, final List messages) { - - this.errors = Collections.EMPTY_LIST; + this.errors = Collections.emptyList(); this.messages = messages; this.entity = entity; - this.i18nMessagesMap = Collections.EMPTY_MAP; - this.permissions = Collections.EMPTY_LIST; + this.i18nMessagesMap = Collections.emptyMap(); + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final T entity, final List messages, final Map i18nMessagesMap) { - - this.errors = Collections.EMPTY_LIST; + this.errors = Collections.emptyList(); this.messages = messages; this.entity = entity; this.i18nMessagesMap = i18nMessagesMap; - this.permissions = Collections.EMPTY_LIST; + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final T entity, final List errors, final List messages) { - this.errors = errors; this.messages = messages; this.entity = entity; - this.i18nMessagesMap = Collections.EMPTY_MAP; - this.permissions = Collections.EMPTY_LIST; + this.i18nMessagesMap = Collections.emptyMap(); + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final T entity, final List errors, final List messages, final Map i18nMessagesMap) { - this.errors = errors; this.messages = messages; this.entity = entity; this.i18nMessagesMap = i18nMessagesMap; - this.permissions = Collections.EMPTY_LIST; + this.permissions = Collections.emptyList(); + this.pagination = null; } public ResponseEntityView(final T entity, final List errors, final List messages, final Map i18nMessagesMap, final List permissions) { + this.errors = errors; + this.messages = messages; + this.entity = entity; + this.i18nMessagesMap = i18nMessagesMap; + this.permissions = permissions; + this.pagination = null; + } + public ResponseEntityView(final T entity, final List errors, final List messages, final Map i18nMessagesMap, final List permissions, final Pagination pagination) { this.errors = errors; this.messages = messages; this.entity = entity; this.i18nMessagesMap = i18nMessagesMap; this.permissions = permissions; + this.pagination = pagination; } public List getErrors() { @@ -153,11 +175,14 @@ public Map getI18nMessagesMap() { return i18nMessagesMap; } - public List getPermissions() { return permissions; } + public Pagination getPagination() { + return this.pagination; + } + @Override public String toString() { return "ResponseEntityView{" + @@ -165,6 +190,8 @@ public String toString() { ", entity=" + entity + ", messages=" + messages + ", i18nMessagesMap=" + i18nMessagesMap + + ", pagination=" + this.pagination + '}'; } -} // E:O:F:ResponseEntityView. + +} diff --git a/dotCMS/src/main/java/com/dotcms/util/PaginationUtil.java b/dotCMS/src/main/java/com/dotcms/util/PaginationUtil.java index a46ce7a627c0..3e8bf6f00900 100644 --- a/dotCMS/src/main/java/com/dotcms/util/PaginationUtil.java +++ b/dotCMS/src/main/java/com/dotcms/util/PaginationUtil.java @@ -1,5 +1,6 @@ package com.dotcms.util; +import com.dotcms.rest.Pagination; import com.dotcms.rest.ResponseEntityView; import com.dotcms.util.pagination.OrderDirection; import com.dotcms.util.pagination.Paginator; @@ -193,16 +194,8 @@ public Response getPage(final HttpServletRequest req, final User user, fi new LinkHeader.Builder().baseUrl(req.getRequestURI()).filter(sanitizeFilter).page(pageValue) .perPage(perPageValue).totalRecords(totalRecords).orderBy(orderBy).direction(direction) .extraParams(extraParams).build(); - final String linkHeaderValue = this.getHeaderValue(linkHeader); final Object paginatedItems = null != function ? function.apply(items) : items; - return Response. - ok(new ResponseEntityView<>(paginatedItems)) - .header(LINK_HEADER_NAME, linkHeaderValue) - .header(PAGINATION_PER_PAGE_HEADER_NAME, perPageValue) - .header(PAGINATION_CURRENT_PAGE_HEADER_NAME, pageValue) - .header(PAGINATION_MAX_LINK_PAGES_HEADER_NAME, nLinks) - .header(PAGINATION_TOTAL_ENTRIES_HEADER_NAME, totalRecords) - .build(); + return this.createResponse(paginatedItems, linkHeader, pageValue, perPageValue, totalRecords); } /** @@ -383,7 +376,49 @@ private static String getUrl(final String baseUrl, final String filter, final in } /** - * Contains the required information for generating the value of the {@code Link} header. + * Creates an instance of the {@link Response} class including both the Entity -- i.e., the list of queried + * objects -- and an additional attribute containing the pagination attributes for the UI layer to handle it. This + * is meant to solve a problem in which certain proxies may remove the already existing pagination headers. + * + * @param paginatedItems The list of results that are being returned based on the specified pagination parameters. + * @param linkHeader The {@link LinkHeader} object with the information for generating the main navigation + * links. + * @param pageValue The currently selected page. + * @param perPageValue The maximum number of items returned in a given page. + * @param totalRecords The total number of unfiltered items returned by the query. + * + * @return The expected {@link Response} object. + */ + private Response createResponse(final Object paginatedItems, final LinkHeader linkHeader, final int pageValue, + final int perPageValue, final long totalRecords) { + final String linkHeaderValue = this.getHeaderValue(linkHeader); + final Pagination pagination = + new Pagination.Builder() + .currentPage(pageValue) + .perPage(perPageValue) + .totalEntries(totalRecords).build(); + return Response.ok(new ResponseEntityView<>(paginatedItems, pagination)) + .header(LINK_HEADER_NAME, linkHeaderValue) + .header(PAGINATION_PER_PAGE_HEADER_NAME, perPageValue) + .header(PAGINATION_CURRENT_PAGE_HEADER_NAME, pageValue) + .header(PAGINATION_MAX_LINK_PAGES_HEADER_NAME, nLinks) + .header(PAGINATION_TOTAL_ENTRIES_HEADER_NAME, totalRecords).build(); + } + + /** + * Contains the required information for generating the value of the {@code Link} HTTP Header used by the UI layer. + * There are three values that can be generated: + *
    + *
  • The link to get the first results page.
  • + *
  • The link to get the last results page.
  • + *
  • The link to get the a specific results page.
  • + *
+ * For instance, the value of this header may look like this: + *

{@code + * ;rel="first", + * ;rel="last", + * ;rel="x-page" + * } */ protected static class LinkHeader {