From f26b313cd1d544f5f25cbe8fd7bbabf3d76dd8fc Mon Sep 17 00:00:00 2001 From: Jose Castro Date: Thu, 28 Nov 2024 21:08:17 -0600 Subject: [PATCH 1/3] Initial code changes --- .../content/ContentAnalyticsQuery.java | 177 ++++++++++++++++++ .../content/ContentAnalyticsResource.java | 111 +++++++++-- 2 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java diff --git a/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java b/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java new file mode 100644 index 000000000000..a04de858219b --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java @@ -0,0 +1,177 @@ +package com.dotcms.analytics.content; + +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import javax.annotation.Nonnull; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * + * @author Jose Castro + * @since Nov 28th, 2024 + */ +@JsonDeserialize(builder = ContentAnalyticsQuery.Builder.class) +public class ContentAnalyticsQuery implements Serializable { + + @JsonProperty() + private final Set measures; + @JsonProperty() + private final Set dimensions; + @JsonProperty() + private final List> timeDimensions; + @JsonProperty() + private final List> filters; + @JsonProperty() + private final List order; + @JsonProperty() + private final int limit; + @JsonProperty() + private final int offset; + + private static final String SEPARATOR = ":"; + + private ContentAnalyticsQuery(final Builder builder) { + this.measures = builder.measures; + this.dimensions = builder.dimensions; + this.timeDimensions = builder.timeDimensions; + this.filters = builder.filters; + this.order = builder.order; + this.limit = builder.limit; + this.offset = builder.offset; + } + + public Set measures() { + return this.measures; + } + + public Set dimensions() { + return this.dimensions; + } + + public List> timeDimensions() { + return this.timeDimensions; + } + + public List> filters() { + return this.filters; + } + + public List order() { + return this.order; + } + + public int limit() { + return this.limit; + } + + public int offset() { + return this.offset; + } + + public static ContentAnalyticsQuery.Builder builder() { + return new Builder(); + } + + @Override + public String toString() { + return "ContentAnalyticsQuery{" + + "measures='" + measures + '\'' + + ", dimensions='" + dimensions + '\'' + + ", timeDimensions='" + timeDimensions + '\'' + + ", filters='" + filters + '\'' + + ", order='" + order + '\'' + + ", limit='" + limit + '\'' + + ", offset='" + offset + '\'' + + '}'; + } + + public static class Builder { + + private Set measures; + private Set dimensions; + private final List> timeDimensions = new ArrayList<>(); + private final List> filters = new ArrayList<>(); + private final List order = new ArrayList<>(); + private int limit = 1000; + private int offset = 0; + + public Builder measures(@Nonnull final String measures) { + this.measures = Set.of(measures.split("\\s+")); + return this; + } + + public Builder dimensions(@Nonnull final String dimensions) { + this.dimensions = Set.of(dimensions.split("\\s+")); + return this; + } + + public Builder timeDimensions(final String timeDimensions) { + if (UtilMethods.isNotSet(timeDimensions)) { + return this; + } + final String[] timeParams = timeDimensions.split(SEPARATOR); + final Map timeDimensionsData = new HashMap<>(); + timeDimensionsData.put("dimension", timeParams[0]); + if (timeParams.length > 1) { + timeDimensionsData.put("dateRange", timeParams[1]); + } else { + timeDimensionsData.put("dateRange", "Last week"); + } + this.timeDimensions.add(timeDimensionsData); + return this; + } + + public Builder filters(final String filters) { + if (UtilMethods.isNotSet(filters)) { + return this; + } + final String[] filterArr = filters.split(SEPARATOR); + for (final String filter : filterArr) { + final String[] filterParams = filter.split("\\s+"); + final Map filterDataMap = new HashMap<>(); + filterDataMap.put("member", filterParams[0]); + filterDataMap.put("operator", filterParams[1]); + final String[] filterValues = filterParams[2].split(","); + filterDataMap.put("values", filterValues); + this.filters.add(filterDataMap); + } + return this; + } + + public Builder order(final String order) { + if (UtilMethods.isNotSet(order)) { + return this; + } + final Set orderCriteria = Set.of(order.split(SEPARATOR)); + for (final String orderCriterion : orderCriteria) { + final String[] orderParams = orderCriterion.split("\\s+"); + this.order.add(orderParams); + } + return this; + } + + public Builder limit(final int limit) { + this.limit = limit; + return this; + } + + public Builder offset(final int offset) { + this.offset = offset; + return this; + } + + public ContentAnalyticsQuery build() { + return new ContentAnalyticsQuery(this); + } + + } + +} + diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java index 16cea9eaafc9..95bf06afef13 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java @@ -1,10 +1,12 @@ package com.dotcms.rest.api.v1.analytics.content; import com.dotcms.analytics.content.ContentAnalyticsAPI; +import com.dotcms.analytics.content.ContentAnalyticsQuery; import com.dotcms.analytics.content.ReportResponse; import com.dotcms.analytics.model.ResultSetItem; import com.dotcms.analytics.track.collectors.Collector; import com.dotcms.analytics.track.collectors.EventSource; +import com.dotcms.analytics.track.collectors.EventType; import com.dotcms.analytics.track.collectors.WebEventsCollectorServiceFactory; import com.dotcms.analytics.track.matchers.FilesRequestMatcher; import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; @@ -18,12 +20,14 @@ import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; import com.dotcms.util.DotPreconditions; +import com.dotcms.util.JsonUtil; import com.dotmarketing.beans.Host; import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDUtil; +import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.annotations.VisibleForTesting; import com.liferay.portal.model.User; import io.swagger.v3.oas.annotations.Operation; @@ -31,25 +35,25 @@ import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import java.io.Serializable; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.function.Supplier; -import java.util.stream.Collectors; +import io.vavr.Lazy; +import org.glassfish.jersey.server.JSONP; + import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.Consumes; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; - -import io.vavr.Lazy; -import org.glassfish.jersey.server.JSONP; -import com.dotcms.analytics.track.collectors.EventType; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Collectors; /** * Resource class that exposes endpoints to query content analytics data. @@ -63,7 +67,7 @@ @Path("/v1/analytics/content") @Tag(name = "Content Analytics", - description = "Endpoints that exposes information related to how dotCMS content is accessed and interacted with by users.") + description = "This REST Endpoint exposes information related to how dotCMS content is accessed and interacted with by users.") public class ContentAnalyticsResource { private static final UserCustomDefinedRequestMatcher USER_CUSTOM_DEFINED_REQUEST_MATCHER = new UserCustomDefinedRequestMatcher(); @@ -102,7 +106,9 @@ public ContentAnalyticsResource(final WebResource webResource, @Operation( operationId = "postContentAnalyticsQuery", summary = "Retrieve Content Analytics data", - description = "Returns information of specific dotCMS objects whose health and engagement data is tracked.", + description = "Returns information of specific dotCMS objects whose health and " + + "engagement data is tracked. This method takes a specific less verbose JSON " + + "format to query the data.", tags = {"Content Analytics"}, responses = { @ApiResponse(responseCode = "200", description = "Content Analytics data being queried", @@ -169,10 +175,12 @@ public ReportResponseEntityView query(@Context final HttpServletRequest request, @Operation( operationId = "postContentAnalyticsQuery", summary = "Retrieve Content Analytics data", - description = "Returns information of specific dotCMS objects whose health and engagement data is tracked.", + description = "Returns information of specific dotCMS objects whose health and " + + "engagement data is tracked, using a CubeJS JSON query.", tags = {"Content Analytics"}, responses = { - @ApiResponse(responseCode = "200", description = "Content Analytics data being queried", + @ApiResponse(responseCode = "200", description = "Content Analytics data " + + "being queried", content = @Content(mediaType = "application/json", examples = { @ExampleObject( @@ -215,6 +223,81 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re return new ReportResponseEntityView(reportResponse.getResults().stream().map(ResultSetItem::getAll).collect(Collectors.toList())); } + + /** + * Query Content Analytics data. + * + * @param request the HTTP request. + * @param response the HTTP response. + * @param cubeJsQueryJson the query form. + * @return the report response entity view. + */ + @Operation( + operationId = "postContentAnalyticsQuery", + summary = "Retrieve Content Analytics data", + description = "Returns information of specific dotCMS objects whose health and " + + "engagement data is tracked, using a CubeJS JSON query.", + tags = {"Content Analytics"}, + responses = { + @ApiResponse(responseCode = "200", description = "Content Analytics data " + + "being queried", + content = @Content(mediaType = "application/json", + examples = { + @ExampleObject( + value = "{\n" + + " \"dimensions\": [\n" + + " \"Events.experiment\",\n" + + " \"Events.variant\"\n" + + " ]\n" + + "}" + ) + } + ) + ), + @ApiResponse(responseCode = "400", description = "Bad Request"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) + @POST + @Path("/query/{params:.*}") + @JSONP + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + public ReportResponseEntityView query(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final @PathParam("params") String params) throws JsonProcessingException { + final InitDataObject initDataObject = new WebResource.InitBuilder(this.webResource) + .requestAndResponse(request, response) + .params(params) + .requiredBackendUser(true) + .rejectWhenNoUser(true) + .init(); + final User user = initDataObject.getUser(); + final Map paramsMap = initDataObject.getParamsMap(); + final ContentAnalyticsQuery.Builder builder = new ContentAnalyticsQuery.Builder() + .dimensions(paramsMap.get("dimensions")) + .measures(paramsMap.get("measures")) + .filters(paramsMap.get("filters")) + .order(paramsMap.get("order")) + .timeDimensions(paramsMap.get("timedimensions")); + if (paramsMap.containsKey("limit")) { + builder.limit(Integer.parseInt(paramsMap.get("limit"))); + } + if (paramsMap.containsKey("offset")) { + builder.offset(Integer.parseInt(paramsMap.get("offset"))); + } + final ContentAnalyticsQuery contentAnalyticsQuery = builder.build(); + final String cubeJsQuery = JsonUtil.getJsonStringFromObject(contentAnalyticsQuery); + + Logger.debug(this, () -> "Querying content analytics data with parameters: " + paramsMap); + final ReportResponse reportResponse = this.contentAnalyticsAPI.runRawReport(cubeJsQuery, + user); + return new ReportResponseEntityView(reportResponse.getResults().stream() + .map(ResultSetItem::getAll).collect(Collectors.toList())); + } + /** * Fire an user custom event. * From 9a4c159470d20da08aa38601371f085361afbd35 Mon Sep 17 00:00:00 2001 From: Jose Castro Date: Fri, 29 Nov 2024 14:16:09 -0600 Subject: [PATCH 2/3] feat(content analytics) fixes #30521 : Allow users to pass down simple Strings to query for Content Analytics data --- .../content/ContentAnalyticsQuery.java | 115 ++++++- .../content/ContentAnalyticsResource.java | 89 ++++-- .../Content_Analytics.postman_collection.json | 281 +++++++++++++----- 3 files changed, 369 insertions(+), 116 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java b/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java index a04de858219b..065ed1f3dabd 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import javax.annotation.Nonnull; import java.io.Serializable; import java.util.ArrayList; import java.util.HashMap; @@ -12,7 +11,13 @@ import java.util.Map; import java.util.Set; +import static com.liferay.util.StringPool.COLON; +import static com.liferay.util.StringPool.COMMA; + /** + * This class represents the parameters of a Content Analytics Query abstracting the complexity + * of the underlying JSON format. The simplified REST Endpoint and the Content Analytics ViewTool + * use this class so that parameters can be entered in a more user-friendly way. * * @author Jose Castro * @since Nov 28th, 2024 @@ -20,6 +25,19 @@ @JsonDeserialize(builder = ContentAnalyticsQuery.Builder.class) public class ContentAnalyticsQuery implements Serializable { + public static final String MEASURES_ATTR = "measures"; + public static final String DIMENSIONS_ATTR = "dimensions"; + public static final String TIME_DIMENSIONS_ATTR = "timeDimensions"; + public static final String FILTERS_ATTR = "filters"; + public static final String ORDER_ATTR = "order"; + public static final String LIMIT_ATTR = "limit"; + public static final String OFFSET_ATTR = "offset"; + public static final String GRANULARITY_ATTR = "granularity"; + public static final String DATE_RANGE_ATTR = "dateRange"; + public static final String MEMBER_ATTR = "member"; + public static final String OPERATOR_ATTR = "operator"; + public static final String VALUES_ATTR = "values"; + @JsonProperty() private final Set measures; @JsonProperty() @@ -35,7 +53,7 @@ public class ContentAnalyticsQuery implements Serializable { @JsonProperty() private final int offset; - private static final String SEPARATOR = ":"; + private static final String SEPARATOR = COLON; private ContentAnalyticsQuery(final Builder builder) { this.measures = builder.measures; @@ -92,6 +110,10 @@ public String toString() { '}'; } + /** + * This builder creates the appropriate data structures that match the JSON format of the final + * CubeJS query. + */ public static class Builder { private Set measures; @@ -102,32 +124,71 @@ public static class Builder { private int limit = 1000; private int offset = 0; - public Builder measures(@Nonnull final String measures) { + /** + * The measures parameter contains a set of measures and each measure is an aggregation over + * a certain column in your ClickHouse database table. + * + * @param measures A string with the measures separated by a space. + * + * @return The builder instance. + */ + public Builder measures(final String measures) { this.measures = Set.of(measures.split("\\s+")); return this; } - public Builder dimensions(@Nonnull final String dimensions) { + /** + * The dimensions property contains a set of dimensions. You can think about a dimension as + * an attribute related to a measure, e.g. the measure user_count can have dimensions like + * country, age, occupation, etc. + * + * @param dimensions A string with the dimensions separated by a space. + * + * @return The builder instance. + */ + public Builder dimensions(final String dimensions) { this.dimensions = Set.of(dimensions.split("\\s+")); return this; } + /** + * Time dimensions provide a convenient way to specify a time dimension with a filter. It is + * an array of objects in timeDimension format. If no date range is provided, the default + * value will be "Last week". + * + * @param timeDimensions A string with the time dimensions separated by a colon. + * + * @return The builder instance. + */ public Builder timeDimensions(final String timeDimensions) { if (UtilMethods.isNotSet(timeDimensions)) { return this; } final String[] timeParams = timeDimensions.split(SEPARATOR); final Map timeDimensionsData = new HashMap<>(); - timeDimensionsData.put("dimension", timeParams[0]); - if (timeParams.length > 1) { - timeDimensionsData.put("dateRange", timeParams[1]); + timeDimensionsData.put(DIMENSIONS_ATTR, timeParams[0]); + if (timeParams.length > 2) { + timeDimensionsData.put(GRANULARITY_ATTR, timeParams[1]); + timeDimensionsData.put(DATE_RANGE_ATTR, timeParams[2]); + } else if (timeParams.length > 1) { + timeDimensionsData.put(DATE_RANGE_ATTR, timeParams[1]); } else { - timeDimensionsData.put("dateRange", "Last week"); + timeDimensionsData.put(DATE_RANGE_ATTR, "Last week"); } this.timeDimensions.add(timeDimensionsData); return this; } + /** + * Filters are applied differently to dimensions and measures. When you filter on a + * dimension, you are restricting the raw data before any calculations are made. When you + * filter on a measure, you are restricting the results after the measure has been + * calculated. They are composed of: member, operator, and values. + * + * @param filters A string with the filters separated by a colon. + * + * @return The builder instance. + */ public Builder filters(final String filters) { if (UtilMethods.isNotSet(filters)) { return this; @@ -136,15 +197,25 @@ public Builder filters(final String filters) { for (final String filter : filterArr) { final String[] filterParams = filter.split("\\s+"); final Map filterDataMap = new HashMap<>(); - filterDataMap.put("member", filterParams[0]); - filterDataMap.put("operator", filterParams[1]); - final String[] filterValues = filterParams[2].split(","); - filterDataMap.put("values", filterValues); + filterDataMap.put(MEMBER_ATTR, filterParams[0]); + filterDataMap.put(OPERATOR_ATTR, filterParams[1]); + final String[] filterValues = filterParams[2].split(COMMA); + filterDataMap.put(VALUES_ATTR, filterValues); this.filters.add(filterDataMap); } return this; } + /** + * This is an object where the keys are measures or dimensions to order by and their + * corresponding values are either asc or desc. The order of the fields to order on is based + * on the order of the keys in the object. If not provided, default ordering is applied. If + * an empty object ([]) is provided, no ordering is applied. + * + * @param order A string with the order separated by a colon. + * + * @return The builder instance. + */ public Builder order(final String order) { if (UtilMethods.isNotSet(order)) { return this; @@ -157,16 +228,35 @@ public Builder order(final String order) { return this; } + /** + * A row limit for your query. + * + * @param limit The number of rows to limit the query. The default value is 1000. + * + * @return The builder instance. + */ public Builder limit(final int limit) { this.limit = limit; return this; } + /** + * The number of initial rows to be skipped for your query. The default value is 0. + * + * @param offset The number of rows to skip. + * + * @return The builder instance. + */ public Builder offset(final int offset) { this.offset = offset; return this; } + /** + * This method builds the ContentAnalyticsQuery object. + * + * @return The ContentAnalyticsQuery object. + */ public ContentAnalyticsQuery build() { return new ContentAnalyticsQuery(this); } @@ -174,4 +264,3 @@ public ContentAnalyticsQuery build() { } } - diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java index 95bf06afef13..fbdaeedbd22d 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java @@ -19,7 +19,6 @@ import com.dotcms.rest.ResponseEntityStringView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; -import com.dotcms.util.DotPreconditions; import com.dotcms.util.JsonUtil; import com.dotmarketing.beans.Host; import com.dotmarketing.business.web.WebAPILocator; @@ -27,6 +26,7 @@ import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.annotations.VisibleForTesting; import com.liferay.portal.model.User; @@ -55,6 +55,16 @@ import java.util.function.Supplier; import java.util.stream.Collectors; +import static com.dotcms.analytics.content.ContentAnalyticsQuery.DIMENSIONS_ATTR; +import static com.dotcms.analytics.content.ContentAnalyticsQuery.FILTERS_ATTR; +import static com.dotcms.analytics.content.ContentAnalyticsQuery.LIMIT_ATTR; +import static com.dotcms.analytics.content.ContentAnalyticsQuery.MEASURES_ATTR; +import static com.dotcms.analytics.content.ContentAnalyticsQuery.OFFSET_ATTR; +import static com.dotcms.analytics.content.ContentAnalyticsQuery.ORDER_ATTR; +import static com.dotcms.analytics.content.ContentAnalyticsQuery.TIME_DIMENSIONS_ATTR; +import static com.dotcms.util.DotPreconditions.checkArgument; +import static com.dotcms.util.DotPreconditions.checkNotNull; + /** * Resource class that exposes endpoints to query content analytics data. * This REST Endpoint exposes different operations to query Content Analytics data. Content @@ -157,7 +167,7 @@ public ReportResponseEntityView query(@Context final HttpServletRequest request, .init(); final User user = initDataObject.getUser(); - DotPreconditions.checkNotNull(queryForm, IllegalArgumentException.class, "The 'query' JSON data cannot be null"); + checkNotNull(queryForm, IllegalArgumentException.class, "The 'query' JSON data cannot be null"); Logger.debug(this, () -> "Querying content analytics data with the form: " + queryForm); final ReportResponse reportResponse = this.contentAnalyticsAPI.runReport(queryForm.getQuery(), user); @@ -195,7 +205,7 @@ public ReportResponseEntityView query(@Context final HttpServletRequest request, ) ), @ApiResponse(responseCode = "400", description = "Bad Request"), - @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -216,7 +226,7 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re .init(); final User user = initDataObject.getUser(); - DotPreconditions.checkNotNull(cubeJsQueryJson, IllegalArgumentException.class, "The 'query' JSON data cannot be null"); + checkNotNull(cubeJsQueryJson, IllegalArgumentException.class, "The 'query' JSON data cannot be null"); Logger.debug(this, ()->"Querying content analytics data with the cube query json: " + cubeJsQueryJson); final ReportResponse reportResponse = this.contentAnalyticsAPI.runRawReport(cubeJsQueryJson, user); @@ -225,18 +235,23 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re /** - * Query Content Analytics data. + * Returns information of specific dotCMS objects whose health and engagement data is tracked, + * using Path Parameters instead of a CubeJS JSON query. This helps abstract the complexity of + * the underlying JSON format for users that need an easier way to query for specific data. * - * @param request the HTTP request. - * @param response the HTTP response. - * @param cubeJsQueryJson the query form. - * @return the report response entity view. + * @param request The current instance of the {@link HttpServletRequest} object. + * @param response The current instance of the {@link HttpServletResponse} object. + * @param params The query parameters provided in the URL path. + * + * @return The request information from the Content Analytics server. */ @Operation( operationId = "postContentAnalyticsQuery", summary = "Retrieve Content Analytics data", description = "Returns information of specific dotCMS objects whose health and " + - "engagement data is tracked, using a CubeJS JSON query.", + "engagement data is tracked, using Path Parameters instead of a CubeJS JSON " + + "query. This helps abstract the complexity of the underlying JSON format for " + + "users that need an easier way to query for specific data.", tags = {"Content Analytics"}, responses = { @ApiResponse(responseCode = "200", description = "Content Analytics data " + @@ -244,18 +259,23 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re content = @Content(mediaType = "application/json", examples = { @ExampleObject( - value = "{\n" + - " \"dimensions\": [\n" + - " \"Events.experiment\",\n" + - " \"Events.variant\"\n" + - " ]\n" + - "}" + value = "http://localhost:8080/api/v1" + + "/analytics/content/query/measures" + + "/request.count request" + + ".totalSessions/dimensions/request" + + ".host request.whatAmI request" + + ".url/timeDimensions/request" + + ".createdAt:day:This " + + "month/filters/request.totalRequest " + + "gt 0:request.whatAmI contains PAGE," + + "FILE/order/request.count asc:request" + + ".createdAt asc/limit/5/offset/0" ) } ) ), @ApiResponse(responseCode = "400", description = "Bad Request"), - @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -276,26 +296,29 @@ public ReportResponseEntityView query(@Context final HttpServletRequest request, .init(); final User user = initDataObject.getUser(); final Map paramsMap = initDataObject.getParamsMap(); + Logger.debug(this, () -> "Querying content analytics data with the following parameters: " + paramsMap); + checkArgument(!(UtilMethods.isNotSet(paramsMap.get(MEASURES_ATTR)) + && UtilMethods.isNotSet(paramsMap.get(DIMENSIONS_ATTR)) + && UtilMethods.isNotSet(paramsMap.get(TIME_DIMENSIONS_ATTR.toLowerCase()))), + IllegalArgumentException.class, "Query should contain either measures, dimensions or timeDimensions with granularities in order to be valid"); final ContentAnalyticsQuery.Builder builder = new ContentAnalyticsQuery.Builder() - .dimensions(paramsMap.get("dimensions")) - .measures(paramsMap.get("measures")) - .filters(paramsMap.get("filters")) - .order(paramsMap.get("order")) - .timeDimensions(paramsMap.get("timedimensions")); - if (paramsMap.containsKey("limit")) { - builder.limit(Integer.parseInt(paramsMap.get("limit"))); + .measures(paramsMap.get(MEASURES_ATTR)) + .dimensions(paramsMap.get(DIMENSIONS_ATTR)) + .timeDimensions(paramsMap.get(TIME_DIMENSIONS_ATTR.toLowerCase())) + .filters(paramsMap.get(FILTERS_ATTR)) + .order(paramsMap.get(ORDER_ATTR)); + if (paramsMap.containsKey(LIMIT_ATTR)) { + builder.limit(Integer.parseInt(paramsMap.get(LIMIT_ATTR))); } - if (paramsMap.containsKey("offset")) { - builder.offset(Integer.parseInt(paramsMap.get("offset"))); + if (paramsMap.containsKey(OFFSET_ATTR)) { + builder.offset(Integer.parseInt(paramsMap.get(OFFSET_ATTR))); } final ContentAnalyticsQuery contentAnalyticsQuery = builder.build(); final String cubeJsQuery = JsonUtil.getJsonStringFromObject(contentAnalyticsQuery); - - Logger.debug(this, () -> "Querying content analytics data with parameters: " + paramsMap); - final ReportResponse reportResponse = this.contentAnalyticsAPI.runRawReport(cubeJsQuery, - user); - return new ReportResponseEntityView(reportResponse.getResults().stream() - .map(ResultSetItem::getAll).collect(Collectors.toList())); + Logger.debug(this, ()-> "Generated query: " + cubeJsQuery); + final ReportResponse reportResponse = this.contentAnalyticsAPI.runRawReport(cubeJsQuery, user); + return new ReportResponseEntityView(reportResponse.getResults() + .stream().map(ResultSetItem::getAll).collect(Collectors.toList())); } /** @@ -336,7 +359,7 @@ public ResponseEntityStringView fireUserCustomEvent(@Context final HttpServletRe @Context final HttpServletResponse response, final Map userEventPayload) throws DotSecurityException { - DotPreconditions.checkNotNull(userEventPayload, IllegalArgumentException.class, "The 'userEventPayload' JSON cannot be null"); + checkNotNull(userEventPayload, IllegalArgumentException.class, "The 'userEventPayload' JSON cannot be null"); if (userEventPayload.containsKey(Collector.EVENT_SOURCE)) { throw new IllegalArgumentException("The 'event_source' field is reserved and cannot be used"); } diff --git a/dotcms-postman/src/main/resources/postman/Content_Analytics.postman_collection.json b/dotcms-postman/src/main/resources/postman/Content_Analytics.postman_collection.json index c9fd53d7dd8f..0c56b2927967 100644 --- a/dotcms-postman/src/main/resources/postman/Content_Analytics.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/Content_Analytics.postman_collection.json @@ -1,95 +1,236 @@ { "info": { - "_postman_id": "b0022550-05d2-4e69-8dec-2c9c743f6119", + "_postman_id": "10c05583-df4b-45e1-8794-0a1721e168f6", "name": "Content Analytics", "description": "Performs simple data validation for the Content Analytics REST Endpoint. It's very important to notice that, for the time being, the CICD instance does not start up any of the additional third-party tools required to actually run the Content Analytics feature.\n\nThis means that these test do not deal with retrieveing or saving data at all. It verifies that important/required information is present.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "781456" + "_exporter_id": "5403727" }, "item": [ { "name": "Data Query", "item": [ { - "name": "No User Authentication", - "event": [ + "name": "Using Path Parameters", + "item": [ { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"HTTP Status code must be unauthorized\", function () {", - " pm.response.to.have.status(401);", - "});", - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "auth": { - "type": "noauth" + "name": "No User Authentication", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"HTTP Status code must be unauthorized\", function () {", + " pm.response.to.have.status(401);", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/analytics/content/query/measures/request.count request.totalSessions/dimensions/request.host request.whatAmI request.url/timeDimensions/request.createdAt:day:This month/filters/request.totalRequest gt 0:request.whatAmI contains PAGE,FILE/order/request.count asc:request.createdAt asc/limit/5/offset/0", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "analytics", + "content", + "query", + "measures", + "request.count request.totalSessions", + "dimensions", + "request.host request.whatAmI request.url", + "timeDimensions", + "request.createdAt:day:This month", + "filters", + "request.totalRequest gt 0:request.whatAmI contains PAGE,FILE", + "order", + "request.count asc:request.createdAt asc", + "limit", + "5", + "offset", + "0" + ] + } + }, + "response": [] }, - "method": "POST", - "header": [], - "url": { - "raw": "{{serverURL}}/api/v1/analytics/content/_query", - "host": [ - "{{serverURL}}" + { + "name": "Missing Required Parameters", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"HTTP Status code must be Bad Request\", function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test(\"Check that minimum required parameters are NOT present\", function () {", + " pm.expect(pm.response.json().message).to.equal(\"Query should contain either measures, dimensions or timeDimensions with granularities in order to be valid\", \"This is NOT the expected error message\");", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } ], - "path": [ - "api", - "v1", - "analytics", - "content", - "_query" - ] + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/analytics/content/query/filters/request.totalRequest gt 0:request.whatAmI contains PAGE,FILE/order/request.count asc:request.createdAt asc/limit/5/offset/0", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "analytics", + "content", + "query", + "filters", + "request.totalRequest gt 0:request.whatAmI contains PAGE,FILE", + "order", + "request.count asc:request.createdAt asc", + "limit", + "5", + "offset", + "0" + ] + }, + "description": "As the error message states, the CubeJS Query should contain either measures, dimensions or timeDimensions with granularities in order to be valid." + }, + "response": [] } - }, - "response": [] + ], + "description": "This test group verifies that the Endpoint that receives plain String parameters for the CubeJS query works as expected.\n\nParameter has specfic formatting and separator characters that allow Users a more dynamic interaction with the Content Analitycs service:\n\n- Measures: Values are separated by blank spaces: `/measures/request.count request.totalSessions`\n \n- Dimensions: Values are separated by blank spaces: `/dimensions/request.host request.whatAmI request.url`\n \n- Time Dimensions: Values are separated by colons: `/timeDimensions/request.createdAt:day:This month` . The second parameter 'day' -- the \"granularity\" parameter -- is optional.\n \n- Filters: Values are separated by colons: `/filters/request.totalRequest gt 0:request.whatAmI contains PAGE,FILE` . In this case, you're filtering by the number of requests, and the type of object being queried: Pages and Files.\n \n- Order: Values are separated by colon: `/order/request.count asc:request.createdAt asc`\n \n- Limit: Value is provided as is: `/limit/50`\n \n- Offset: Value is provided as is: `/ffset/0`" }, { - "name": "No Query Form", - "event": [ + "name": "Using the JSON query", + "item": [ { - "listen": "test", - "script": { - "exec": [ - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"query\": {}\n}\n", - "options": { - "raw": { - "language": "json" + "name": "No User Authentication", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"HTTP Status code must be unauthorized\", function () {", + " pm.response.to.have.status(401);", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } } - } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/analytics/content/_query", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "analytics", + "content", + "_query" + ] + } + }, + "response": [] }, - "url": { - "raw": "{{serverURL}}/api/v1/analytics/content/_query", - "host": [ - "{{serverURL}}" + { + "name": "No Query Form", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } ], - "path": [ - "api", - "v1", - "analytics", - "content", - "_query" - ] + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"query\": {}\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/analytics/content/_query", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "analytics", + "content", + "_query" + ] + } + }, + "response": [] } - }, - "response": [] + ] } ] }, From 98d92cef649deb4b7726d76a43c0cc8ce831133e Mon Sep 17 00:00:00 2001 From: Jose Castro Date: Sat, 30 Nov 2024 11:33:46 -0600 Subject: [PATCH 3/3] Implementing SonarQube feedback. --- .../api/v1/analytics/content/ContentAnalyticsResource.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java index fbdaeedbd22d..afb15ea0db86 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java @@ -27,7 +27,6 @@ import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDUtil; import com.dotmarketing.util.UtilMethods; -import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.annotations.VisibleForTesting; import com.liferay.portal.model.User; import io.swagger.v3.oas.annotations.Operation; @@ -287,7 +286,7 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) public ReportResponseEntityView query(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - final @PathParam("params") String params) throws JsonProcessingException { + final @PathParam("params") String params) { final InitDataObject initDataObject = new WebResource.InitBuilder(this.webResource) .requestAndResponse(request, response) .params(params)