Add UID validation routes for CTM livechecks

Signed-off-by: Alexandre Bodin <bodin.alex@gmail.com>
This commit is contained in:
Alexandre Bodin 2020-02-19 10:54:44 +01:00
parent a6708c3e46
commit db03d9d07d
11 changed files with 822 additions and 90 deletions

View File

@ -18,7 +18,8 @@
"type": "string"
},
"slug": {
"type": "uid"
"type": "uid",
"targetField": "name"
},
"price_range": {
"enum": ["very_cheap", "cheap", "average", "expensive", "very_expensive"],

View File

@ -48,6 +48,22 @@
"policies": []
}
},
{
"method": "POST",
"path": "/explorer/uid/generate",
"handler": "ContentManager.generateUID",
"config": {
"policies": []
}
},
{
"method": "POST",
"path": "/explorer/uid/check-availability",
"handler": "ContentManager.checkUIDAvailability",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/explorer/:model",

View File

@ -1,27 +1,57 @@
'use strict';
const _ = require('lodash');
const parseMultipartBody = require('../utils/parse-multipart');
const {
validateGenerateUIDInput,
validateCheckUIDAvailabilityInput,
validateUIDField,
} = require('./validation');
module.exports = {
async generateUID(ctx) {
const { contentTypeUID, field, data } = await validateGenerateUIDInput(ctx.request.body);
await validateUIDField(contentTypeUID, field);
const uidService = strapi.plugins['content-manager'].services.uid;
ctx.body = {
data: await uidService.generateUIDField({ contentTypeUID, field, data }),
};
},
async checkUIDAvailability(ctx) {
const { contentTypeUID, field, value } = await validateCheckUIDAvailabilityInput(
ctx.request.body
);
await validateUIDField(contentTypeUID, field);
const uidService = strapi.plugins['content-manager'].services.uid;
const isAvailable = await uidService.checkUIDAvailability({ contentTypeUID, field, value });
ctx.body = {
isAvailable,
suggestion: !isAvailable
? await uidService.findUniqueUID({ contentTypeUID, field, value })
: null,
};
},
/**
* Returns a list of entities of a content-type matching the query parameters
*/
async find(ctx) {
const contentManagerService =
strapi.plugins['content-manager'].services.contentmanager;
const contentManagerService = strapi.plugins['content-manager'].services.contentmanager;
let entities = [];
if (_.has(ctx.request.query, '_q')) {
entities = await contentManagerService.search(
ctx.params,
ctx.request.query
);
entities = await contentManagerService.search(ctx.params, ctx.request.query);
} else {
entities = await contentManagerService.fetchAll(
ctx.params,
ctx.request.query
);
entities = await contentManagerService.fetchAll(ctx.params, ctx.request.query);
}
ctx.body = entities;
@ -31,8 +61,7 @@ module.exports = {
* Returns an entity of a content type by id
*/
async findOne(ctx) {
const contentManagerService =
strapi.plugins['content-manager'].services.contentmanager;
const contentManagerService = strapi.plugins['content-manager'].services.contentmanager;
const entry = await contentManagerService.fetch(ctx.params);
@ -48,15 +77,11 @@ module.exports = {
* Returns a count of entities of a content type matching query parameters
*/
async count(ctx) {
const contentManagerService =
strapi.plugins['content-manager'].services.contentmanager;
const contentManagerService = strapi.plugins['content-manager'].services.contentmanager;
let count;
if (_.has(ctx.request.query, '_q')) {
count = await contentManagerService.countSearch(
ctx.params,
ctx.request.query
);
count = await contentManagerService.countSearch(ctx.params, ctx.request.query);
} else {
count = await contentManagerService.count(ctx.params, ctx.request.query);
}
@ -70,36 +95,24 @@ module.exports = {
* Creates an entity of a content type
*/
async create(ctx) {
const contentManagerService =
strapi.plugins['content-manager'].services.contentmanager;
const contentManagerService = strapi.plugins['content-manager'].services.contentmanager;
const { model } = ctx.params;
try {
if (ctx.is('multipart')) {
const { data, files } = parseMultipartBody(ctx);
ctx.body = await contentManagerService.create(data, {
files,
model,
});
} else {
// Create an entry using `queries` system
ctx.body = await contentManagerService.create(ctx.request.body, {
model,
});
}
strapi.emit('didCreateFirstContentTypeEntry', ctx.params);
} catch (error) {
strapi.log.error(error);
ctx.badRequest(null, [
{
messages: [
{ id: error.message, message: error.message, field: error.field },
],
},
]);
if (ctx.is('multipart')) {
const { data, files } = parseMultipartBody(ctx);
ctx.body = await contentManagerService.create(data, {
files,
model,
});
} else {
// Create an entry using `queries` system
ctx.body = await contentManagerService.create(ctx.request.body, {
model,
});
}
strapi.emit('didCreateFirstContentTypeEntry', ctx.params);
},
/**
@ -108,31 +121,19 @@ module.exports = {
async update(ctx) {
const { id, model } = ctx.params;
const contentManagerService =
strapi.plugins['content-manager'].services.contentmanager;
const contentManagerService = strapi.plugins['content-manager'].services.contentmanager;
try {
if (ctx.is('multipart')) {
const { data, files } = parseMultipartBody(ctx);
ctx.body = await contentManagerService.edit({ id }, data, {
files,
model,
});
} else {
// Return the last one which is the current model.
ctx.body = await contentManagerService.edit({ id }, ctx.request.body, {
model,
});
}
} catch (error) {
strapi.log.error(error);
ctx.badRequest(null, [
{
messages: [
{ id: error.message, message: error.message, field: error.field },
],
},
]);
if (ctx.is('multipart')) {
const { data, files } = parseMultipartBody(ctx);
ctx.body = await contentManagerService.edit({ id }, data, {
files,
model,
});
} else {
// Return the last one which is the current model.
ctx.body = await contentManagerService.edit({ id }, ctx.request.body, {
model,
});
}
},
@ -140,8 +141,7 @@ module.exports = {
* Deletes one entity of a content type matching a query
*/
async delete(ctx) {
const contentManagerService =
strapi.plugins['content-manager'].services.contentmanager;
const contentManagerService = strapi.plugins['content-manager'].services.contentmanager;
ctx.body = await contentManagerService.delete(ctx.params);
},
@ -150,12 +150,8 @@ module.exports = {
* Deletes multiple entities of a content type matching a query
*/
async deleteMany(ctx) {
const contentManagerService =
strapi.plugins['content-manager'].services.contentmanager;
const contentManagerService = strapi.plugins['content-manager'].services.contentmanager;
ctx.body = await contentManagerService.deleteMany(
ctx.params,
ctx.request.query
);
ctx.body = await contentManagerService.deleteMany(ctx.params, ctx.request.query);
},
};

View File

@ -1,7 +1,7 @@
'use strict';
const yup = require('yup');
const { formatYupErrors } = require('strapi-utils');
const _ = require('lodash');
const { yup, formatYupErrors } = require('strapi-utils');
const createModelConfigurationSchema = require('./model-configuration');
@ -19,7 +19,62 @@ const validateKind = kind => {
.catch(error => Promise.reject(formatYupErrors(error)));
};
const validateGenerateUIDInput = data => {
return yup
.object({
contentTypeUID: yup.string().required(),
field: yup.string().required(),
data: yup.object().required(),
})
.validate(data, {
strict: true,
abortEarly: false,
})
.catch(error => {
throw strapi.errors.badRequest('ValidationError', formatYupErrors(error));
});
};
const validateCheckUIDAvailabilityInput = data => {
return yup
.object({
contentTypeUID: yup.string().required(),
field: yup.string().required(),
value: yup
.string()
.matches(new RegExp('^[A-Za-z0-9-_.~]*$'))
.required(),
})
.validate(data, {
strict: true,
abortEarly: false,
})
.catch(error => {
throw strapi.errors.badRequest('ValidationError', formatYupErrors(error));
});
};
const validateUIDField = (contentTypeUID, field) => {
const model = strapi.contentTypes[contentTypeUID];
if (!model) {
throw strapi.errors.badRequest('ValidationError', ['ContentType not found']);
}
if (
!_.has(model, ['attributes', field]) ||
_.get(model, ['attributes', field, 'type']) !== 'uid'
) {
throw strapi.errors.badRequest('ValidationError', {
field: ['field must be a valid `uid` attribute'],
});
}
};
module.exports = {
createModelConfigurationSchema,
validateKind,
validateGenerateUIDInput,
validateCheckUIDAvailabilityInput,
validateUIDField,
};

View File

@ -1,6 +1,6 @@
'use strict';
const yup = require('yup');
const { yup } = require('strapi-utils');
const {
isListable,
hasRelationAttribute,
@ -26,9 +26,7 @@ module.exports = (model, schema, opts = {}) =>
.noUnknown();
const createSettingsSchema = (model, schema) => {
const validAttributes = Object.keys(schema.attributes).filter(key =>
isListable(schema, key)
);
const validAttributes = Object.keys(schema.attributes).filter(key => isListable(schema, key));
return yup
.object()
@ -98,14 +96,11 @@ const createMetadasSchema = (model, schema) => {
const createArrayTest = ({ allowUndefined = false } = {}) => ({
name: 'isArray',
message: '${path} is required and must be an array',
test: val =>
allowUndefined === true && val === undefined ? true : Array.isArray(val),
test: val => (allowUndefined === true && val === undefined ? true : Array.isArray(val)),
});
const createLayoutsSchema = (model, schema, opts = {}) => {
const validAttributes = Object.keys(schema.attributes).filter(key =>
isListable(schema, key)
);
const validAttributes = Object.keys(schema.attributes).filter(key => isListable(schema, key));
const editAttributes = Object.keys(schema.attributes).filter(key =>
hasEditableAttribute(schema, key)

View File

@ -9,6 +9,7 @@
"required": true
},
"dependencies": {
"@sindresorhus/slugify": "0.9.1",
"classnames": "^2.2.6",
"codemirror": "^5.46.0",
"draft-js": "^0.10.5",

View File

@ -0,0 +1,238 @@
const uidService = require('../uid');
describe('Test uid service', () => {
describe('generateUIDField', () => {
test('Uses modelName if no targetField specified or set', async () => {
global.strapi = {
contentTypes: {
'my-model': {
modelName: 'myTestModel',
attributes: {
slug: {
type: 'uid',
},
},
},
},
db: {
query() {
return {
find: async () => [],
};
},
},
};
const uid = await uidService.generateUIDField({
contentTypeUID: 'my-model',
field: 'slug',
data: {},
});
expect(uid).toBe('my-test-model');
});
test('Calls findUniqueUID', async () => {
const tmpFn = uidService.findUniqueUID;
uidService.findUniqueUID = jest.fn(v => v);
global.strapi = {
contentTypes: {
'my-model': {
modelName: 'myTestModel',
attributes: {
title: {
type: 'string',
},
slug: {
type: 'uid',
targetField: 'title',
},
},
},
},
db: {
query() {
return {
find: async () => [],
};
},
},
};
await uidService.generateUIDField({
contentTypeUID: 'my-model',
field: 'slug',
data: {
title: 'Test title',
},
});
await uidService.generateUIDField({
contentTypeUID: 'my-model',
field: 'slug',
data: {},
});
expect(uidService.findUniqueUID).toHaveBeenCalledTimes(2);
uidService.findUniqueUID = tmpFn;
});
test('Uses targetField value for generation', async () => {
const find = jest.fn(async () => {
return [{ slug: 'test-title' }];
});
global.strapi = {
contentTypes: {
'my-model': {
modelName: 'myTestModel',
attributes: {
title: {
type: 'string',
},
slug: {
type: 'uid',
targetField: 'title',
},
},
},
},
db: {
query() {
return {
find,
};
},
},
};
const uid = await uidService.generateUIDField({
contentTypeUID: 'my-model',
field: 'slug',
data: {
title: 'Test title',
},
});
expect(uid).toBe('test-title-1');
// change find response
global.strapi.db.query = () => ({ find: jest.fn(async () => []) });
const uidWithEmptyTarget = await uidService.generateUIDField({
contentTypeUID: 'my-model',
field: 'slug',
data: {
title: '',
},
});
expect(uidWithEmptyTarget).toBe('my-test-model');
});
});
describe('findUniqueUID', () => {
test('Finds closest match', async () => {
const find = jest.fn(async () => {
return [
{ slug: 'my-test-model' },
{ slug: 'my-test-model-1' },
// it finds the quickest match possible
{ slug: 'my-test-model-4' },
];
});
global.strapi = {
contentTypes: {
'my-model': {
modelName: 'myTestModel',
attributes: {
slug: {
type: 'uid',
},
},
},
},
db: {
query() {
return {
find,
};
},
},
};
const uid = await uidService.findUniqueUID({
contentTypeUID: 'my-model',
field: 'slug',
value: 'my-test-model',
});
expect(uid).toBe('my-test-model-2');
});
test('Calls db find', async () => {
const find = jest.fn(async () => {
return [];
});
global.strapi = {
contentTypes: {
'my-model': {
modelName: 'myTestModel',
attributes: {
slug: {
type: 'uid',
},
},
},
},
db: {
query() {
return {
find,
};
},
},
};
await uidService.findUniqueUID({
contentTypeUID: 'my-model',
field: 'slug',
value: 'my-test-model',
});
expect(find).toHaveBeenCalledWith({
slug_contains: 'my-test-model',
_limit: -1,
});
});
});
describe('CheckUIDAvailability', () => {
test('Counts the data in db', async () => {
const count = jest.fn(async () => 0);
global.strapi = {
db: {
query() {
return {
count,
};
},
},
};
const isAvailable = await uidService.checkUIDAvailability({
contentTypeUID: 'my-model',
field: 'slug',
value: 'my-test-model',
});
expect(count).toHaveBeenCalledWith({ slug: 'my-test-model' });
expect(isAvailable).toBe(true);
});
});
});

View File

@ -0,0 +1,63 @@
'use strict';
const _ = require('lodash');
const slugify = require('@sindresorhus/slugify');
module.exports = {
async generateUIDField({ contentTypeUID, field, data }) {
const contentType = strapi.contentTypes[contentTypeUID];
const { attributes } = contentType;
const targetField = _.get(attributes, [field, 'targetField']);
const targetValue = _.get(data, targetField);
if (!_.isEmpty(targetValue)) {
return this.findUniqueUID({
contentTypeUID,
field,
value: slugify(targetValue),
});
}
return this.findUniqueUID({
contentTypeUID,
field,
value: slugify(contentType.modelName),
});
},
async findUniqueUID({ contentTypeUID, field, value }) {
const query = strapi.db.query(contentTypeUID);
const possibleColisions = await query
.find({
[`${field}_contains`]: value,
_limit: -1,
})
.then(results => results.map(result => result[field]));
if (possibleColisions.length === 0) {
return value;
}
let i = 1;
let tmpUId = `${value}-${i}`;
while (possibleColisions.includes(tmpUId)) {
i += 1;
tmpUId = `${value}-${i}`;
}
return tmpUId;
},
async checkUIDAvailability({ contentTypeUID, field, value }) {
const query = strapi.db.query(contentTypeUID);
const count = await query.count({
[field]: value,
});
if (count > 0) return false;
return true;
},
};

View File

@ -0,0 +1,367 @@
// Helpers.
const { registerAndLogin } = require('../../../../test/helpers/auth');
const createModelsUtils = require('../../../../test/helpers/models');
const { createAuthRequest } = require('../../../../test/helpers/request');
let modelsUtils;
let rq;
let uid = 'application::uid-model.uid-model';
describe('Content Manager single types', () => {
beforeAll(async () => {
const token = await registerAndLogin();
rq = createAuthRequest(token);
modelsUtils = createModelsUtils({ rq });
await modelsUtils.createContentType({
kind: 'collectionType',
name: 'uid-model',
attributes: {
title: {
type: 'string',
},
slug: {
type: 'uid',
targetField: 'title',
},
otherField: {
type: 'integer',
},
},
});
}, 60000);
afterAll(() => modelsUtils.deleteContentType('uid-model'), 60000);
describe('Generate UID', () => {
test('Throws if input is not provided', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {},
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
statusCode: 400,
error: 'Bad Request',
message: 'ValidationError',
data: {
contentTypeUID: expect.arrayContaining([expect.stringMatching('required field')]),
field: expect.arrayContaining([expect.stringMatching('required field')]),
data: expect.arrayContaining([expect.stringMatching('required field')]),
},
});
});
test('Throws when contentType is not found', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: 'non-existent',
field: 'slug',
data: {},
},
});
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: 'ValidationError',
data: ['ContentType not found'],
});
});
test('Throws when field is not a uid field', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'otherField',
data: {},
},
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
statusCode: 400,
error: 'Bad Request',
message: 'ValidationError',
data: {
field: [expect.stringMatching('must be a valid `uid` attribute')],
},
});
});
test('Generates a unique field when not targetField', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
data: {},
},
});
expect(res.statusCode).toBe(200);
expect(res.body.data).toBe('uid-model');
await rq({
url: `/content-manager/explorer/${uid}`,
method: 'POST',
body: {
slug: res.body.data,
},
});
const secondRes = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
data: {},
},
});
expect(secondRes.statusCode).toBe(200);
expect(secondRes.body.data).toBe('uid-model-1');
});
test('Generates a unique field based on targetField', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
data: {
title: 'This is a super title',
},
},
});
expect(res.statusCode).toBe(200);
expect(res.body.data).toBe('this-is-a-super-title');
await rq({
url: `/content-manager/explorer/${uid}`,
method: 'POST',
body: {
slug: res.body.data,
},
});
const secondRes = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
data: {
title: 'This is a super title',
},
},
});
expect(secondRes.statusCode).toBe(200);
expect(secondRes.body.data).toBe('this-is-a-super-title-1');
});
test('Avoids colisions with already generated uids', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
data: {
title: 'My title',
},
},
});
expect(res.statusCode).toBe(200);
expect(res.body.data).toBe('my-title');
await rq({
url: `/content-manager/explorer/${uid}`,
method: 'POST',
body: {
slug: res.body.data,
},
});
const secondRes = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
data: {
title: 'My title',
},
},
});
expect(secondRes.statusCode).toBe(200);
expect(secondRes.body.data).toBe('my-title-1');
await rq({
url: `/content-manager/explorer/${uid}`,
method: 'POST',
body: {
slug: secondRes.body.data,
},
});
const thridRes = await rq({
url: `/content-manager/explorer/uid/generate`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
data: {
title: 'My title 1',
},
},
});
expect(thridRes.statusCode).toBe(200);
expect(thridRes.body.data).toBe('my-title-1-1');
});
});
describe('Check UID availability', () => {
test('Throws if input is not provided', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/check-availability`,
method: 'POST',
body: {},
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
statusCode: 400,
error: 'Bad Request',
message: 'ValidationError',
data: {
contentTypeUID: expect.arrayContaining([expect.stringMatching('required field')]),
field: expect.arrayContaining([expect.stringMatching('required field')]),
value: expect.arrayContaining([expect.stringMatching('required field')]),
},
});
});
test('Throws on invalid uid value', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/check-availability`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
value: 'Invalid UID valuéééé',
},
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
data: {
value: expect.arrayContaining([expect.stringMatching('must match')]),
},
});
});
test('Throws when contentType is not found', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/check-availability`,
method: 'POST',
body: {
contentTypeUID: 'non-existent',
field: 'slug',
value: 'some-slug',
},
});
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: 'ValidationError',
data: ['ContentType not found'],
});
});
test('Throws when field is not a uid field', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/check-availability`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'otherField',
value: 'some-slug',
},
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
statusCode: 400,
error: 'Bad Request',
message: 'ValidationError',
data: {
field: [expect.stringMatching('must be a valid `uid` attribute')],
},
});
});
test('Checks availability', async () => {
const res = await rq({
url: `/content-manager/explorer/uid/check-availability`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
value: 'some-available-slug',
},
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
isAvailable: true,
suggestion: null,
});
});
test('Gives a suggestion when not available', async () => {
// create data
await rq({
url: `/content-manager/explorer/${uid}`,
method: 'POST',
body: {
slug: 'custom-slug',
},
});
const res = await rq({
url: `/content-manager/explorer/uid/check-availability`,
method: 'POST',
body: {
contentTypeUID: uid,
field: 'slug',
value: 'custom-slug',
},
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
isAvailable: false,
suggestion: 'custom-slug-1',
});
});
});
});

View File

@ -25,7 +25,7 @@ describe('Content Manager single types', () => {
});
}, 60000);
afterAll(() => modelsUtils.deleteContentType('single-type'), 60000);
afterAll(() => modelsUtils.deleteContentType('single-type-model'), 60000);
test('Label is not pluralized', async () => {
const res = await rq({

View File

@ -8,7 +8,7 @@
"description": "content-type-builder.plugin.description"
},
"dependencies": {
"@sindresorhus/slugify": "^0.9.1",
"@sindresorhus/slugify": "0.9.1",
"classnames": "^2.2.6",
"fs-extra": "^7.0.0",
"immutable": "^3.8.2",