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..065ed1f3dabd --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/content/ContentAnalyticsQuery.java @@ -0,0 +1,266 @@ +package com.dotcms.analytics.content; + +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +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 + */ +@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() + 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 = COLON; + + 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 + '\'' + + '}'; + } + + /** + * This builder creates the appropriate data structures that match the JSON format of the final + * CubeJS query. + */ + 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; + + /** + * 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; + } + + /** + * 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(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(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; + } + 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_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; + } + 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; + } + + /** + * 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); + } + + } + +} 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..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 @@ -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; @@ -17,13 +19,14 @@ 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; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; import com.google.common.annotations.VisibleForTesting; import com.liferay.portal.model.User; import io.swagger.v3.oas.annotations.Operation; @@ -31,25 +34,35 @@ 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 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 com.dotcms.analytics.track.collectors.EventType; +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. @@ -63,7 +76,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 +115,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", @@ -151,7 +166,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); @@ -169,10 +184,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( @@ -187,7 +204,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") } ) @@ -208,13 +225,101 @@ 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); return new ReportResponseEntityView(reportResponse.getResults().stream().map(ResultSetItem::getAll).collect(Collectors.toList())); } + + /** + * 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 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 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 " + + "being queried", + 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" + ) + } + ) + ), + @ApiResponse(responseCode = "400", description = "Bad Request"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @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) { + 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(); + final String cubeJsQuery = JsonUtil.getJsonStringFromObject(contentAnalyticsQuery); + 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())); + } + /** * Fire an user custom event. * @@ -253,7 +358,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": [] + ] } ] },