From 6d243669296956cebb1c542662c70cc6a5f74ff7 Mon Sep 17 00:00:00 2001 From: Jose Castro Date: Thu, 5 Dec 2024 10:51:38 -0600 Subject: [PATCH] feat(content analytics) fixes #30521 : Allow users to pass down simple Strings to query for Content Analytics data (#30847) ### Proposed Changes * This code change allows users to pass down simple Strings to query for Content Analytics data. * Considering the formatting of different values, a special notation must be used to specify certain parameters in the URL path. * You can do a POST REST call using a JSON body like this one: ```json { "measures": "request.count request.totalSessions", "dimensions": "request.host request.whatAmI request.url", "timeDimensions": "request.createdAt:day:Last month", "filters": "request.totalRequest gt 0:request.whatAmI contains PAGE,FILE", "order": "request.count asc:request.createdAt asc", "limit": 5, "offset": 0 } ``` The following must be taken into account when putting a query together: * Measures: Values are separated by blank spaces: `request.count request.totalSessions` * Dimensions: Values are separated by blank spaces: `request.host request.whatAmI request.url` * Time Dimensions: Values are separated by colons: `request.createdAt:day:This month` . The second parameter 'day' -- the "granularity" parameter -- is optional. * Filters: Values are separated by colons: `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. * Order: Values are separated by colon: `request.count asc:request.createdAt asc` * Limit: Value is provided as is: `50` * Offset: Value is provided as is: `0` --- .../content/ContentAnalyticsQuery.java | 28 +++++++- .../content/ContentAnalyticsResource.java | 71 ++++++------------- .../Content_Analytics.postman_collection.json | 42 +++-------- 3 files changed, 56 insertions(+), 85 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 065ed1f3dabd..9466e308678f 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java @@ -28,6 +28,7 @@ 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 TIME_DIMENSIONS_DIMENSION_ATTR = "dimension"; public static final String FILTERS_ATTR = "filters"; public static final String ORDER_ATTR = "order"; public static final String LIMIT_ATTR = "limit"; @@ -166,7 +167,7 @@ public Builder timeDimensions(final String timeDimensions) { } final String[] timeParams = timeDimensions.split(SEPARATOR); final Map timeDimensionsData = new HashMap<>(); - timeDimensionsData.put(DIMENSIONS_ATTR, timeParams[0]); + timeDimensionsData.put(TIME_DIMENSIONS_DIMENSION_ATTR, timeParams[0]); if (timeParams.length > 2) { timeDimensionsData.put(GRANULARITY_ATTR, timeParams[1]); timeDimensionsData.put(DATE_RANGE_ATTR, timeParams[2]); @@ -252,15 +253,36 @@ public Builder offset(final int offset) { return this; } + /** - * This method builds the ContentAnalyticsQuery object. + * This method builds the ContentAnalyticsQuery object based on all the specified + * parameters for the query. * - * @return The ContentAnalyticsQuery object. + * @return The {@link ContentAnalyticsQuery} object. */ public ContentAnalyticsQuery build() { return new ContentAnalyticsQuery(this); } + /** + * This method builds the ContentAnalyticsQuery object based on all the specified + * parameters in the provided map. + * + * @param form A {@link Map} containing the query data. + * + * @return The {@link ContentAnalyticsQuery} object. + */ + public ContentAnalyticsQuery build(final Map form) { + this.measures((String) form.get(MEASURES_ATTR)); + this.dimensions((String) form.get(DIMENSIONS_ATTR)); + this.timeDimensions((String) form.get(TIME_DIMENSIONS_ATTR)); + this.filters((String) form.get(FILTERS_ATTR)); + this.order((String) form.get(ORDER_ATTR)); + this.limit((Integer) form.get(LIMIT_ATTR)); + this.offset((Integer) form.get(OFFSET_ATTR)); + 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 fbdaeedbd22d..afbab16ef1cb 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 @@ -26,8 +26,6 @@ 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; import io.swagger.v3.oas.annotations.Operation; @@ -44,7 +42,6 @@ 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; @@ -55,13 +52,6 @@ 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; @@ -236,18 +226,20 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re /** * 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. + * using simplified version of a query sent to the Content Analytics service. 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 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. + * @param form A Map with the parameters that will be used to generate the query + * internally. * * @return The request information from the Content Analytics server. */ @Operation( - operationId = "postContentAnalyticsQuery", - summary = "Retrieve Content Analytics data", + operationId = "postContentAnalyticsSimpleQuery", + summary = "Returns Content Analytics data", description = "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 " + @@ -259,17 +251,15 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re content = @Content(mediaType = "application/json", examples = { @ExampleObject( - 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" + value = "{\n" + + " \"measures\": \"request.count request.totalSessions\",\n" + + " \"dimensions\": \"request.host request.whatAmI request.url\",\n" + + " \"timeDimensions\": \"request.createdAt:day:Last month\",\n" + + " \"filters\": \"request.totalRequest gt 0:request.whatAmI contains PAGE,FILE\",\n" + + " \"order\": \"request.count asc:request.createdAt asc\",\n" + + " \"limit\": 15,\n" + + " \"offset\": 0\n" + + "}" ) } ) @@ -280,42 +270,25 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re } ) @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 Map form) { 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(); - 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() - .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_ATTR)) { - builder.offset(Integer.parseInt(paramsMap.get(OFFSET_ATTR))); - } - final ContentAnalyticsQuery contentAnalyticsQuery = builder.build(); + Logger.debug(this, () -> "Querying content analytics data with the following parameters: " + form); + checkNotNull(form, IllegalArgumentException.class, "The 'form' JSON data cannot be null"); + checkArgument(!form.isEmpty(), IllegalArgumentException.class, "The 'form' JSON data cannot be empty"); + final ContentAnalyticsQuery contentAnalyticsQuery = new ContentAnalyticsQuery.Builder().build(form); final String cubeJsQuery = JsonUtil.getJsonStringFromObject(contentAnalyticsQuery); - Logger.debug(this, ()-> "Generated query: " + cubeJsQuery); + 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())); 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 0c56b2927967..933b017a332c 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,6 +1,6 @@ { "info": { - "_postman_id": "10c05583-df4b-45e1-8794-0a1721e168f6", + "_postman_id": "630e7fe8-7824-4ab6-a56c-2f7c4056c596", "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", @@ -11,7 +11,7 @@ "name": "Data Query", "item": [ { - "name": "Using Path Parameters", + "name": "Using Simple Strings", "item": [ { "name": "No User Authentication", @@ -38,7 +38,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", + "raw": "{\n \"measures\": \"request.count request.totalSessions\",\n \"dimensions\": \"request.host request.whatAmI request.url\",\n \"timeDimensions\": \"request.createdAt:day:Last month\",\n \"filters\": \"request.totalRequest gt 0:request.whatAmI contains PAGE,FILE\",\n \"order\": \"request.count asc:request.createdAt asc\",\n \"limit\": 5,\n \"offset\": 0\n}", "options": { "raw": { "language": "json" @@ -46,7 +46,7 @@ } }, "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", + "raw": "{{serverURL}}/api/v1/analytics/content", "host": [ "{{serverURL}}" ], @@ -54,22 +54,7 @@ "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" + "content" ] } }, @@ -87,7 +72,7 @@ "});", "", "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\");", + " pm.expect(pm.response.json().message).to.equal(\"The 'form' JSON data cannot be null\");", "});", "" ], @@ -119,7 +104,7 @@ } }, "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", + "raw": "{{serverURL}}/api/v1/analytics/content", "host": [ "{{serverURL}}" ], @@ -127,16 +112,7 @@ "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" + "content" ] }, "description": "As the error message states, the CubeJS Query should contain either measures, dimensions or timeDimensions with granularities in order to be valid." @@ -144,7 +120,7 @@ "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`" + "description": "This test group verifies that the Endpoint that receives simple String parameters for the Content Analytics query works as expected. This endpoint takes a JSON body with parameters such as the following:\n\n`{`\n\n`\"measures\": \"request.count request.totalSessions\",`\n\n`\"dimensions\": \"request.host request.whatAmI request.url\",`\n\n`\"timeDimensions\": \"request.createdAt:day:Last month\",`\n\n`\"filters\": \"request.totalRequest gt 0:request.whatAmI contains PAGE,FILE\",`\n\n`\"order\": \"request.count asc:request.createdAt asc\",`\n\n`\"limit\": 5,`\n\n`\"offset\": 0`\n\n`}`" }, { "name": "Using the JSON query",