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

392 lines
10 KiB
JavaScript
Raw Normal View History

'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
*/
const { has, isEmpty, isObject, cloneDeep, get } = require('lodash/fp');
const _ = require('lodash');
const parseType = require('./parse-type');
const contentTypesUtils = 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
}
}
const validateOrder = order => {
if (!['asc', 'desc'].includes(order.toLocaleLowerCase())) {
throw new InvalidOrderError();
}
};
const convertCountQueryParams = countQuery => {
return parseType({ type: 'boolean', value: countQuery });
};
/**
* Sort query parser
* @param {string} sortQuery - ex: id:asc,price:desc
*/
const convertSortQueryParams = sortQuery => {
2021-08-30 19:34:19 +02:00
if (typeof sortQuery === 'string') {
return sortQuery.split(',').map(value => convertSingleSortQueryParam(value));
}
if (Array.isArray(sortQuery)) {
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();
};
2021-08-30 19:34:19 +02:00
const convertSingleSortQueryParam = sortQuery => {
// 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);
};
2021-08-30 19:34:19 +02:00
const convertNestedSortQueryParam = sortQuery => {
const transformedSort = {};
for (const field in sortQuery) {
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
*/
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
*/
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;
};
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') {
return populate.split(',').map(value => _.trim(value));
}
if (Array.isArray(populate)) {
// map convert
return _.uniq(
populate.flatMap(value => {
if (typeof value !== 'string') {
throw new InvalidPopulateError();
}
2021-08-30 19:34:19 +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;
}
// FIXME: This is a temporary solution for dynamic zones that should be
// fixed when we'll implement a more accurate way to query them
2022-02-18 14:36:39 +01:00
if (attribute.type === 'dynamiczone') {
const generatedFakeDynamicZoneSchema = {
uid: `${schema.uid}.${key}`,
attributes: attribute.components
.sort()
.map(uid => strapi.getModel(uid).attributes)
2022-02-18 14:36:39 +01:00
.reduce((acc, componentAttributes) => ({ ...acc, ...componentAttributes }), {}),
};
return {
...acc,
[key]: convertNestedPopulate(subPopulate, generatedFakeDynamicZoneSchema),
};
}
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;
}
return {
...acc,
[key]: convertNestedPopulate(subPopulate, targetSchema),
};
}, {});
};
2022-02-17 23:57:02 +01:00
const convertNestedPopulate = (subPopulate, schema) => {
if (subPopulate === '*') {
return 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`);
}
// TODO: We will need to consider a way to add limitation / pagination
const { sort, filters, fields, populate, count } = 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);
}
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') {
const fieldsValues = fields.split(',').map(value => _.trim(value));
return _.uniq(['id', ...fieldsValues]);
}
if (Array.isArray(fields)) {
// map convert
2021-09-01 21:13:23 +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) => {
2022-01-26 15:45:51 +01:00
if (!isObject(filters)) {
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
.map(filter => convertAndSanitizeFilters(filter, schema))
2022-01-26 15:45:51 +01:00
// Filter out empty filters
.filter(filter => !isObject(filter) || !isEmpty(filter))
);
}
2021-12-15 11:15:58 +01:00
2022-01-26 15:45:51 +01: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)) {
2022-02-25 15:32:11 +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
}
// Scalar attributes
else {
// Always remove password attributes from filters object
if (attribute.type === 'password') {
removeOperator(key);
} else {
filters[key] = convertAndSanitizeFilters(value, schema);
}
}
2021-12-15 11:15:58 +01:00
}
2022-01-26 15:45:51 +01:00
// Handle operators
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 (isObject(filters[key]) && isEmpty(filters[key])) {
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 } };
}
};
}
};
module.exports = {
convertSortQueryParams,
convertStartQueryParams,
convertLimitQueryParams,
convertPopulateQueryParams,
convertFiltersQueryParams,
2021-08-30 18:31:09 +02:00
convertFieldsQueryParams,
convertPublicationStateParams,
};