mirror of
https://github.com/strapi/strapi.git
synced 2025-08-02 13:58:18 +00:00
feat(strapi): entity validator
This commit is contained in:
parent
d0958eec24
commit
00d6962b5e
@ -5,15 +5,16 @@ const entityValidator = require('../entity-validator');
|
|||||||
describe('Entity validator', () => {
|
describe('Entity validator', () => {
|
||||||
describe('Published input', () => {
|
describe('Published input', () => {
|
||||||
describe('General Errors', () => {
|
describe('General Errors', () => {
|
||||||
it('Throws a badRequest error on invalid input', async () => {
|
let model;
|
||||||
global.strapi = {
|
global.strapi = {
|
||||||
errors: {
|
errors: {
|
||||||
badRequest: jest.fn(),
|
badRequest: jest.fn(),
|
||||||
},
|
},
|
||||||
getModel: () => ({}),
|
getModel: () => model,
|
||||||
};
|
};
|
||||||
|
|
||||||
const model = {
|
it('Throws a badRequest error on invalid input', async () => {
|
||||||
|
model = {
|
||||||
attributes: {
|
attributes: {
|
||||||
title: {
|
title: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -45,7 +46,7 @@ describe('Entity validator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Returns data on valid input', async () => {
|
it('Returns data on valid input', async () => {
|
||||||
const model = {
|
model = {
|
||||||
attributes: {
|
attributes: {
|
||||||
title: {
|
title: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -62,7 +63,7 @@ describe('Entity validator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Returns casted data when possible', async () => {
|
it('Returns casted data when possible', async () => {
|
||||||
const model = {
|
model = {
|
||||||
attributes: {
|
attributes: {
|
||||||
title: {
|
title: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -85,14 +86,7 @@ describe('Entity validator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Throws on required not respected', async () => {
|
test('Throws on required not respected', async () => {
|
||||||
global.strapi = {
|
model = {
|
||||||
errors: {
|
|
||||||
badRequest: jest.fn(),
|
|
||||||
},
|
|
||||||
getModel: () => ({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const model = {
|
|
||||||
attributes: {
|
attributes: {
|
||||||
title: {
|
title: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -141,7 +135,7 @@ describe('Entity validator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Supports custom field types', async () => {
|
it('Supports custom field types', async () => {
|
||||||
const model = {
|
model = {
|
||||||
attributes: {
|
attributes: {
|
||||||
uuid: {
|
uuid: {
|
||||||
type: 'uuid',
|
type: 'uuid',
|
||||||
@ -166,7 +160,7 @@ describe('Entity validator', () => {
|
|||||||
errors: {
|
errors: {
|
||||||
badRequest: jest.fn(),
|
badRequest: jest.fn(),
|
||||||
},
|
},
|
||||||
getModel: () => ({}),
|
getModel: () => model,
|
||||||
};
|
};
|
||||||
|
|
||||||
const model = {
|
const model = {
|
||||||
@ -202,13 +196,6 @@ describe('Entity validator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Throws on max length not respected', async () => {
|
test('Throws on max length not respected', async () => {
|
||||||
global.strapi = {
|
|
||||||
errors: {
|
|
||||||
badRequest: jest.fn(),
|
|
||||||
},
|
|
||||||
getModel: () => ({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const model = {
|
const model = {
|
||||||
attributes: {
|
attributes: {
|
||||||
title: {
|
title: {
|
||||||
@ -333,7 +320,7 @@ describe('Entity validator', () => {
|
|||||||
errors: {
|
errors: {
|
||||||
badRequest: jest.fn(),
|
badRequest: jest.fn(),
|
||||||
},
|
},
|
||||||
getModel: () => ({}),
|
getModel: () => model,
|
||||||
};
|
};
|
||||||
|
|
||||||
const model = {
|
const model = {
|
||||||
@ -461,6 +448,13 @@ describe('Entity validator', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
global.strapi = {
|
||||||
|
errors: {
|
||||||
|
badRequest: jest.fn(),
|
||||||
|
},
|
||||||
|
getModel: () => model,
|
||||||
|
};
|
||||||
|
|
||||||
const input = { title: 'tooSmall' };
|
const input = { title: 'tooSmall' };
|
||||||
|
|
||||||
expect.hasAssertions();
|
expect.hasAssertions();
|
||||||
@ -470,13 +464,6 @@ describe('Entity validator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Throws on max length not respected', async () => {
|
test('Throws on max length not respected', async () => {
|
||||||
global.strapi = {
|
|
||||||
errors: {
|
|
||||||
badRequest: jest.fn(),
|
|
||||||
},
|
|
||||||
getModel: () => ({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const model = {
|
const model = {
|
||||||
attributes: {
|
attributes: {
|
||||||
title: {
|
title: {
|
||||||
|
@ -4,28 +4,22 @@ const createEntityService = require('..');
|
|||||||
const entityValidator = require('../../entity-validator');
|
const entityValidator = require('../../entity-validator');
|
||||||
|
|
||||||
describe('Entity service triggers webhooks', () => {
|
describe('Entity service triggers webhooks', () => {
|
||||||
global.strapi = {
|
|
||||||
getModel: () => ({}),
|
|
||||||
config: {
|
|
||||||
get: () => [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let instance;
|
let instance;
|
||||||
const eventHub = { emit: jest.fn() };
|
const eventHub = { emit: jest.fn() };
|
||||||
let entity = { attr: 'value' };
|
let entity = { attr: 'value' };
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
instance = createEntityService({
|
const model = {
|
||||||
strapi: {
|
|
||||||
getModel: () => ({
|
|
||||||
kind: 'singleType',
|
kind: 'singleType',
|
||||||
modelName: 'test-model',
|
modelName: 'test-model',
|
||||||
privateAttributes: [],
|
privateAttributes: [],
|
||||||
attributes: {
|
attributes: {
|
||||||
attr: { type: 'string' },
|
attr: { type: 'string' },
|
||||||
},
|
},
|
||||||
}),
|
};
|
||||||
|
instance = createEntityService({
|
||||||
|
strapi: {
|
||||||
|
getModel: () => model,
|
||||||
},
|
},
|
||||||
db: {
|
db: {
|
||||||
query: () => ({
|
query: () => ({
|
||||||
@ -41,6 +35,13 @@ describe('Entity service triggers webhooks', () => {
|
|||||||
eventHub,
|
eventHub,
|
||||||
entityValidator,
|
entityValidator,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
global.strapi = {
|
||||||
|
getModel: () => model,
|
||||||
|
config: {
|
||||||
|
get: () => [],
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Emit event: Create', async () => {
|
test('Emit event: Create', async () => {
|
||||||
|
@ -157,16 +157,15 @@ describe('Entity service', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const fakeQuery = (uid) => ({
|
const fakeQuery = (uid) => ({
|
||||||
count: jest.fn(() => 0),
|
|
||||||
create: jest.fn(({ data }) => data),
|
create: jest.fn(({ data }) => data),
|
||||||
findWithCount: jest.fn(({ where }) => {
|
count: jest.fn(({ where }) => {
|
||||||
const ret = [];
|
let ret = 0;
|
||||||
where.id.$in.forEach((id) => {
|
where.id.$in.forEach((id) => {
|
||||||
const entity = fakeEntities[uid][id];
|
const entity = fakeEntities[uid][id];
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
ret.push(entity);
|
ret += 1;
|
||||||
});
|
});
|
||||||
return [ret, ret.length];
|
return ret;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -372,14 +371,14 @@ describe('Entity service', () => {
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const fakeQuery = (key) => ({
|
const fakeQuery = (key) => ({
|
||||||
findOne: jest.fn(({ where }) => fakeEntities[key][where.id]),
|
findOne: jest.fn(({ where }) => fakeEntities[key][where.id]),
|
||||||
findWithCount: jest.fn(({ where }) => {
|
count: jest.fn(({ where }) => {
|
||||||
const ret = [];
|
let ret = 0;
|
||||||
where.id.$in.forEach((id) => {
|
where.id.$in.forEach((id) => {
|
||||||
const entity = fakeEntities[key][id];
|
const entity = fakeEntities[key][id];
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
ret.push(entity);
|
ret += 1;
|
||||||
});
|
});
|
||||||
return [ret, ret.length];
|
return ret;
|
||||||
}),
|
}),
|
||||||
update: jest.fn(({ where }) => ({
|
update: jest.fn(({ where }) => ({
|
||||||
...fakeEntities[key][where.id],
|
...fakeEntities[key][where.id],
|
||||||
|
@ -205,107 +205,6 @@ const createModelValidator =
|
|||||||
return yup.object().shape(schema);
|
return yup.object().shape(schema);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a map containing all the media and relations being associated with an entity
|
|
||||||
* @param {String} uid of the model
|
|
||||||
* @param {Object} data
|
|
||||||
* @param {Map} relationsMap to be updated and returned
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const buildRelationsMap = (uid, data, relationsMap = new Map()) => {
|
|
||||||
const currentModel = strapi.getModel(uid);
|
|
||||||
if (isEmpty(currentModel)) return;
|
|
||||||
|
|
||||||
Object.keys(currentModel.attributes).forEach((attributeName) => {
|
|
||||||
const attribute = currentModel.attributes[attributeName];
|
|
||||||
const value = data[attributeName];
|
|
||||||
if (isEmpty(value) || isNil(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (attribute.type) {
|
|
||||||
case 'relation': {
|
|
||||||
if (!attribute.target) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the attribute type is a relation keep track of all
|
|
||||||
// associations being made with relations. These will later be checked
|
|
||||||
// against the DB to confirm they exist
|
|
||||||
let directValue = [];
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
directValue = value.map((v) => ({ id: v }));
|
|
||||||
}
|
|
||||||
relationsMap.set(
|
|
||||||
attribute.target,
|
|
||||||
(relationsMap.get(attribute.target) || []).concat(
|
|
||||||
...(value.connect || value.set || directValue)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'media': {
|
|
||||||
// For media attribute types keep track of all media associated with
|
|
||||||
// this entity. These will later be checked against the DB to confirm
|
|
||||||
// they exist
|
|
||||||
const mediaUID = 'plugin::upload.file';
|
|
||||||
castArray(value).forEach((v) =>
|
|
||||||
relationsMap.set(mediaUID, [...(relationsMap.get(mediaUID) || []), { id: v.id || v }])
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'component': {
|
|
||||||
return castArray(value).forEach((componentValue) =>
|
|
||||||
buildRelationsMap(attribute.component, componentValue, relationsMap)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'dynamiczone': {
|
|
||||||
return value.forEach((dzValue) =>
|
|
||||||
buildRelationsMap(dzValue.__component, dzValue, relationsMap)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return relationsMap;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iterate through the relations map and validate that every relation or media
|
|
||||||
* mentioned exists
|
|
||||||
*/
|
|
||||||
const checkRelationsExist = async (relationsMap = new Map()) => {
|
|
||||||
const promises = [];
|
|
||||||
for (const [key, value] of relationsMap) {
|
|
||||||
const evaluate = async () => {
|
|
||||||
const uniqueValues = uniqBy(value, `id`);
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
const [entities, count] = await strapi.db.query(key).findWithCount({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
$in: uniqueValues.map((v) => Number(v.id)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (count !== uniqueValues.length) {
|
|
||||||
const missingEntities = uniqueValues.filter(
|
|
||||||
(value) => !entities.find((entity) => entity.id === value.id)
|
|
||||||
);
|
|
||||||
throw new ValidationError(
|
|
||||||
`Relations of type ${key} associated with this entity do not exist. IDs: ${missingEntities
|
|
||||||
.map((entity) => entity.id)
|
|
||||||
.join(',')}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
promises.push(evaluate());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.all(promises);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createValidateEntity =
|
const createValidateEntity =
|
||||||
(createOrUpdate) =>
|
(createOrUpdate) =>
|
||||||
async (model, data, { isDraft = false } = {}, entity = null) => {
|
async (model, data, { isDraft = false } = {}, entity = null) => {
|
||||||
@ -327,7 +226,7 @@ const createValidateEntity =
|
|||||||
)
|
)
|
||||||
.test('relations-test', 'check that all relations exist', async function (data) {
|
.test('relations-test', 'check that all relations exist', async function (data) {
|
||||||
try {
|
try {
|
||||||
await checkRelationsExist(buildRelationsMap(model.uid, data) || new Map());
|
await checkRelationsExist(buildRelationsStore(model.uid, data) || {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return this.createError({
|
return this.createError({
|
||||||
path: this.path,
|
path: this.path,
|
||||||
@ -341,6 +240,100 @@ const createValidateEntity =
|
|||||||
return validateYupSchema(validator, { strict: false, abortEarly: false })(data);
|
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
|
||||||
|
* @param {Object} relationsStore to be updated and returned
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
const buildRelationsStore = (uid, data, relationsStore = {}) => {
|
||||||
|
const currentModel = strapi.getModel(uid);
|
||||||
|
|
||||||
|
Object.keys(currentModel?.attributes || {}).forEach((attributeName) => {
|
||||||
|
const attribute = currentModel.attributes[attributeName];
|
||||||
|
|
||||||
|
const value = data[attributeName];
|
||||||
|
if (isEmpty(value) || isNil(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (attribute.type) {
|
||||||
|
case 'relation': {
|
||||||
|
if (!attribute.target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the attribute type is a relation keep track of all
|
||||||
|
// associations being made with relations.
|
||||||
|
let directValue = [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
directValue = value.map((v) => ({ id: v }));
|
||||||
|
}
|
||||||
|
relationsStore[attribute.target] = relationsStore[attribute.target] || [];
|
||||||
|
relationsStore[attribute.target].push(...(value.connect || value.set || directValue));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'media': {
|
||||||
|
// For media attribute types keep track of all media associated with
|
||||||
|
// this entity.
|
||||||
|
const mediaUID = 'plugin::upload.file';
|
||||||
|
castArray(value).forEach((v) => {
|
||||||
|
relationsStore[mediaUID] = relationsStore[mediaUID] || [];
|
||||||
|
relationsStore[mediaUID].push({ id: v.id || v });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'component': {
|
||||||
|
return castArray(value).forEach((componentValue) =>
|
||||||
|
buildRelationsStore(attribute.component, componentValue, relationsStore)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'dynamiczone': {
|
||||||
|
return value.forEach((dzValue) =>
|
||||||
|
buildRelationsStore(dzValue.__component, dzValue, relationsStore)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return relationsStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`);
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const count = await strapi.db.query(key).count({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
$in: uniqueValues.map((v) => Number(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 = {
|
module.exports = {
|
||||||
validateEntityCreation: createValidateEntity('creation'),
|
validateEntityCreation: createValidateEntity('creation'),
|
||||||
validateEntityUpdate: createValidateEntity('update'),
|
validateEntityUpdate: createValidateEntity('update'),
|
||||||
|
@ -163,9 +163,7 @@ describe('Create Strapi API End to End', () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(JSON.parse(res.error.text).error.message).toContain(
|
expect(JSON.parse(res.error.text).error.message).toContain(
|
||||||
`Relations of type api::tag.tag associated with this entity do not exist. IDs: ${entry.tags.join(
|
`1 relation(s) of type api::tag.tag associated with this entity do not exist`
|
||||||
','
|
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -243,9 +241,7 @@ describe('Create Strapi API End to End', () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(JSON.parse(res.error.text).error.message).toContain(
|
expect(JSON.parse(res.error.text).error.message).toContain(
|
||||||
`Relations of type api::tag.tag associated with this entity do not exist. IDs: ${entry.tags.join(
|
`3 relation(s) of type api::tag.tag associated with this entity do not exist`
|
||||||
','
|
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user