513 lines
21 KiB
JavaScript
513 lines
21 KiB
JavaScript
'use strict';
|
|
|
|
var _ = require('lodash');
|
|
var fp = require('lodash/fp');
|
|
var contentTypes = require('./content-types.js');
|
|
var errors = require('./errors.js');
|
|
var operators = require('./operators.js');
|
|
var parseType = require('./parse-type.js');
|
|
|
|
const { ID_ATTRIBUTE, DOC_ID_ATTRIBUTE, PUBLISHED_AT_ATTRIBUTE } = contentTypes.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 (!fp.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)=>_.isPlainObject(value);
|
|
const isStringArray = (value)=>fp.isArray(value) && value.every(fp.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 (!fp.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 _.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 = fp.toNumber(startQuery);
|
|
if (!_.isInteger(startAsANumber) || startAsANumber < 0) {
|
|
throw new Error(`convertStartQueryParams expected a positive integer got ${startAsANumber}`);
|
|
}
|
|
return startAsANumber;
|
|
};
|
|
/**
|
|
* Limit query parser
|
|
*/ const convertLimitQueryParams = (limitQuery)=>{
|
|
const limitAsANumber = fp.toNumber(limitQuery);
|
|
if (!_.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 = fp.toNumber(page);
|
|
if (!fp.isInteger(pageVal) || pageVal <= 0) {
|
|
throw new errors.PaginationError(`Invalid 'page' parameter. Expected an integer > 0, received: ${page}`);
|
|
}
|
|
return pageVal;
|
|
};
|
|
const convertPageSizeQueryParams = (pageSize, page)=>{
|
|
const pageSizeVal = fp.toNumber(pageSize);
|
|
if (!fp.isInteger(pageSizeVal) || pageSizeVal <= 0) {
|
|
throw new errors.PaginationError(`Invalid 'pageSize' parameter. Expected an integer > 0, received: ${page}`);
|
|
}
|
|
return pageSizeVal;
|
|
};
|
|
const validatePaginationParams = (page, pageSize, start, limit)=>{
|
|
const isPagePagination = !fp.isNil(page) || !fp.isNil(pageSize);
|
|
const isOffsetPagination = !fp.isNil(start) || !fp.isNil(limit);
|
|
if (isPagePagination && isOffsetPagination) {
|
|
throw new errors.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)=>_.trim(value));
|
|
}
|
|
if (Array.isArray(populate)) {
|
|
// map convert
|
|
return _.uniq(populate.flatMap((value)=>{
|
|
if (typeof value !== 'string') {
|
|
throw new InvalidPopulateError();
|
|
}
|
|
return value.split(',').map((value)=>_.trim(value));
|
|
}));
|
|
}
|
|
if (_.isPlainObject(populate)) {
|
|
return convertPopulateObject(populate, schema);
|
|
}
|
|
throw new InvalidPopulateError();
|
|
};
|
|
const hasPopulateFragmentDefined = (populate)=>{
|
|
return typeof populate === 'object' && 'on' in populate && !fp.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 (_.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 (_.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 = contentTypes.isDynamicZoneAttribute(attribute) || contentTypes.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 (_.isString(subPopulate)) {
|
|
return parseType({
|
|
type: 'boolean',
|
|
value: subPopulate,
|
|
forceCast: true
|
|
});
|
|
}
|
|
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) {
|
|
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 (!fp.isNil(page)) {
|
|
query.page = convertPageQueryParams(page);
|
|
}
|
|
if (!fp.isNil(pageSize)) {
|
|
query.pageSize = convertPageSizeQueryParams(pageSize, page);
|
|
}
|
|
if (!fp.isNil(start)) {
|
|
query.offset = convertStartQueryParams(start);
|
|
}
|
|
if (!fp.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)=>_.trim(value));
|
|
// NOTE: Only include the doc id if it's a content type
|
|
if (schema?.modelType === 'contentType') {
|
|
return _.uniq([
|
|
ID_ATTRIBUTE,
|
|
DOC_ID_ATTRIBUTE,
|
|
...fieldsValues
|
|
]);
|
|
}
|
|
return _.uniq([
|
|
ID_ATTRIBUTE,
|
|
...fieldsValues
|
|
]);
|
|
}
|
|
if (isStringArray(fields)) {
|
|
// map convert
|
|
const fieldsValues = fields.flatMap((value)=>convertFieldsQueryParams(value, schema, depth + 1)).filter((v)=>!fp.isNil(v));
|
|
// NOTE: Only include the doc id if it's a content type
|
|
if (schema?.modelType === 'contentType') {
|
|
return _.uniq([
|
|
ID_ATTRIBUTE,
|
|
DOC_ID_ATTRIBUTE,
|
|
...fieldsValues
|
|
]);
|
|
}
|
|
return _.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 (!fp.isObject(filters)) {
|
|
throw new Error('The filters parameter must be an object or an array');
|
|
}
|
|
// Don't mutate the original object
|
|
const filtersCopy = fp.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) || !fp.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 = fp.get(key, schema?.attributes);
|
|
const validKey = operators.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 (fp.isObject(value)) {
|
|
filters[key] = convertAndSanitizeFilters(value, schema);
|
|
}
|
|
// Remove empty objects & arrays
|
|
if (isPlainObject(filters[key]) && fp.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 || !contentTypes.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 (!fp.isNil(status)) {
|
|
convertStatusParams(status, query);
|
|
}
|
|
if (!fp.isNil(_q)) {
|
|
query._q = _q;
|
|
}
|
|
if (!fp.isNil(sort)) {
|
|
query.orderBy = convertSortQueryParams(sort);
|
|
}
|
|
if (!fp.isNil(filters)) {
|
|
query.where = convertFiltersQueryParams(filters, schema);
|
|
}
|
|
if (!fp.isNil(fields)) {
|
|
query.select = convertFieldsQueryParams(fields, schema);
|
|
}
|
|
if (!fp.isNil(populate)) {
|
|
query.populate = convertPopulateQueryParams(populate, schema);
|
|
}
|
|
validatePaginationParams(page, pageSize, start, limit);
|
|
if (!fp.isNil(page)) {
|
|
query.page = convertPageQueryParams(page);
|
|
}
|
|
if (!fp.isNil(pageSize)) {
|
|
query.pageSize = convertPageSizeQueryParams(pageSize, page);
|
|
}
|
|
if (!fp.isNil(start)) {
|
|
query.offset = convertStartQueryParams(start);
|
|
}
|
|
if (!fp.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
|
|
};
|
|
};
|
|
|
|
exports.createTransformer = createTransformer;
|
|
//# sourceMappingURL=convert-query-params.js.map
|