Skip to content

Commit

Permalink
feat(content analytics) fixes #30521 : Allow users to pass down simpl…
Browse files Browse the repository at this point in the history
…e 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`
  • Loading branch information
jcastro-dotcms authored Dec 5, 2024
1 parent f274db5 commit 6d24366
Show file tree
Hide file tree
Showing 3 changed files with 56 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -166,7 +167,7 @@ public Builder timeDimensions(final String timeDimensions) {
}
final String[] timeParams = timeDimensions.split(SEPARATOR);
final Map<String, String> 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]);
Expand Down Expand Up @@ -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<String, Object> 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);
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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 " +
Expand All @@ -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" +
"}"
)
}
)
Expand All @@ -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<String, Object> 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<String, String> 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()));
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -11,7 +11,7 @@
"name": "Data Query",
"item": [
{
"name": "Using Path Parameters",
"name": "Using Simple Strings",
"item": [
{
"name": "No User Authentication",
Expand All @@ -38,38 +38,23 @@
"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"
}
}
},
"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}}"
],
"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"
"content"
]
}
},
Expand All @@ -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\");",
"});",
""
],
Expand Down Expand Up @@ -119,32 +104,23 @@
}
},
"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}}"
],
"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"
"content"
]
},
"description": "As the error message states, the CubeJS Query should contain either measures, dimensions or timeDimensions with granularities in order to be valid."
},
"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",
Expand Down

0 comments on commit 6d24366

Please sign in to comment.