@strapi/utils
Version:
Shared utilities for the Strapi packages
511 lines (508 loc) • 21.4 kB
JavaScript
import ___default from 'lodash';
import { isString, toNumber, isNil, isObject, cloneDeep, isEmpty, get, isArray, isInteger } from 'lodash/fp';
import { isDynamicZoneAttribute, isMorphToRelationalAttribute, constants, hasDraftAndPublish } from './content-types.mjs';
import { PaginationError } from './errors.mjs';
import { isOperator } from './operators.mjs';
import parseType from './parse-type.mjs';
const { ID_ATTRIBUTE, DOC_ID_ATTRIBUTE, PUBLISHED_AT_ATTRIBUTE } = constants;
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 = 'Invalid sort parameter. Expected a string, an array of strings, a sort object or an array of sort objects';
}
}
function validateOrder(order) {
if (!isString(order) || ![
'asc',
'desc'
].includes(order.toLocaleLowerCase())) {
throw new InvalidOrderError();
}
}
const convertCountQueryParams = (countQuery)=>{
return parseType({
type: 'boolean',
value: countQuery
});
};
const convertOrderingQueryParams = (ordering)=>{
return ordering;
};
const isPlainObject = (value)=>___default.isPlainObject(value);
const isStringArray = (value)=>isArray(value) && value.every(isString);
const createTransformer = ({ getModel })=>{
/**
* Sort query parser
*/ const convertSortQueryParams = (sortQuery)=>{
if (typeof sortQuery === 'string') {
return convertStringSortQueryParam(sortQuery);
}
if (isStringArray(sortQuery)) {
return sortQuery.flatMap((sortValue)=>convertStringSortQueryParam(sortValue));
}
if (Array.isArray(sortQuery)) {
return sortQuery.map((sortValue)=>convertNestedSortQueryParam(sortValue));
}
if (isPlainObject(sortQuery)) {
return convertNestedSortQueryParam(sortQuery);
}
throw new InvalidSortError();
};
const convertStringSortQueryParam = (sortQuery)=>{
return sortQuery.split(',').map((value)=>convertSingleSortQueryParam(value));
};
const convertSingleSortQueryParam = (sortQuery)=>{
if (!sortQuery) {
return {};
}
if (!isString(sortQuery)) {
throw new Error('Invalid sort query');
}
// split field and order param with default order to ascending
const [field, order = 'asc'] = sortQuery.split(':');
if (field.length === 0) {
throw new Error('Field cannot be empty');
}
validateOrder(order);
// TODO: field should be a valid path on an object model
return ___default.set({}, field, order);
};
const convertNestedSortQueryParam = (sortQuery)=>{
const transformedSort = {};
for (const field of Object.keys(sortQuery)){
const order = sortQuery[field];
// this is a deep sort
if (isPlainObject(order)) {
transformedSort[field] = convertNestedSortQueryParam(order);
} else if (typeof order === 'string') {
validateOrder(order);
transformedSort[field] = order;
} else {
throw Error(`Invalid sort type expected object or string got ${typeof order}`);
}
}
return transformedSort;
};
/**
* Start query parser
*/ const convertStartQueryParams = (startQuery)=>{
const startAsANumber = toNumber(startQuery);
if (!___default.isInteger(startAsANumber) || startAsANumber < 0) {
throw new Error(`convertStartQueryParams expected a positive integer got ${startAsANumber}`);
}
return startAsANumber;
};
/**
* Limit query parser
*/ const convertLimitQueryParams = (limitQuery)=>{
const limitAsANumber = toNumber(limitQuery);
if (!___default.isInteger(limitAsANumber) || limitAsANumber !== -1 && limitAsANumber < 0) {
throw new Error(`convertLimitQueryParams expected a positive integer got ${limitAsANumber}`);
}
if (limitAsANumber === -1) {
return undefined;
}
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. The page parameters are incorrect and must be in the pagination object');
}
};
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
const convertPopulateQueryParams = (populate, schema, depth = 0)=>{
if (depth === 0 && populate === '*') {
return true;
}
if (typeof populate === 'string') {
return populate.split(',').map((value)=>___default.trim(value));
}
if (Array.isArray(populate)) {
// map convert
return ___default.uniq(populate.flatMap((value)=>{
if (typeof value !== 'string') {
throw new InvalidPopulateError();
}
return value.split(',').map((value)=>___default.trim(value));
}));
}
if (___default.isPlainObject(populate)) {
return convertPopulateObject(populate, schema);
}
throw new InvalidPopulateError();
};
const hasPopulateFragmentDefined = (populate)=>{
return typeof populate === 'object' && 'on' in populate && !isNil(populate.on);
};
const hasCountDefined = (populate)=>{
return typeof populate === 'object' && 'count' in populate && typeof populate.count === 'boolean';
};
const convertPopulateObject = (populate, schema)=>{
if (!schema) {
return {};
}
const { attributes } = schema;
return Object.entries(populate).reduce((acc, [key, subPopulate])=>{
// Try converting strings to regular booleans if possible
if (___default.isString(subPopulate)) {
try {
const subPopulateAsBoolean = parseType({
type: 'boolean',
value: subPopulate
});
// Only true is accepted as a boolean populate value
return subPopulateAsBoolean ? {
...acc,
[key]: true
} : acc;
} catch {
// ignore
}
}
if (___default.isBoolean(subPopulate)) {
// Only true is accepted as a boolean populate value
return subPopulate === true ? {
...acc,
[key]: true
} : acc;
}
const attribute = attributes[key];
if (!attribute) {
return acc;
}
// Allow adding an 'on' strategy to populate queries for morphTo relations and dynamic zones
const isMorphLikeRelationalAttribute = isDynamicZoneAttribute(attribute) || isMorphToRelationalAttribute(attribute);
if (isMorphLikeRelationalAttribute) {
const hasInvalidProperties = Object.keys(subPopulate).some((key)=>![
'populate',
'on',
'count'
].includes(key));
if (hasInvalidProperties) {
throw new Error(`Invalid nested populate for ${schema.info?.singularName}.${key} (${schema.uid}). Expected a fragment ("on") or "count" but found ${JSON.stringify(subPopulate)}`);
}
/**
* Validate nested population queries in the context of a polymorphic attribute (dynamic zone, morph relation).
*
* If 'populate' exists in subPopulate, its value should be constrained to a wildcard ('*').
*/ if ('populate' in subPopulate && subPopulate.populate !== '*') {
throw new Error(`Invalid nested population query detected. When using 'populate' within polymorphic structures, ` + `its value must be '*' to indicate all second level links. Specific field targeting is not supported here. ` + `Consider using the fragment API for more granular population control.`);
}
// TODO: Remove the possibility to have multiple properties at the same time (on/count/populate)
const newSubPopulate = {};
// case: { populate: '*' }
if ('populate' in subPopulate) {
Object.assign(newSubPopulate, {
populate: true
});
}
// case: { on: { <clauses> } }
if (hasPopulateFragmentDefined(subPopulate)) {
// If the fragment API is used, it applies the transformation to every
// sub-populate, then assign the result to the new sub-populate
Object.assign(newSubPopulate, {
on: Object.entries(subPopulate.on).reduce((acc, [type, typeSubPopulate])=>({
...acc,
[type]: convertNestedPopulate(typeSubPopulate, getModel(type))
}), {})
});
}
// case: { count: true | false }
if (hasCountDefined(subPopulate)) {
Object.assign(newSubPopulate, {
count: subPopulate.count
});
}
return {
...acc,
[key]: newSubPopulate
};
}
// Edge case when trying to use the fragment ('on') on a non-morph like attribute
if (!isMorphLikeRelationalAttribute && hasPopulateFragmentDefined(subPopulate)) {
throw new Error(`Using fragments is not permitted to populate "${key}" in "${schema.uid}"`);
}
// NOTE: Retrieve the target schema UID.
// Only handles basic relations, medias and component since it's not possible
// to populate with options for a dynamic zone or a polymorphic relation
let targetSchemaUID;
if (attribute.type === 'relation') {
targetSchemaUID = attribute.target;
} else if (attribute.type === 'component') {
targetSchemaUID = attribute.component;
} else if (attribute.type === 'media') {
targetSchemaUID = 'plugin::upload.file';
} else {
return acc;
}
const targetSchema = getModel(targetSchemaUID);
// ignore the sub-populate for the current key if there is no schema associated
if (!targetSchema) {
return acc;
}
const populateObject = convertNestedPopulate(subPopulate, targetSchema);
if (!populateObject) {
return acc;
}
return {
...acc,
[key]: populateObject
};
}, {});
};
const convertNestedPopulate = (subPopulate, schema)=>{
if (___default.isString(subPopulate)) {
return parseType({
type: 'boolean',
value: subPopulate,
forceCast: true
});
}
if (___default.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) {
query.where = convertFiltersQueryParams(filters, schema);
}
if (fields) {
query.select = convertFieldsQueryParams(fields, schema);
}
if (populate) {
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);
}
return query;
};
// TODO: ensure field is valid in content types (will probably have to check strapi.contentTypes since it can be a string.path)
const convertFieldsQueryParams = (fields, schema, depth = 0)=>{
if (depth === 0 && fields === '*') {
return undefined;
}
if (typeof fields === 'string') {
const fieldsValues = fields.split(',').map((value)=>___default.trim(value));
// NOTE: Only include the doc id if it's a content type
if (schema?.modelType === 'contentType') {
return ___default.uniq([
ID_ATTRIBUTE,
DOC_ID_ATTRIBUTE,
...fieldsValues
]);
}
return ___default.uniq([
ID_ATTRIBUTE,
...fieldsValues
]);
}
if (isStringArray(fields)) {
// map convert
const fieldsValues = fields.flatMap((value)=>convertFieldsQueryParams(value, schema, depth + 1)).filter((v)=>!isNil(v));
// NOTE: Only include the doc id if it's a content type
if (schema?.modelType === 'contentType') {
return ___default.uniq([
ID_ATTRIBUTE,
DOC_ID_ATTRIBUTE,
...fieldsValues
]);
}
return ___default.uniq([
ID_ATTRIBUTE,
...fieldsValues
]);
}
throw new Error('Invalid fields parameter. Expected a string or an array of strings');
};
const isValidSchemaAttribute = (key, schema)=>{
if ([
DOC_ID_ATTRIBUTE,
ID_ATTRIBUTE
].includes(key)) {
return true;
}
if (!schema) {
return false;
}
return Object.keys(schema.attributes).includes(key);
};
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
if (!isObject(filters)) {
throw new Error('The filters parameter must be an object or an array');
}
// Don't mutate the original object
const filtersCopy = cloneDeep(filters);
return convertAndSanitizeFilters(filtersCopy, schema);
};
const convertAndSanitizeFilters = (filters, schema)=>{
if (Array.isArray(filters)) {
return filters// Sanitize each filter
.map((filter)=>convertAndSanitizeFilters(filter, schema))// Filter out empty filters
.filter((filter)=>!isPlainObject(filter) || !isEmpty(filter));
}
if (!isPlainObject(filters)) {
return filters;
}
const removeOperator = (operator)=>delete filters[operator];
// Here, `key` can either be an operator or an attribute name
for (const [key, value] of Object.entries(filters)){
const attribute = get(key, schema?.attributes);
const validKey = isOperator(key) || isValidSchemaAttribute(key, schema);
if (!validKey) {
removeOperator(key);
} else if (attribute) {
// Relations
if (attribute.type === 'relation') {
filters[key] = convertAndSanitizeFilters(value, getModel(attribute.target));
} else if (attribute.type === 'component') {
filters[key] = convertAndSanitizeFilters(value, getModel(attribute.component));
} else if (attribute.type === 'media') {
filters[key] = convertAndSanitizeFilters(value, getModel('plugin::upload.file'));
} else if (attribute.type === 'dynamiczone') {
removeOperator(key);
} else if (attribute.type === 'password') {
// Always remove password attributes from filters object
removeOperator(key);
} else {
filters[key] = convertAndSanitizeFilters(value, schema);
}
} 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);
}
// Remove empty objects & arrays
if (isPlainObject(filters[key]) && isEmpty(filters[key])) {
removeOperator(key);
}
}
return filters;
};
const convertStatusParams = (status, query = {})=>{
// NOTE: this is the query layer filters not the document/entity service filters
query.filters = ({ meta })=>{
const contentType = getModel(meta.uid);
// Ignore if target model has disabled DP, as it doesn't make sense to filter by its status
if (!contentType || !hasDraftAndPublish(contentType)) {
return {};
}
return {
[PUBLISHED_AT_ATTRIBUTE]: {
$null: status === 'draft'
}
};
};
};
const transformQueryParams = (uid, params)=>{
// NOTE: can be a CT, a Compo or nothing in the case of polymorphism (DZ & morph relations)
const schema = getModel(uid);
const query = {};
const { _q, sort, filters, fields, populate, page, pageSize, start, limit, status, ...rest } = params;
if (!isNil(status)) {
convertStatusParams(status, query);
}
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, schema);
}
if (!isNil(populate)) {
query.populate = convertPopulateQueryParams(populate, schema);
}
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);
}
return {
...rest,
...query
};
};
return {
private_convertSortQueryParams: convertSortQueryParams,
private_convertStartQueryParams: convertStartQueryParams,
private_convertLimitQueryParams: convertLimitQueryParams,
private_convertPopulateQueryParams: convertPopulateQueryParams,
private_convertFiltersQueryParams: convertFiltersQueryParams,
private_convertFieldsQueryParams: convertFieldsQueryParams,
transformQueryParams
};
};
export { createTransformer };
//# sourceMappingURL=convert-query-params.mjs.map