strapi/packages/core/utils/lib/convert-query-params.js

558 lines
14 KiB
JavaScript
Raw Normal View History

2022-08-09 19:14:27 +02:00
/* eslint-disable max-classes-per-file */
'use strict';
/**
2021-09-01 21:13:23 +02:00
* Converts the standard Strapi REST query params to a more usable format for querying
* You can read more here: https://docs.strapi.io/developer-docs/latest/developer-resources/database-apis-reference/rest-api.html#filters
*/
2022-11-04 15:06:36 +01:00
2022-08-16 19:36:10 +02:00
const {
2022-11-04 15:06:36 +01:00
isNil,
toNumber,
isInteger,
2022-08-16 19:36:10 +02:00
has,
isEmpty,
isObject,
isPlainObject,
cloneDeep,
get,
mergeAll,
2022-08-16 19:36:10 +02:00
} = require('lodash/fp');
const _ = require('lodash');
const parseType = require('./parse-type');
const contentTypesUtils = require('./content-types');
2022-08-16 19:36:10 +02:00
const { PaginationError } = require('./errors');
const {
isMediaAttribute,
isDynamicZoneAttribute,
isMorphToRelationalAttribute,
} = require('./content-types');
const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants;
2021-08-30 19:34:19 +02:00
class InvalidOrderError extends Error {
constructor() {
super();
this.message = 'Invalid order. order can only be one of asc|desc|ASC|DESC';
}
}
class InvalidSortError extends Error {
constructor() {
super();
this.message =
2021-09-01 21:13:23 +02:00
'Invalid sort parameter. Expected a string, an array of strings, a sort object or an array of sort objects';
2021-08-30 19:34:19 +02:00
}
}
2022-08-08 23:33:39 +02:00
const validateOrder = (order) => {
2021-08-30 19:34:19 +02:00
if (!['asc', 'desc'].includes(order.toLocaleLowerCase())) {
throw new InvalidOrderError();
}
};
2022-08-08 23:33:39 +02:00
const convertCountQueryParams = (countQuery) => {
return parseType({ type: 'boolean', value: countQuery });
};
const convertOrderingQueryParams = (ordering) => {
return ordering;
};
/**
* Sort query parser
* @param {string} sortQuery - ex: id:asc,price:desc
*/
2022-08-08 23:33:39 +02:00
const convertSortQueryParams = (sortQuery) => {
2021-08-30 19:34:19 +02:00
if (typeof sortQuery === 'string') {
2022-08-08 23:33:39 +02:00
return sortQuery.split(',').map((value) => convertSingleSortQueryParam(value));
2021-08-30 19:34:19 +02:00
}
if (Array.isArray(sortQuery)) {
2022-08-08 23:33:39 +02:00
return sortQuery.flatMap((sortValue) => convertSortQueryParams(sortValue));
}
2021-08-30 19:34:19 +02:00
if (_.isPlainObject(sortQuery)) {
return convertNestedSortQueryParam(sortQuery);
}
2021-08-30 19:34:19 +02:00
throw new InvalidSortError();
};
2022-08-08 23:33:39 +02:00
const convertSingleSortQueryParam = (sortQuery) => {
2021-08-30 19:34:19 +02:00
// split field and order param with default order to ascending
const [field, order = 'asc'] = sortQuery.split(':');
2021-08-30 19:34:19 +02:00
if (field.length === 0) {
throw new Error('Field cannot be empty');
}
2021-08-30 19:34:19 +02:00
validateOrder(order);
2021-08-30 19:34:19 +02:00
return _.set({}, field, order);
};
2022-08-08 23:33:39 +02:00
const convertNestedSortQueryParam = (sortQuery) => {
2021-08-30 19:34:19 +02:00
const transformedSort = {};
2022-08-08 23:33:39 +02:00
for (const field of Object.keys(sortQuery)) {
2021-08-30 19:34:19 +02:00
const order = sortQuery[field];
// this is a deep sort
if (_.isPlainObject(order)) {
transformedSort[field] = convertNestedSortQueryParam(order);
} else {
validateOrder(order);
transformedSort[field] = order;
}
}
return transformedSort;
};
/**
* Start query parser
2021-09-01 21:13:23 +02:00
* @param {string} startQuery
*/
2022-08-08 23:33:39 +02:00
const convertStartQueryParams = (startQuery) => {
const startAsANumber = _.toNumber(startQuery);
if (!_.isInteger(startAsANumber) || startAsANumber < 0) {
throw new Error(`convertStartQueryParams expected a positive integer got ${startAsANumber}`);
}
return startAsANumber;
};
/**
* Limit query parser
2021-09-01 21:13:23 +02:00
* @param {string} limitQuery
*/
2022-08-08 23:33:39 +02:00
const convertLimitQueryParams = (limitQuery) => {
const limitAsANumber = _.toNumber(limitQuery);
if (!_.isInteger(limitAsANumber) || (limitAsANumber !== -1 && limitAsANumber < 0)) {
throw new Error(`convertLimitQueryParams expected a positive integer got ${limitAsANumber}`);
}
if (limitAsANumber === -1) return null;
return limitAsANumber;
};
const convertPageQueryParams = (page) => {
const pageVal = toNumber(page);
if (!isInteger(pageVal) || pageVal <= 0) {
throw new PaginationError(
`Invalid 'page' parameter. Expected an integer > 0, received: ${page}`
);
}
return pageVal;
};
const convertPageSizeQueryParams = (pageSize, page) => {
const pageSizeVal = toNumber(pageSize);
if (!isInteger(pageSizeVal) || pageSizeVal <= 0) {
throw new PaginationError(
`Invalid 'pageSize' parameter. Expected an integer > 0, received: ${page}`
);
}
return pageSizeVal;
};
const validatePaginationParams = (page, pageSize, start, limit) => {
const isPagePagination = !isNil(page) || !isNil(pageSize);
const isOffsetPagination = !isNil(start) || !isNil(limit);
if (isPagePagination && isOffsetPagination) {
throw new PaginationError(
'Invalid pagination attributes. You cannot use page and offset pagination in the same query'
);
}
};
2021-08-30 19:34:19 +02:00
class InvalidPopulateError extends Error {
constructor() {
super();
this.message =
'Invalid populate parameter. Expected a string, an array of strings, a populate object';
}
}
// NOTE: we could support foo.* or foo.bar.* etc later on
2022-02-17 23:57:02 +01:00
const convertPopulateQueryParams = (populate, schema, depth = 0) => {
if (depth === 0 && populate === '*') {
return true;
}
if (typeof populate === 'string') {
2022-08-08 23:33:39 +02:00
return populate.split(',').map((value) => _.trim(value));
}
if (Array.isArray(populate)) {
// map convert
return _.uniq(
2022-08-08 23:33:39 +02:00
populate.flatMap((value) => {
if (typeof value !== 'string') {
throw new InvalidPopulateError();
}
2021-08-30 19:34:19 +02:00
2022-08-08 23:33:39 +02:00
return value.split(',').map((value) => _.trim(value));
})
);
}
if (_.isPlainObject(populate)) {
2022-02-18 14:36:39 +01:00
return convertPopulateObject(populate, schema);
}
2022-02-18 14:36:39 +01:00
throw new InvalidPopulateError();
};
2022-02-18 14:36:39 +01:00
const convertPopulateObject = (populate, schema) => {
if (!schema) {
return {};
}
2022-02-18 14:36:39 +01:00
const { attributes } = schema;
2022-02-18 14:36:39 +01:00
return Object.entries(populate).reduce((acc, [key, subPopulate]) => {
const attribute = attributes[key];
2022-02-18 14:36:39 +01:00
if (!attribute) {
return acc;
}
2022-11-21 14:20:59 +01:00
// Allow adding an 'on' strategy to populate queries for polymorphic relations, media and dynamic zones
2022-11-21 11:02:38 +01:00
const isAllowedAttributeForFragmentPopulate =
isDynamicZoneAttribute(attribute) ||
isMediaAttribute(attribute) ||
isMorphToRelationalAttribute(attribute);
2022-11-21 11:02:38 +01:00
add traverse query fix single type fix query sanitize pagination count params add comments Cleanup the params/filters sanitize helpers sanitize association resolver Sanitize sort fix graphql single type fix graphql types fix addFindQuery Sanitize fields Update sanitize sort to handle all the different formats Update fields sanitize to handle regular strings & wildcard Fix non scalar recursion Add a traverse factory Add visitor to remove dz & morph relations Replace the old traverse utils (sort, filters) by one created using the traverse factory add sanitize populate await args fix async and duplicate sanitization sanitize u&p params Add traverse fields Fix traverse & sanitize fields add traverse fields to nested populate sanitize admin api filter queries Co-authored-by: Jean-Sébastien Herbaux <Convly@users.noreply.github.com> sanitize sort params in admin API todo make token fields unsearchable with _q sanitize delete mutation Update packages/core/admin/server/services/permission/permissions-manager/sanitize.js Co-authored-by: Jamie Howard <48524071+jhoward1994@users.noreply.github.com> fix errors on queries without ctx rename findParams to sanitizedParams Sanitize queries everywhere in the content manager admin controllers sanitize single type update and delete Ignore non attribute keys in the sanitize sort Fix the sanitize query sort for nested string sort Fix permission check for the admin typo sanitize upload sanitize admin media library sanitize admin users Add missing await Co-authored-by: Jean-Sébastien Herbaux <Convly@users.noreply.github.com> set U&P users fields to searchable:false add token support to createContentAPIRequest add searchable:false to getstarted U&P schema remove comment sanitize component resolver remove await add searchable false to the file's folder path Fix admin query when the permission query is set to null add basic tests for filtering private params add tests for fields add pagination tests Fix admin user fields not being sanitized Fix convert query params for the morph fragment on undefined value Traverse dynamic zone on nested populate Handle nested sort, filters & fields in populate queries + handle populate fragment for morphTo relations Sanitize 'on' subpopulate Co-authored-by: Jean-Sébastien Herbaux <Convly@users.noreply.github.com> don't throw error on invalid attributes check models for snake case column name instead of assuming they are operators Add first batch of api tests for params sanitize Fix sort traversal: handle object arrays Put back removePassword for fields,sort,filters Add schemas and fixtures for sanitize api tests Add tests for relations (sanitize api tests) Move constant to domain scope Rename sanitize params to sanitize query Fix typo Cleanup fixtures file Fix variable name conflict Update packages/core/admin/server/services/permission/permissions-manager/sanitize.js Co-authored-by: Alexandre BODIN <alexandrebodin@users.noreply.github.com> Update comment for array filters Rename sanitize test Test implicit & explicit array operator for filter Remove unused code
2023-02-09 11:35:50 +01:00
const hasFragmentPopulateDefined =
typeof subPopulate === 'object' && 'on' in subPopulate && !isNil(subPopulate.on);
2022-11-21 11:02:38 +01:00
if (isAllowedAttributeForFragmentPopulate && hasFragmentPopulateDefined) {
2022-02-18 14:36:39 +01:00
return {
...acc,
2022-11-04 15:06:36 +01:00
[key]: {
on: Object.entries(subPopulate.on).reduce(
(acc, [type, typeSubPopulate]) => ({
...acc,
2022-11-04 15:06:36 +01:00
[type]: convertNestedPopulate(typeSubPopulate, strapi.getModel(type)),
}),
{}
),
},
2022-02-18 14:36:39 +01:00
};
}
2022-11-21 11:02:38 +01:00
// TODO: This is a query's populate fallback for DynamicZone and is kept for legacy purpose.
// Removing it could break existing user queries but it should be removed in V5.
if (attribute.type === 'dynamiczone') {
const populates = attribute.components
.map((uid) => strapi.getModel(uid))
.map((schema) => convertNestedPopulate(subPopulate, schema))
.map((populate) => (populate === true ? {} : populate)) // cast boolean to empty object to avoid merging issues
.filter((populate) => populate !== false);
if (isEmpty(populates)) {
return acc;
}
return {
...acc,
[key]: mergeAll(populates),
};
}
2022-02-18 14:36:39 +01:00
// NOTE: Retrieve the target schema UID.
2022-02-18 16:20:45 +01:00
// Only handles basic relations, medias and component since it's not possible
2022-02-18 14:36:39 +01:00
// to populate with options for a dynamic zone or a polymorphic relation
let targetSchemaUID;
2022-02-17 23:57:02 +01:00
2022-02-18 14:36:39 +01:00
if (attribute.type === 'relation') {
targetSchemaUID = attribute.target;
} else if (attribute.type === 'component') {
targetSchemaUID = attribute.component;
2022-02-18 16:20:45 +01:00
} else if (attribute.type === 'media') {
targetSchemaUID = 'plugin::upload.file';
2022-02-18 14:36:39 +01:00
} else {
return acc;
}
2022-02-18 14:36:39 +01:00
const targetSchema = strapi.getModel(targetSchemaUID);
2022-02-18 14:36:39 +01:00
if (!targetSchema) {
return acc;
}
const populateObject = convertNestedPopulate(subPopulate, targetSchema);
if (!populateObject) {
return acc;
}
2022-02-18 14:36:39 +01:00
return {
...acc,
[key]: populateObject,
2022-02-18 14:36:39 +01:00
};
}, {});
};
2022-02-17 23:57:02 +01:00
const convertNestedPopulate = (subPopulate, schema) => {
if (_.isString(subPopulate)) {
return parseType({ type: 'boolean', value: subPopulate, forceCast: true });
}
2021-08-30 14:23:19 +02:00
if (_.isBoolean(subPopulate)) {
return subPopulate;
}
if (!_.isPlainObject(subPopulate)) {
throw new Error(`Invalid nested populate. Expected '*' or an object`);
}
const { sort, filters, fields, populate, count, ordering, page, pageSize, start, limit } =
subPopulate;
const query = {};
if (sort) {
query.orderBy = convertSortQueryParams(sort);
}
if (filters) {
2022-02-17 23:57:02 +01:00
query.where = convertFiltersQueryParams(filters, schema);
}
if (fields) {
2021-08-30 18:31:09 +02:00
query.select = convertFieldsQueryParams(fields);
}
if (populate) {
2022-02-17 23:57:02 +01:00
query.populate = convertPopulateQueryParams(populate, schema);
}
if (count) {
query.count = convertCountQueryParams(count);
}
if (ordering) {
query.ordering = convertOrderingQueryParams(ordering);
}
validatePaginationParams(page, pageSize, start, limit);
if (!isNil(page)) {
query.page = convertPageQueryParams(page);
}
if (!isNil(pageSize)) {
query.pageSize = convertPageSizeQueryParams(pageSize, page);
}
if (!isNil(start)) {
query.offset = convertStartQueryParams(start);
}
if (!isNil(limit)) {
query.limit = convertLimitQueryParams(limit);
}
convertPublicationStateParams(schema, subPopulate, query);
return query;
};
2021-08-30 18:31:09 +02:00
const convertFieldsQueryParams = (fields, depth = 0) => {
if (depth === 0 && fields === '*') {
return undefined;
}
if (typeof fields === 'string') {
2022-08-08 23:33:39 +02:00
const fieldsValues = fields.split(',').map((value) => _.trim(value));
2021-08-30 18:31:09 +02:00
return _.uniq(['id', ...fieldsValues]);
}
if (Array.isArray(fields)) {
// map convert
2022-08-08 23:33:39 +02:00
const fieldsValues = fields.flatMap((value) => convertFieldsQueryParams(value, depth + 1));
2021-08-30 18:31:09 +02:00
return _.uniq(['id', ...fieldsValues]);
}
throw new Error('Invalid fields parameter. Expected a string or an array of strings');
};
2022-01-06 16:19:27 +01:00
const convertFiltersQueryParams = (filters, schema) => {
// Filters need to be either an array or an object
// Here we're only checking for 'object' type since typeof [] => object and typeof {} => object
2022-01-10 12:35:08 +01:00
if (!isObject(filters)) {
2022-01-06 16:19:27 +01:00
throw new Error('The filters parameter must be an object or an array');
}
2022-01-26 15:45:51 +01:00
// Don't mutate the original object
const filtersCopy = cloneDeep(filters);
2022-01-10 12:35:08 +01:00
return convertAndSanitizeFilters(filtersCopy, schema);
2022-01-26 15:45:51 +01:00
};
2021-12-15 11:15:58 +01:00
const convertAndSanitizeFilters = (filters, schema) => {
if (!isPlainObject(filters)) {
2022-01-26 15:45:51 +01:00
return filters;
}
2021-12-15 11:15:58 +01:00
2022-01-26 15:45:51 +01:00
if (Array.isArray(filters)) {
return (
filters
// Sanitize each filter
2022-08-08 23:33:39 +02:00
.map((filter) => convertAndSanitizeFilters(filter, schema))
2022-01-26 15:45:51 +01:00
// Filter out empty filters
2022-08-08 23:33:39 +02:00
.filter((filter) => !isObject(filter) || !isEmpty(filter))
2022-01-26 15:45:51 +01:00
);
}
2021-12-15 11:15:58 +01:00
2022-08-08 23:33:39 +02:00
const removeOperator = (operator) => delete filters[operator];
2021-12-15 11:15:58 +01:00
2022-01-26 15:45:51 +01:00
// Here, `key` can either be an operator or an attribute name
for (const [key, value] of Object.entries(filters)) {
add traverse query fix single type fix query sanitize pagination count params add comments Cleanup the params/filters sanitize helpers sanitize association resolver Sanitize sort fix graphql single type fix graphql types fix addFindQuery Sanitize fields Update sanitize sort to handle all the different formats Update fields sanitize to handle regular strings & wildcard Fix non scalar recursion Add a traverse factory Add visitor to remove dz & morph relations Replace the old traverse utils (sort, filters) by one created using the traverse factory add sanitize populate await args fix async and duplicate sanitization sanitize u&p params Add traverse fields Fix traverse & sanitize fields add traverse fields to nested populate sanitize admin api filter queries Co-authored-by: Jean-Sébastien Herbaux <Convly@users.noreply.github.com> sanitize sort params in admin API todo make token fields unsearchable with _q sanitize delete mutation Update packages/core/admin/server/services/permission/permissions-manager/sanitize.js Co-authored-by: Jamie Howard <48524071+jhoward1994@users.noreply.github.com> fix errors on queries without ctx rename findParams to sanitizedParams Sanitize queries everywhere in the content manager admin controllers sanitize single type update and delete Ignore non attribute keys in the sanitize sort Fix the sanitize query sort for nested string sort Fix permission check for the admin typo sanitize upload sanitize admin media library sanitize admin users Add missing await Co-authored-by: Jean-Sébastien Herbaux <Convly@users.noreply.github.com> set U&P users fields to searchable:false add token support to createContentAPIRequest add searchable:false to getstarted U&P schema remove comment sanitize component resolver remove await add searchable false to the file's folder path Fix admin query when the permission query is set to null add basic tests for filtering private params add tests for fields add pagination tests Fix admin user fields not being sanitized Fix convert query params for the morph fragment on undefined value Traverse dynamic zone on nested populate Handle nested sort, filters & fields in populate queries + handle populate fragment for morphTo relations Sanitize 'on' subpopulate Co-authored-by: Jean-Sébastien Herbaux <Convly@users.noreply.github.com> don't throw error on invalid attributes check models for snake case column name instead of assuming they are operators Add first batch of api tests for params sanitize Fix sort traversal: handle object arrays Put back removePassword for fields,sort,filters Add schemas and fixtures for sanitize api tests Add tests for relations (sanitize api tests) Move constant to domain scope Rename sanitize params to sanitize query Fix typo Cleanup fixtures file Fix variable name conflict Update packages/core/admin/server/services/permission/permissions-manager/sanitize.js Co-authored-by: Alexandre BODIN <alexandrebodin@users.noreply.github.com> Update comment for array filters Rename sanitize test Test implicit & explicit array operator for filter Remove unused code
2023-02-09 11:35:50 +01:00
const attribute = get(key, schema?.attributes);
2021-12-15 11:15:58 +01:00
2022-01-26 15:45:51 +01:00
// Handle attributes
if (attribute) {
// Relations
if (attribute.type === 'relation') {
filters[key] = convertAndSanitizeFilters(value, strapi.getModel(attribute.target));
2022-01-06 16:19:27 +01:00
}
2021-12-15 11:15:58 +01:00
2022-01-26 15:45:51 +01:00
// Components
else if (attribute.type === 'component') {
filters[key] = convertAndSanitizeFilters(value, strapi.getModel(attribute.component));
2022-01-06 16:19:27 +01:00
}
2021-12-15 11:15:58 +01:00
2022-01-26 15:45:51 +01:00
// Media
else if (attribute.type === 'media') {
filters[key] = convertAndSanitizeFilters(value, strapi.getModel('plugin::upload.file'));
2022-01-26 15:45:51 +01:00
}
// Dynamic Zones
else if (attribute.type === 'dynamiczone') {
removeOperator(key);
2022-01-06 16:19:27 +01:00
}
2022-08-08 23:33:39 +02:00
// Password attributes
else if (attribute.type === 'password') {
// Always remove password attributes from filters object
removeOperator(key);
}
// Scalar attributes
else {
2022-08-08 23:33:39 +02:00
filters[key] = convertAndSanitizeFilters(value, schema);
}
2021-12-15 11:15:58 +01:00
}
2022-01-26 15:45:51 +01:00
// Handle operators
2022-08-08 15:50:34 +02:00
else if (['$null', '$notNull'].includes(key)) {
filters[key] = parseType({ type: 'boolean', value: filters[key], forceCast: true });
} else if (isObject(value)) {
filters[key] = convertAndSanitizeFilters(value, schema);
2022-01-26 15:45:51 +01:00
}
2021-12-15 11:15:58 +01:00
2022-01-26 15:45:51 +01:00
// Remove empty objects & arrays
if (isPlainObject(filters[key]) && isEmpty(filters[key])) {
2022-01-26 15:45:51 +01:00
removeOperator(key);
}
}
2022-01-26 15:45:51 +01:00
return filters;
2021-12-15 11:15:58 +01:00
};
const convertPublicationStateParams = (type, params = {}, query = {}) => {
if (!type) {
return;
}
const { publicationState } = params;
if (!_.isNil(publicationState)) {
if (!contentTypesUtils.constants.DP_PUB_STATES.includes(publicationState)) {
throw new Error(
`Invalid publicationState. Expected one of 'preview','live' received: ${publicationState}.`
);
}
// NOTE: this is the query layer filters not the entity service filters
query.filters = ({ meta }) => {
if (publicationState === 'live' && has(PUBLISHED_AT_ATTRIBUTE, meta.attributes)) {
return { [PUBLISHED_AT_ATTRIBUTE]: { $notNull: true } };
}
};
}
};
2022-08-16 19:36:10 +02:00
const transformParamsToQuery = (uid, params) => {
// NOTE: can be a CT, a Compo or nothing in the case of polymorphism (DZ & morph relations)
const schema = strapi.getModel(uid);
const query = {};
const { _q, sort, filters, fields, populate, page, pageSize, start, limit } = params;
if (!isNil(_q)) {
query._q = _q;
}
if (!isNil(sort)) {
query.orderBy = convertSortQueryParams(sort);
}
if (!isNil(filters)) {
query.where = convertFiltersQueryParams(filters, schema);
}
if (!isNil(fields)) {
query.select = convertFieldsQueryParams(fields);
}
if (!isNil(populate)) {
query.populate = convertPopulateQueryParams(populate, schema);
}
validatePaginationParams(page, pageSize, start, limit);
2022-08-16 19:36:10 +02:00
if (!isNil(page)) {
query.page = convertPageQueryParams(page);
2022-08-16 19:36:10 +02:00
}
if (!isNil(pageSize)) {
query.pageSize = convertPageSizeQueryParams(pageSize, page);
2022-08-16 19:36:10 +02:00
}
if (!isNil(start)) {
query.offset = convertStartQueryParams(start);
}
if (!isNil(limit)) {
query.limit = convertLimitQueryParams(limit);
}
convertPublicationStateParams(schema, params, query);
return query;
};
module.exports = {
convertSortQueryParams,
convertStartQueryParams,
convertLimitQueryParams,
convertPopulateQueryParams,
convertFiltersQueryParams,
2021-08-30 18:31:09 +02:00
convertFieldsQueryParams,
convertPublicationStateParams,
2022-08-16 19:36:10 +02:00
transformParamsToQuery,
};