mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-31 00:16:47 +01:00
chore(modernize): Use joi schema-validation in FeatureController
This commit is contained in:
parent
7705bfe1a9
commit
9eb0d2e535
14
lib/error/name-invalid-error.js
Normal file
14
lib/error/name-invalid-error.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
class NameInvalidError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super();
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.message = message;
|
||||||
|
this.msg = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = NameInvalidError;
|
30
lib/routes/admin-api/feature-schema.js
Normal file
30
lib/routes/admin-api/feature-schema.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const joi = require('joi');
|
||||||
|
|
||||||
|
const strategiesSchema = joi.object().keys({
|
||||||
|
name: joi
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/)
|
||||||
|
.required(),
|
||||||
|
parameters: joi.object(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const featureShema = joi
|
||||||
|
.object()
|
||||||
|
.keys({
|
||||||
|
name: joi
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/)
|
||||||
|
.required(),
|
||||||
|
enabled: joi.boolean().default(false),
|
||||||
|
description: joi.string(),
|
||||||
|
strategies: joi
|
||||||
|
.array()
|
||||||
|
.required()
|
||||||
|
.min(1)
|
||||||
|
.items(strategiesSchema),
|
||||||
|
})
|
||||||
|
.options({ allowUnknown: false, stripUnknown: true });
|
||||||
|
|
||||||
|
module.exports = { featureShema, strategiesSchema };
|
@ -10,25 +10,32 @@ const {
|
|||||||
FEATURE_ARCHIVED,
|
FEATURE_ARCHIVED,
|
||||||
} = require('../../event-type');
|
} = require('../../event-type');
|
||||||
const NameExistsError = require('../../error/name-exists-error');
|
const NameExistsError = require('../../error/name-exists-error');
|
||||||
const NotFoundError = require('../../error/notfound-error');
|
const NameInvalidError = require('../../error/name-invalid-error');
|
||||||
const ValidationError = require('../../error/validation-error.js');
|
const { isUrlFriendlyName } = require('./util');
|
||||||
const validateRequest = require('../../error/validate-request');
|
|
||||||
const extractUser = require('../../extract-user');
|
const extractUser = require('../../extract-user');
|
||||||
|
|
||||||
|
const { featureShema } = require('./feature-schema');
|
||||||
|
|
||||||
const handleErrors = (req, res, error) => {
|
const handleErrors = (req, res, error) => {
|
||||||
logger.warn('Error creating or updating feature', error);
|
logger.warn('Error creating or updating feature', error);
|
||||||
switch (error.constructor) {
|
switch (error.name) {
|
||||||
case NotFoundError:
|
case 'NotFoundError':
|
||||||
return res.status(404).end();
|
return res.status(404).end();
|
||||||
case NameExistsError:
|
case 'NameInvalidError':
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json([{ msg: error.message }])
|
||||||
|
.end();
|
||||||
|
case 'NameExistsError':
|
||||||
return res
|
return res
|
||||||
.status(403)
|
.status(403)
|
||||||
.json([{ msg: error.message }])
|
.json([{ msg: error.message }])
|
||||||
.end();
|
.end();
|
||||||
case ValidationError:
|
case 'ValidationError':
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json(req.validationErrors())
|
.json(error)
|
||||||
.end();
|
.end();
|
||||||
default:
|
default:
|
||||||
logger.error('Server failed executing request', error);
|
logger.error('Server failed executing request', error);
|
||||||
@ -36,39 +43,6 @@ const handleErrors = (req, res, error) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const strategiesSchema = joi.object().keys({
|
|
||||||
name: joi
|
|
||||||
.string()
|
|
||||||
.regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/)
|
|
||||||
.required(),
|
|
||||||
parameters: joi.object(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function validateStrategy(featureToggle) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (
|
|
||||||
!featureToggle.strategies ||
|
|
||||||
featureToggle.strategies.length === 0
|
|
||||||
) {
|
|
||||||
return reject(
|
|
||||||
new ValidationError('You must define at least one strategy')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
featureToggle.strategies = featureToggle.strategies.map(
|
|
||||||
strategyConfig => {
|
|
||||||
const result = joi.validate(strategyConfig, strategiesSchema);
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return resolve(featureToggle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = 1;
|
const version = 1;
|
||||||
|
|
||||||
class FeatureController extends Controller {
|
class FeatureController extends Controller {
|
||||||
@ -104,42 +78,49 @@ class FeatureController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validate(req, res) {
|
async validate(req, res) {
|
||||||
req.checkBody('name', 'Name is required').notEmpty();
|
const name = req.body.name;
|
||||||
req.checkBody('name', 'Name must be URL friendly').isUrlFriendlyName();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateRequest(req);
|
await this.validateNameFormat(name);
|
||||||
await this.validateUniqueName(req);
|
await this.validateUniqueName(name);
|
||||||
res.status(201).end();
|
res.status(201).end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleErrors(req, res, error);
|
handleErrors(req, res, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validateUniqueName(req) {
|
// TODO: cleanup this validation
|
||||||
return new Promise((resolve, reject) => {
|
async validateUniqueName(name) {
|
||||||
this.featureToggleStore
|
let msg;
|
||||||
.hasFeature(req.body.name)
|
try {
|
||||||
.then(definition => {
|
const definition = await this.featureToggleStore.hasFeature(name);
|
||||||
const msg = definition.archived
|
msg = definition.archived
|
||||||
? 'An archived toggle with that name already exist'
|
? 'An archived toggle with that name already exist'
|
||||||
: 'A toggle with that name already exist';
|
: 'A toggle with that name already exist';
|
||||||
reject(new NameExistsError(msg));
|
} catch (error) {
|
||||||
})
|
// No conflict, everything ok!
|
||||||
.catch(() => resolve(req));
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// Interntional throw here!
|
||||||
|
throw new NameExistsError(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this should be part of the schema validation!
|
||||||
|
validateNameFormat(name) {
|
||||||
|
if (!isUrlFriendlyName(name)) {
|
||||||
|
throw new NameInvalidError('Name must be URL friendly');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createToggle(req, res) {
|
async createToggle(req, res) {
|
||||||
req.checkBody('name', 'Name is required').notEmpty();
|
const toggleName = req.body.name;
|
||||||
req.checkBody('name', 'Name must be URL friendly').isUrlFriendlyName();
|
|
||||||
|
|
||||||
const userName = extractUser(req);
|
const userName = extractUser(req);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateRequest(req);
|
await this.validateNameFormat(toggleName);
|
||||||
await this.validateUniqueName(req);
|
await this.validateUniqueName(toggleName);
|
||||||
const featureToggle = await validateStrategy(req.body);
|
const featureToggle = await joi.validate(req.body, featureShema);
|
||||||
await this.eventStore.store({
|
await this.eventStore.store({
|
||||||
type: FEATURE_CREATED,
|
type: FEATURE_CREATED,
|
||||||
createdBy: userName,
|
createdBy: userName,
|
||||||
@ -160,7 +141,7 @@ class FeatureController extends Controller {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.featureToggleStore.getFeature(featureName);
|
await this.featureToggleStore.getFeature(featureName);
|
||||||
await validateStrategy(updatedFeature);
|
await joi.validate(updatedFeature, featureShema);
|
||||||
await this.eventStore.store({
|
await this.eventStore.store({
|
||||||
type: FEATURE_UPDATED,
|
type: FEATURE_UPDATED,
|
||||||
createdBy: userName,
|
createdBy: userName,
|
||||||
|
@ -200,3 +200,23 @@ test('invalid feature names should not pass validation', t => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('invalid feature names should have error msg', t => {
|
||||||
|
t.plan(1);
|
||||||
|
const { request, base } = getSetup();
|
||||||
|
|
||||||
|
const name = 'ØÆ`';
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post(`${base}/api/admin/features`)
|
||||||
|
.send({
|
||||||
|
name,
|
||||||
|
enabled: false,
|
||||||
|
strategies: [{ name: 'default' }],
|
||||||
|
})
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(400)
|
||||||
|
.expect(res => {
|
||||||
|
t.true(res.body[0].msg === 'Name must be URL friendly');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
5
lib/routes/admin-api/util.js
Normal file
5
lib/routes/admin-api/util.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const isUrlFriendlyName = input => encodeURIComponent(input) === input;
|
||||||
|
|
||||||
|
module.exports = { isUrlFriendlyName };
|
Loading…
Reference in New Issue
Block a user