mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	chore(modernize): Use joi schema-validation in FeatureController
This commit is contained in:
		
							parent
							
								
									e285f39bcb
								
							
						
					
					
						commit
						39bc265daf
					
				
							
								
								
									
										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