@digicms/cms
Version:
An open source headless CMS solution to create and manage your own API. It provides a powerful dashboard and features to make your life easier. Databases supported: MySQL, MariaDB, PostgreSQL, SQLite
383 lines (327 loc) • 11.1 kB
JavaScript
/**
* Entity validator
* Module that will validate input data for entity creation or edition
*/
;
const { uniqBy, castArray, isNil, isArray, mergeWith } = require('lodash');
const { has, assoc, prop, isObject, isEmpty } = require('lodash/fp');
const strapiUtils = require('@strapi/utils');
const validators = require('./validators');
const { yup, validateYupSchema } = strapiUtils;
const { isMediaAttribute, isScalarAttribute, getWritableAttributes } = strapiUtils.contentTypes;
const { ValidationError } = strapiUtils.errors;
const addMinMax = (validator, { attr, updatedAttribute }) => {
let nextValidator = validator;
if (
Number.isInteger(attr.min) &&
(attr.required || (Array.isArray(updatedAttribute.value) && updatedAttribute.value.length > 0))
) {
nextValidator = nextValidator.min(attr.min);
}
if (Number.isInteger(attr.max)) {
nextValidator = nextValidator.max(attr.max);
}
return nextValidator;
};
const addRequiredValidation = (createOrUpdate) => {
return (validator, { attr: { required } }) => {
let nextValidator = validator;
if (required) {
if (createOrUpdate === 'creation') {
nextValidator = nextValidator.notNil();
} else if (createOrUpdate === 'update') {
nextValidator = nextValidator.notNull();
}
} else {
nextValidator = nextValidator.nullable();
}
return nextValidator;
};
};
const addDefault = (createOrUpdate) => {
return (validator, { attr }) => {
let nextValidator = validator;
if (createOrUpdate === 'creation') {
if (
((attr.type === 'component' && attr.repeatable) || attr.type === 'dynamiczone') &&
!attr.required
) {
nextValidator = nextValidator.default([]);
} else {
nextValidator = nextValidator.default(attr.default);
}
} else {
nextValidator = nextValidator.default(undefined);
}
return nextValidator;
};
};
const preventCast = (validator) => validator.transform((val, originalVal) => originalVal);
const createComponentValidator =
(createOrUpdate) =>
({ attr, updatedAttribute }, { isDraft }) => {
let validator;
const model = strapi.getModel(attr.component);
if (!model) {
throw new Error('Validation failed: Model not found');
}
if (prop('repeatable', attr) === true) {
validator = yup
.array()
.of(
yup.lazy((item) =>
createModelValidator(createOrUpdate)({ model, data: item }, { isDraft }).notNull()
)
);
validator = addRequiredValidation(createOrUpdate)(validator, { attr: { required: true } });
validator = addMinMax(validator, { attr, updatedAttribute });
} else {
validator = createModelValidator(createOrUpdate)({ model, updatedAttribute }, { isDraft });
validator = addRequiredValidation(createOrUpdate)(validator, {
attr: { required: !isDraft && attr.required },
});
}
return validator;
};
const createDzValidator =
(createOrUpdate) =>
({ attr, updatedAttribute }, { isDraft }) => {
let validator;
validator = yup.array().of(
yup.lazy((item) => {
const model = strapi.getModel(prop('__component', item));
const schema = yup
.object()
.shape({
__component: yup.string().required().oneOf(Object.keys(strapi.components)),
})
.notNull();
return model
? schema.concat(createModelValidator(createOrUpdate)({ model, data: item }, { isDraft }))
: schema;
})
);
validator = addRequiredValidation(createOrUpdate)(validator, { attr: { required: true } });
validator = addMinMax(validator, { attr, updatedAttribute });
return validator;
};
const createRelationValidator =
(createOrUpdate) =>
({ attr, updatedAttribute }, { isDraft }) => {
let validator;
if (Array.isArray(updatedAttribute.value)) {
validator = yup.array().of(yup.mixed());
} else {
validator = yup.mixed();
}
validator = addRequiredValidation(createOrUpdate)(validator, {
attr: { required: !isDraft && attr.required },
});
return validator;
};
const createScalarAttributeValidator = (createOrUpdate) => (metas, options) => {
let validator;
if (has(metas.attr.type, validators)) {
validator = validators[metas.attr.type](metas, options);
} else {
// No validators specified - fall back to mixed
validator = yup.mixed();
}
validator = addRequiredValidation(createOrUpdate)(validator, {
attr: { required: !options.isDraft && metas.attr.required },
});
return validator;
};
const createAttributeValidator = (createOrUpdate) => (metas, options) => {
let validator;
if (isMediaAttribute(metas.attr)) {
validator = yup.mixed();
} else if (isScalarAttribute(metas.attr)) {
validator = createScalarAttributeValidator(createOrUpdate)(metas, options);
} else {
if (metas.attr.type === 'component') {
validator = createComponentValidator(createOrUpdate)(metas, options);
} else if (metas.attr.type === 'dynamiczone') {
validator = createDzValidator(createOrUpdate)(metas, options);
} else {
validator = createRelationValidator(createOrUpdate)(metas, options);
}
validator = preventCast(validator);
}
validator = addDefault(createOrUpdate)(validator, metas);
return validator;
};
const createModelValidator =
(createOrUpdate) =>
({ model, data, entity }, options) => {
const writableAttributes = model ? getWritableAttributes(model) : [];
const schema = writableAttributes.reduce((validators, attributeName) => {
const validator = createAttributeValidator(createOrUpdate)(
{
attr: model.attributes[attributeName],
updatedAttribute: { name: attributeName, value: prop(attributeName, data) },
model,
entity,
},
options
);
return assoc(attributeName, validator)(validators);
}, {});
return yup.object().shape(schema);
};
const createValidateEntity =
(createOrUpdate) =>
async (model, data, { isDraft = false } = {}, entity = null) => {
if (!isObject(data)) {
const { displayName } = model.info;
throw new ValidationError(
`Invalid payload submitted for the ${createOrUpdate} of an entity of type ${displayName}. Expected an object, but got ${typeof data}`
);
}
const validator = createModelValidator(createOrUpdate)(
{
model,
data,
entity,
},
{ isDraft }
)
.test('relations-test', 'check that all relations exist', async function (data) {
try {
await checkRelationsExist(buildRelationsStore({ uid: model.uid, data }));
} catch (e) {
return this.createError({
path: this.path,
message: e.message,
});
}
return true;
})
.required();
return validateYupSchema(validator, { strict: false, abortEarly: false })(data);
};
/**
* Builds an object containing all the media and relations being associated with an entity
* @param {String} uid of the model
* @param {Object} data
* @returns {Object}
*/
const buildRelationsStore = ({ uid, data }) => {
if (!uid) {
throw new ValidationError(`Cannot build relations store: "uid" is undefined`);
}
if (isEmpty(data)) {
return {};
}
const currentModel = strapi.getModel(uid);
return Object.keys(currentModel.attributes).reduce((result, attributeName) => {
const attribute = currentModel.attributes[attributeName];
const value = data[attributeName];
if (isNil(value)) {
return result;
}
switch (attribute.type) {
case 'relation':
case 'media': {
if (attribute.relation === 'morphToMany' || attribute.relation === 'morphToOne') {
// TODO: handle polymorphic relations
break;
}
const target = attribute.type === 'media' ? 'plugin::upload.file' : attribute.target;
// As there are multiple formats supported for associating relations
// with an entity, the value here can be an: array, object or number.
let source;
if (Array.isArray(value)) {
source = value;
} else if (isObject(value)) {
source = value.connect ?? value.set ?? [];
} else {
source = castArray(value);
}
const idArray = source.map((v) => ({ id: v.id || v }));
// Update the relationStore to keep track of all associations being made
// with relations and media.
result[target] = result[target] || [];
result[target].push(...idArray);
break;
}
case 'component': {
return castArray(value).reduce((relationsStore, componentValue) => {
if (!attribute.component) {
throw new ValidationError(
`Cannot build relations store from component, component identifier is undefined`
);
}
return mergeWith(
relationsStore,
buildRelationsStore({
uid: attribute.component,
data: componentValue,
}),
(objValue, srcValue) => {
if (isArray(objValue)) {
return objValue.concat(srcValue);
}
}
);
}, result);
}
case 'dynamiczone': {
return value.reduce((relationsStore, dzValue) => {
if (!dzValue.__component) {
throw new ValidationError(
`Cannot build relations store from dynamiczone, component identifier is undefined`
);
}
return mergeWith(
relationsStore,
buildRelationsStore({
uid: dzValue.__component,
data: dzValue,
}),
(objValue, srcValue) => {
if (isArray(objValue)) {
return objValue.concat(srcValue);
}
}
);
}, result);
}
default:
break;
}
return result;
}, {});
};
/**
* Iterate through the relations store and validates that every relation or media
* mentioned exists
*/
const checkRelationsExist = async (relationsStore = {}) => {
const promises = [];
for (const [key, value] of Object.entries(relationsStore)) {
const evaluate = async () => {
const uniqueValues = uniqBy(value, `id`);
const count = await strapi.db.query(key).count({
where: {
id: {
$in: uniqueValues.map((v) => v.id),
},
},
});
if (count !== uniqueValues.length) {
throw new ValidationError(
`${
uniqueValues.length - count
} relation(s) of type ${key} associated with this entity do not exist`
);
}
};
promises.push(evaluate());
}
return Promise.all(promises);
};
module.exports = {
validateEntityCreation: createValidateEntity('creation'),
validateEntityUpdate: createValidateEntity('update'),
};