mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-17 13:46:47 +02:00
feat: Add technical support for projects
This commit is contained in:
parent
144e832cdc
commit
b644071a34
@ -36,6 +36,7 @@ module.exports = function(config) {
|
|||||||
app.use(responseTime(config));
|
app.use(responseTime(config));
|
||||||
app.use(requestLogger(config));
|
app.use(requestLogger(config));
|
||||||
app.use(secureHeaders(config));
|
app.use(secureHeaders(config));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
if (config.publicFolder) {
|
if (config.publicFolder) {
|
||||||
app.use(favicon(path.join(config.publicFolder, 'favicon.ico')));
|
app.use(favicon(path.join(config.publicFolder, 'favicon.ico')));
|
||||||
|
@ -16,6 +16,7 @@ const FEATURE_COLUMNS = [
|
|||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
'type',
|
'type',
|
||||||
|
'project',
|
||||||
'enabled',
|
'enabled',
|
||||||
'stale',
|
'stale',
|
||||||
'strategies',
|
'strategies',
|
||||||
@ -65,6 +66,14 @@ class FeatureToggleStore {
|
|||||||
return rows.map(this.rowToFeature);
|
return rows.map(this.rowToFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFeaturesBy(fields) {
|
||||||
|
const rows = await this.db
|
||||||
|
.select(FEATURE_COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.where(fields);
|
||||||
|
return rows.map(this.rowToFeature);
|
||||||
|
}
|
||||||
|
|
||||||
async count() {
|
async count() {
|
||||||
return this.db
|
return this.db
|
||||||
.count('*')
|
.count('*')
|
||||||
@ -114,6 +123,7 @@ class FeatureToggleStore {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
|
project: row.project,
|
||||||
enabled: row.enabled > 0,
|
enabled: row.enabled > 0,
|
||||||
stale: row.stale,
|
stale: row.stale,
|
||||||
strategies: row.strategies,
|
strategies: row.strategies,
|
||||||
@ -127,6 +137,7 @@ class FeatureToggleStore {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
|
project: data.project,
|
||||||
enabled: data.enabled ? 1 : 0,
|
enabled: data.enabled ? 1 : 0,
|
||||||
stale: data.stale,
|
stale: data.stale,
|
||||||
archived: data.archived ? 1 : 0,
|
archived: data.archived ? 1 : 0,
|
||||||
|
@ -12,6 +12,7 @@ const ClientApplicationsStore = require('./client-applications-store');
|
|||||||
const ContextFieldStore = require('./context-field-store');
|
const ContextFieldStore = require('./context-field-store');
|
||||||
const SettingStore = require('./setting-store');
|
const SettingStore = require('./setting-store');
|
||||||
const UserStore = require('./user-store');
|
const UserStore = require('./user-store');
|
||||||
|
const ProjectStore = require('./project-store');
|
||||||
|
|
||||||
module.exports.createStores = (config, eventBus) => {
|
module.exports.createStores = (config, eventBus) => {
|
||||||
const { getLogger } = config;
|
const { getLogger } = config;
|
||||||
@ -49,5 +50,6 @@ module.exports.createStores = (config, eventBus) => {
|
|||||||
),
|
),
|
||||||
settingStore: new SettingStore(db, getLogger),
|
settingStore: new SettingStore(db, getLogger),
|
||||||
userStore: new UserStore(db, getLogger),
|
userStore: new UserStore(db, getLogger),
|
||||||
|
projectStore: new ProjectStore(db, getLogger),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
95
lib/db/project-store.js
Normal file
95
lib/db/project-store.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
const NotFoundError = require('../error/notfound-error');
|
||||||
|
|
||||||
|
const COLUMNS = ['id', 'name', 'description', 'created_at'];
|
||||||
|
const TABLE = 'projects';
|
||||||
|
|
||||||
|
class ProjectStore {
|
||||||
|
constructor(db, getLogger) {
|
||||||
|
this.db = db;
|
||||||
|
this.logger = getLogger('project-store.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldToRow(data) {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll() {
|
||||||
|
const rows = await this.db
|
||||||
|
.select(COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.orderBy('name', 'asc');
|
||||||
|
|
||||||
|
return rows.map(this.mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id) {
|
||||||
|
return this.db
|
||||||
|
.first(COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.where({ id })
|
||||||
|
.then(this.mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasProject(id) {
|
||||||
|
return this.db
|
||||||
|
.first('id')
|
||||||
|
.from(TABLE)
|
||||||
|
.where({ id })
|
||||||
|
.then(row => {
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundError(`No project with id=${id} found`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
archived: row.archived === 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(project) {
|
||||||
|
await this.db(TABLE)
|
||||||
|
.insert(this.fieldToRow(project))
|
||||||
|
.catch(err =>
|
||||||
|
this.logger.error('Could not insert project, error: ', err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(data) {
|
||||||
|
try {
|
||||||
|
await this.db(TABLE)
|
||||||
|
.where({ id: data.id })
|
||||||
|
.update(this.fieldToRow(data));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Could not update project, error: ', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id) {
|
||||||
|
try {
|
||||||
|
await this.db(TABLE)
|
||||||
|
.where({ id })
|
||||||
|
.del();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Could not delete project, error: ', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mapRow(row) {
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundError('No project found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ProjectStore;
|
26
lib/error/invalid-operation-error.js
Normal file
26
lib/error/invalid-operation-error.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
class InvalidOperationError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super();
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
const obj = {
|
||||||
|
isJoi: true,
|
||||||
|
name: this.constructor.name,
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
message: this.message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = InvalidOperationError;
|
@ -18,6 +18,9 @@ const {
|
|||||||
CONTEXT_FIELD_CREATED,
|
CONTEXT_FIELD_CREATED,
|
||||||
CONTEXT_FIELD_UPDATED,
|
CONTEXT_FIELD_UPDATED,
|
||||||
CONTEXT_FIELD_DELETED,
|
CONTEXT_FIELD_DELETED,
|
||||||
|
PROJECT_CREATED,
|
||||||
|
PROJECT_UPDATED,
|
||||||
|
PROJECT_DELETED,
|
||||||
} = require('./event-type');
|
} = require('./event-type');
|
||||||
|
|
||||||
const strategyTypes = [
|
const strategyTypes = [
|
||||||
@ -43,6 +46,8 @@ const contextTypes = [
|
|||||||
CONTEXT_FIELD_UPDATED,
|
CONTEXT_FIELD_UPDATED,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const projectTypes = [PROJECT_CREATED, PROJECT_UPDATED, PROJECT_DELETED];
|
||||||
|
|
||||||
function baseTypeFor(event) {
|
function baseTypeFor(event) {
|
||||||
if (featureTypes.indexOf(event.type) !== -1) {
|
if (featureTypes.indexOf(event.type) !== -1) {
|
||||||
return 'features';
|
return 'features';
|
||||||
@ -53,6 +58,9 @@ function baseTypeFor(event) {
|
|||||||
if (contextTypes.indexOf(event.type) !== -1) {
|
if (contextTypes.indexOf(event.type) !== -1) {
|
||||||
return 'context';
|
return 'context';
|
||||||
}
|
}
|
||||||
|
if (projectTypes.indexOf(event.type) !== -1) {
|
||||||
|
return 'project';
|
||||||
|
}
|
||||||
throw new Error(`unknown event type: ${JSON.stringify(event)}`);
|
throw new Error(`unknown event type: ${JSON.stringify(event)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,4 +15,7 @@ module.exports = {
|
|||||||
CONTEXT_FIELD_CREATED: 'context-field-created',
|
CONTEXT_FIELD_CREATED: 'context-field-created',
|
||||||
CONTEXT_FIELD_UPDATED: 'context-field-updated',
|
CONTEXT_FIELD_UPDATED: 'context-field-updated',
|
||||||
CONTEXT_FIELD_DELETED: 'context-field-deleted',
|
CONTEXT_FIELD_DELETED: 'context-field-deleted',
|
||||||
|
PROJECT_CREATED: 'project-created',
|
||||||
|
PROJECT_UPDATED: 'project-updated',
|
||||||
|
PROJECT_DELETED: 'project-deleted',
|
||||||
};
|
};
|
||||||
|
@ -12,6 +12,9 @@ const UPDATE_APPLICATION = 'UPDATE_APPLICATION';
|
|||||||
const CREATE_CONTEXT_FIELD = 'CREATE_CONTEXT_FIELD';
|
const CREATE_CONTEXT_FIELD = 'CREATE_CONTEXT_FIELD';
|
||||||
const UPDATE_CONTEXT_FIELD = 'UPDATE_CONTEXT_FIELD';
|
const UPDATE_CONTEXT_FIELD = 'UPDATE_CONTEXT_FIELD';
|
||||||
const DELETE_CONTEXT_FIELD = 'DELETE_CONTEXT_FIELD';
|
const DELETE_CONTEXT_FIELD = 'DELETE_CONTEXT_FIELD';
|
||||||
|
const CREATE_PROJECT = 'CREATE_PROJECT';
|
||||||
|
const UPDATE_PROJECT = 'UPDATE_PROJECT';
|
||||||
|
const DELETE_PROJECT = 'DELETE_PROJECT';
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
@ -26,4 +29,7 @@ module.exports = {
|
|||||||
CREATE_CONTEXT_FIELD,
|
CREATE_CONTEXT_FIELD,
|
||||||
UPDATE_CONTEXT_FIELD,
|
UPDATE_CONTEXT_FIELD,
|
||||||
DELETE_CONTEXT_FIELD,
|
DELETE_CONTEXT_FIELD,
|
||||||
|
CREATE_PROJECT,
|
||||||
|
UPDATE_PROJECT,
|
||||||
|
DELETE_PROJECT,
|
||||||
};
|
};
|
||||||
|
@ -65,6 +65,7 @@ const featureShema = joi
|
|||||||
enabled: joi.boolean().default(false),
|
enabled: joi.boolean().default(false),
|
||||||
stale: joi.boolean().default(false),
|
stale: joi.boolean().default(false),
|
||||||
type: joi.string().default('release'),
|
type: joi.string().default('release'),
|
||||||
|
project: joi.string().default('default'),
|
||||||
description: joi
|
description: joi
|
||||||
.string()
|
.string()
|
||||||
.allow('')
|
.allow('')
|
||||||
|
@ -17,14 +17,12 @@ test('should require URL firendly name', t => {
|
|||||||
test('should be valid toggle name', t => {
|
test('should be valid toggle name', t => {
|
||||||
const toggle = {
|
const toggle = {
|
||||||
name: 'app.name',
|
name: 'app.name',
|
||||||
type: 'release',
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
stale: false,
|
|
||||||
strategies: [{ name: 'default' }],
|
strategies: [{ name: 'default' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { value } = featureShema.validate(toggle);
|
const { value } = featureShema.validate(toggle);
|
||||||
t.deepEqual(value, toggle);
|
t.is(value.name, toggle.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should strip extra variant fields', t => {
|
test('should strip extra variant fields', t => {
|
||||||
@ -52,6 +50,7 @@ test('should allow weightType=fix', t => {
|
|||||||
const toggle = {
|
const toggle = {
|
||||||
name: 'app.name',
|
name: 'app.name',
|
||||||
type: 'release',
|
type: 'release',
|
||||||
|
project: 'default',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
stale: false,
|
stale: false,
|
||||||
strategies: [{ name: 'default' }],
|
strategies: [{ name: 'default' }],
|
||||||
@ -95,6 +94,7 @@ test('should be possible to define variant overrides', t => {
|
|||||||
const toggle = {
|
const toggle = {
|
||||||
name: 'app.name',
|
name: 'app.name',
|
||||||
type: 'release',
|
type: 'release',
|
||||||
|
project: 'some',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
stale: false,
|
stale: false,
|
||||||
strategies: [{ name: 'default' }],
|
strategies: [{ name: 'default' }],
|
||||||
@ -152,6 +152,7 @@ test('should keep constraints', t => {
|
|||||||
const toggle = {
|
const toggle = {
|
||||||
name: 'app.constraints',
|
name: 'app.constraints',
|
||||||
type: 'release',
|
type: 'release',
|
||||||
|
project: 'default',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
stale: false,
|
stale: false,
|
||||||
strategies: [
|
strategies: [
|
||||||
|
@ -31,6 +31,7 @@ const handleErrors = (res, logger, error) => {
|
|||||||
switch (error.name) {
|
switch (error.name) {
|
||||||
case 'NotFoundError':
|
case 'NotFoundError':
|
||||||
return res.status(404).end();
|
return res.status(404).end();
|
||||||
|
case 'InvalidOperationError':
|
||||||
case 'NameExistsError':
|
case 'NameExistsError':
|
||||||
return res
|
return res
|
||||||
.status(409)
|
.status(409)
|
||||||
|
@ -7,18 +7,20 @@ const getApp = require('./app');
|
|||||||
|
|
||||||
const { startMonitoring } = require('./metrics');
|
const { startMonitoring } = require('./metrics');
|
||||||
const { createStores } = require('./db');
|
const { createStores } = require('./db');
|
||||||
|
const { createServices } = require('./services');
|
||||||
const { createOptions } = require('./options');
|
const { createOptions } = require('./options');
|
||||||
const StateService = require('./services/state-service');
|
|
||||||
const User = require('./user');
|
const User = require('./user');
|
||||||
const permissions = require('./permissions');
|
const permissions = require('./permissions');
|
||||||
const AuthenticationRequired = require('./authentication-required');
|
const AuthenticationRequired = require('./authentication-required');
|
||||||
const { addEventHook } = require('./event-hook');
|
const { addEventHook } = require('./event-hook');
|
||||||
|
const eventType = require('./event-type');
|
||||||
|
|
||||||
async function createApp(options) {
|
async function createApp(options) {
|
||||||
// Database dependencies (stateful)
|
// Database dependencies (stateful)
|
||||||
const logger = options.getLogger('server-impl.js');
|
const logger = options.getLogger('server-impl.js');
|
||||||
const eventBus = new EventEmitter();
|
const eventBus = new EventEmitter();
|
||||||
const stores = createStores(options, eventBus);
|
const stores = createStores(options, eventBus);
|
||||||
|
const services = createServices(stores, options, eventBus);
|
||||||
const secret = await stores.settingStore.get('unleash.secret');
|
const secret = await stores.settingStore.get('unleash.secret');
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@ -37,7 +39,8 @@ async function createApp(options) {
|
|||||||
addEventHook(config.eventHook, stores.eventStore);
|
addEventHook(config.eventHook, stores.eventStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateService = new StateService(config);
|
// TODO: refactor this. Should only be accessable via services object
|
||||||
|
const { stateService } = services;
|
||||||
config.stateService = stateService;
|
config.stateService = stateService;
|
||||||
if (config.importFile) {
|
if (config.importFile) {
|
||||||
await stateService.importFile({
|
await stateService.importFile({
|
||||||
@ -53,6 +56,7 @@ async function createApp(options) {
|
|||||||
app,
|
app,
|
||||||
config,
|
config,
|
||||||
stores,
|
stores,
|
||||||
|
services,
|
||||||
eventBus,
|
eventBus,
|
||||||
stateService,
|
stateService,
|
||||||
};
|
};
|
||||||
@ -101,4 +105,5 @@ module.exports = {
|
|||||||
User,
|
User,
|
||||||
AuthenticationRequired,
|
AuthenticationRequired,
|
||||||
permissions,
|
permissions,
|
||||||
|
eventType,
|
||||||
};
|
};
|
||||||
|
7
lib/services/index.js
Normal file
7
lib/services/index.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const ProjectService = require('./project-service');
|
||||||
|
const StateService = require('./state-service');
|
||||||
|
|
||||||
|
module.exports.createServices = (stores, config) => ({
|
||||||
|
projectService: new ProjectService(stores, config),
|
||||||
|
stateService: new StateService(stores, config),
|
||||||
|
});
|
17
lib/services/project-schema.js
Normal file
17
lib/services/project-schema.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const joi = require('joi');
|
||||||
|
const { nameType } = require('../routes/admin-api/util');
|
||||||
|
|
||||||
|
const projectSchema = joi
|
||||||
|
.object()
|
||||||
|
.keys({
|
||||||
|
id: nameType,
|
||||||
|
name: joi.string().required(),
|
||||||
|
description: joi
|
||||||
|
.string()
|
||||||
|
.allow(null)
|
||||||
|
.allow('')
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.options({ allowUnknown: false, stripUnknown: true });
|
||||||
|
|
||||||
|
module.exports = projectSchema;
|
93
lib/services/project-service.js
Normal file
93
lib/services/project-service.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
const NameExistsError = require('../error/name-exists-error');
|
||||||
|
const InvalidOperationError = require('../error/invalid-operation-error');
|
||||||
|
const eventType = require('../event-type');
|
||||||
|
const { nameType } = require('../routes/admin-api/util');
|
||||||
|
const schema = require('./project-schema');
|
||||||
|
|
||||||
|
class ProjectService {
|
||||||
|
constructor(
|
||||||
|
{ projectStore, eventStore, featureToggleStore },
|
||||||
|
{ getLogger },
|
||||||
|
) {
|
||||||
|
this.projectStore = projectStore;
|
||||||
|
this.eventStore = eventStore;
|
||||||
|
this.featureToggleStore = featureToggleStore;
|
||||||
|
this.logger = getLogger('services/project-service.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjects() {
|
||||||
|
return this.projectStore.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProject(id) {
|
||||||
|
return this.projectStore.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(newProject, username) {
|
||||||
|
const data = await schema.validateAsync(newProject);
|
||||||
|
await this.validateUniqueId(data);
|
||||||
|
await this.eventStore.store({
|
||||||
|
type: eventType.PROJECT_CREATED,
|
||||||
|
createdBy: username,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
await this.projectStore.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProject(updatedProject, username) {
|
||||||
|
await this.projectStore.get(updatedProject.id);
|
||||||
|
const project = await schema.validateAsync(updatedProject);
|
||||||
|
await this.eventStore.store({
|
||||||
|
type: eventType.PROJECT_UPDATED,
|
||||||
|
createdBy: username,
|
||||||
|
data: project,
|
||||||
|
});
|
||||||
|
await this.projectStore.update(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProject(id, username) {
|
||||||
|
if (id === 'default') {
|
||||||
|
throw new InvalidOperationError(
|
||||||
|
'You can not delete the default project!',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggles = await this.featureToggleStore.getFeaturesBy({
|
||||||
|
project: id,
|
||||||
|
archived: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toggles.length > 0) {
|
||||||
|
throw new InvalidOperationError(
|
||||||
|
'You can not delete as project with active feature toggles',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.eventStore.store({
|
||||||
|
type: eventType.PROJECT_DELETED,
|
||||||
|
createdBy: username,
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
await this.projectStore.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateId(id) {
|
||||||
|
await nameType.validateAsync(id);
|
||||||
|
await this.validateUniqueId(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateUniqueId(id) {
|
||||||
|
try {
|
||||||
|
await this.projectStore.hasProject(id);
|
||||||
|
} catch (error) {
|
||||||
|
// No conflict, everything ok!
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interntional throw here!
|
||||||
|
throw new NameExistsError('A project with this id already exists.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ProjectService;
|
@ -14,7 +14,7 @@ const {
|
|||||||
} = require('./state-util');
|
} = require('./state-util');
|
||||||
|
|
||||||
class StateService {
|
class StateService {
|
||||||
constructor({ stores, getLogger }) {
|
constructor(stores, { getLogger }) {
|
||||||
this.eventStore = stores.eventStore;
|
this.eventStore = stores.eventStore;
|
||||||
this.toggleStore = stores.featureToggleStore;
|
this.toggleStore = stores.featureToggleStore;
|
||||||
this.strategyStore = stores.strategyStore;
|
this.strategyStore = stores.strategyStore;
|
||||||
|
@ -15,7 +15,10 @@ const {
|
|||||||
|
|
||||||
function getSetup() {
|
function getSetup() {
|
||||||
const stores = store.createStores();
|
const stores = store.createStores();
|
||||||
return { stateService: new StateService({ stores, getLogger }), stores };
|
return {
|
||||||
|
stateService: new StateService(stores, { getLogger }),
|
||||||
|
stores,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('should import a feature', async t => {
|
test('should import a feature', async t => {
|
||||||
|
34
migrations/20200928194947-add-projects.js
Normal file
34
migrations/20200928194947-add-projects.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/* eslint camelcase: "off" */
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const async = require('async');
|
||||||
|
|
||||||
|
exports.up = function(db, cb) {
|
||||||
|
async.series(
|
||||||
|
[
|
||||||
|
db.createTable.bind(db, 'projects', {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
length: 255,
|
||||||
|
primaryKey: true,
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
name: { type: 'string', notNull: true },
|
||||||
|
description: { type: 'string' },
|
||||||
|
created_at: { type: 'timestamp', defaultValue: 'now()' },
|
||||||
|
}),
|
||||||
|
db.runSql.bind(
|
||||||
|
db,
|
||||||
|
`
|
||||||
|
INSERT INTO projects(id, name, description) VALUES('default', 'Default', 'Default project');
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(db, cb) {
|
||||||
|
return db.dropTable('feature_types', cb);
|
||||||
|
};
|
17
migrations/20200928195238-add-project-id-to-features.js
Normal file
17
migrations/20200928195238-add-project-id-to-features.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
exports.up = function(db, cb) {
|
||||||
|
return db.addColumn(
|
||||||
|
'features',
|
||||||
|
'project',
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
defaultValue: 'default',
|
||||||
|
},
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(db, cb) {
|
||||||
|
return db.removeColumn('features', 'project', cb);
|
||||||
|
};
|
@ -75,7 +75,7 @@
|
|||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"gravatar-url": "^3.1.0",
|
"gravatar-url": "^3.1.0",
|
||||||
"helmet": "^4.1.0",
|
"helmet": "^4.1.0",
|
||||||
"joi": "^17.2.0",
|
"joi": "^17.3.0",
|
||||||
"js-yaml": "^3.14.0",
|
"js-yaml": "^3.14.0",
|
||||||
"knex": "0.21.5",
|
"knex": "0.21.5",
|
||||||
"log4js": "^6.0.0",
|
"log4js": "^6.0.0",
|
||||||
|
@ -24,6 +24,7 @@ async function resetDatabase(stores) {
|
|||||||
stores.db('client_instances').del(),
|
stores.db('client_instances').del(),
|
||||||
stores.db('context_fields').del(),
|
stores.db('context_fields').del(),
|
||||||
stores.db('users').del(),
|
stores.db('users').del(),
|
||||||
|
stores.db('projects').del(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,6 +44,10 @@ function createClientInstance(store) {
|
|||||||
return dbState.clientInstances.map(i => store.insert(i));
|
return dbState.clientInstances.map(i => store.insert(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createProjects(store) {
|
||||||
|
return dbState.projects.map(i => store.create(i));
|
||||||
|
}
|
||||||
|
|
||||||
function createFeatures(store) {
|
function createFeatures(store) {
|
||||||
return dbState.features.map(f => store._createFeature(f));
|
return dbState.features.map(f => store._createFeature(f));
|
||||||
}
|
}
|
||||||
@ -54,6 +59,7 @@ async function setupDatabase(stores) {
|
|||||||
updates.push(...createFeatures(stores.featureToggleStore));
|
updates.push(...createFeatures(stores.featureToggleStore));
|
||||||
updates.push(...createClientInstance(stores.clientInstanceStore));
|
updates.push(...createClientInstance(stores.clientInstanceStore));
|
||||||
updates.push(...createApplications(stores.clientApplicationsStore));
|
updates.push(...createApplications(stores.clientApplicationsStore));
|
||||||
|
updates.push(...createProjects(stores.projectStore));
|
||||||
|
|
||||||
await Promise.all(updates);
|
await Promise.all(updates);
|
||||||
}
|
}
|
||||||
|
@ -149,5 +149,11 @@
|
|||||||
{ "name": "new", "weight": 50 }
|
{ "name": "new", "weight": 50 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": "default",
|
||||||
|
"name": "Default"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ function createApp(stores, adminAuthentication = 'none', preHook) {
|
|||||||
adminAuthentication,
|
adminAuthentication,
|
||||||
secret: 'super-secret',
|
secret: 'super-secret',
|
||||||
sessionAge: 4000,
|
sessionAge: 4000,
|
||||||
stateService: new StateService({ stores, getLogger }),
|
stateService: new StateService(stores, { getLogger }),
|
||||||
getLogger,
|
getLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
148
test/e2e/serices/project-service.e2e.test.js
Normal file
148
test/e2e/serices/project-service.e2e.test.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
const test = require('ava');
|
||||||
|
const dbInit = require('../helpers/database-init');
|
||||||
|
const getLogger = require('../../fixtures/no-logger');
|
||||||
|
const ProjectService = require('../../../lib/services/project-service');
|
||||||
|
|
||||||
|
let stores;
|
||||||
|
// let projectStore;
|
||||||
|
let projectService;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
const db = await dbInit('project_service_serial', getLogger);
|
||||||
|
stores = db.stores;
|
||||||
|
// projectStore = stores.projectStore;
|
||||||
|
projectService = new ProjectService(stores, { getLogger });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await stores.db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should have default project', async t => {
|
||||||
|
const project = await projectService.getProject('default');
|
||||||
|
t.assert(project);
|
||||||
|
t.is(project.id, 'default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should list all projects', async t => {
|
||||||
|
const project = {
|
||||||
|
id: 'test-list',
|
||||||
|
name: 'New project',
|
||||||
|
description: 'Blah',
|
||||||
|
};
|
||||||
|
await projectService.createProject(project, 'someUser');
|
||||||
|
const projects = await projectService.getProjects();
|
||||||
|
t.is(projects.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should create new project', async t => {
|
||||||
|
const project = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'New project',
|
||||||
|
description: 'Blah',
|
||||||
|
};
|
||||||
|
await projectService.createProject(project, 'someUser');
|
||||||
|
const ret = await projectService.getProject('test');
|
||||||
|
t.deepEqual(project.id, ret.id);
|
||||||
|
t.deepEqual(project.name, ret.name);
|
||||||
|
t.deepEqual(project.description, ret.description);
|
||||||
|
t.truthy(ret.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should delete project', async t => {
|
||||||
|
const project = {
|
||||||
|
id: 'test-delete',
|
||||||
|
name: 'New project',
|
||||||
|
description: 'Blah',
|
||||||
|
};
|
||||||
|
await projectService.createProject(project, 'some-user');
|
||||||
|
await projectService.deleteProject(project.id, 'some-user');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await projectService.getProject(project.id);
|
||||||
|
} catch (err) {
|
||||||
|
t.is(err.message, 'No project found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should not be able to delete project with toggles', async t => {
|
||||||
|
const project = {
|
||||||
|
id: 'test-delete-with-toggles',
|
||||||
|
name: 'New project',
|
||||||
|
description: 'Blah',
|
||||||
|
};
|
||||||
|
await projectService.createProject(project, 'some-user');
|
||||||
|
await stores.featureToggleStore._createFeature({
|
||||||
|
name: 'test-project-delete',
|
||||||
|
project: project.id,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await projectService.deleteProject(project.id, 'some-user');
|
||||||
|
} catch (err) {
|
||||||
|
t.is(
|
||||||
|
err.message,
|
||||||
|
'You can not delete as project with active feature toggles',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should not delete "default" project', async t => {
|
||||||
|
try {
|
||||||
|
await projectService.deleteProject('default', 'some-user');
|
||||||
|
} catch (err) {
|
||||||
|
t.is(err.message, 'You can not delete the default project!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should validate name, legal', async t => {
|
||||||
|
const result = await projectService.validateId('new_name');
|
||||||
|
t.true(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should require URL friendly ID', async t => {
|
||||||
|
try {
|
||||||
|
await projectService.validateId('new name øæå');
|
||||||
|
} catch (err) {
|
||||||
|
t.is(err.message, '"value" must be URL friendly');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should require unique ID', async t => {
|
||||||
|
try {
|
||||||
|
await projectService.validateId('default');
|
||||||
|
} catch (err) {
|
||||||
|
t.is(err.message, 'A project with this id already exists.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should update project', async t => {
|
||||||
|
const project = {
|
||||||
|
id: 'test-update',
|
||||||
|
name: 'New project',
|
||||||
|
description: 'Blah',
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedProject = {
|
||||||
|
id: 'test-update',
|
||||||
|
name: 'New name',
|
||||||
|
description: 'Blah longer desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
await projectService.createProject(project, 'some-user');
|
||||||
|
await projectService.updateProject(updatedProject, 'some-user');
|
||||||
|
|
||||||
|
const readProject = await projectService.getProject(project.id);
|
||||||
|
|
||||||
|
t.is(updatedProject.name, readProject.name);
|
||||||
|
t.is(updatedProject.description, readProject.description);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should give error when getting unkown project', async t => {
|
||||||
|
try {
|
||||||
|
await projectService.getProject('unknown');
|
||||||
|
} catch (err) {
|
||||||
|
t.is(err.message, 'No project found');
|
||||||
|
}
|
||||||
|
});
|
84
test/e2e/stores/project-store.e2e.test.js
Normal file
84
test/e2e/stores/project-store.e2e.test.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('ava');
|
||||||
|
const dbInit = require('../helpers/database-init');
|
||||||
|
const getLogger = require('../../fixtures/no-logger');
|
||||||
|
|
||||||
|
let stores;
|
||||||
|
let projectStore;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
const db = await dbInit('project_store_serial', getLogger);
|
||||||
|
stores = db.stores;
|
||||||
|
projectStore = stores.projectStore;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await stores.db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should have default project', async t => {
|
||||||
|
const project = await projectStore.get('default');
|
||||||
|
t.assert(project);
|
||||||
|
t.is(project.id, 'default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should create new project', async t => {
|
||||||
|
const project = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'New project',
|
||||||
|
description: 'Blah',
|
||||||
|
};
|
||||||
|
await projectStore.create(project);
|
||||||
|
const ret = await projectStore.get('test');
|
||||||
|
t.deepEqual(project.id, ret.id);
|
||||||
|
t.deepEqual(project.name, ret.name);
|
||||||
|
t.deepEqual(project.description, ret.description);
|
||||||
|
t.truthy(ret.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should delete project', async t => {
|
||||||
|
const project = {
|
||||||
|
id: 'test-delete',
|
||||||
|
name: 'New project',
|
||||||
|
description: 'Blah',
|
||||||
|
};
|
||||||
|
await projectStore.create(project);
|
||||||
|
await projectStore.delete(project.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await projectStore.get(project.id);
|
||||||
|
} catch (err) {
|
||||||
|
t.is(err.message, 'No project found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should update project', async t => {
|
||||||
|
const project = {
|
||||||
|
id: 'test-update',
|
||||||
|
name: 'New project',
|
||||||
|
description: 'Blah',
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedProject = {
|
||||||
|
id: 'test-update',
|
||||||
|
name: 'New name',
|
||||||
|
description: 'Blah longer desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
await projectStore.create(project);
|
||||||
|
await projectStore.update(updatedProject);
|
||||||
|
|
||||||
|
const readProject = await projectStore.get(project.id);
|
||||||
|
|
||||||
|
t.is(updatedProject.name, readProject.name);
|
||||||
|
t.is(updatedProject.description, readProject.description);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should give error when getting unkown project', async t => {
|
||||||
|
try {
|
||||||
|
await projectStore.get('unknown');
|
||||||
|
} catch (err) {
|
||||||
|
t.is(err.message, 'No project found');
|
||||||
|
}
|
||||||
|
});
|
48
yarn.lock
48
yarn.lock
@ -183,28 +183,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
arrify "^1.0.1"
|
arrify "^1.0.1"
|
||||||
|
|
||||||
"@hapi/address@^4.1.0":
|
|
||||||
version "4.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-4.1.0.tgz#d60c5c0d930e77456fdcde2598e77302e2955e1d"
|
|
||||||
integrity sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==
|
|
||||||
dependencies:
|
|
||||||
"@hapi/hoek" "^9.0.0"
|
|
||||||
|
|
||||||
"@hapi/formula@^2.0.0":
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128"
|
|
||||||
integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==
|
|
||||||
|
|
||||||
"@hapi/hoek@^9.0.0":
|
"@hapi/hoek@^9.0.0":
|
||||||
version "9.0.4"
|
version "9.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.0.4.tgz#e80ad4e8e8d2adc6c77d985f698447e8628b6010"
|
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.0.4.tgz#e80ad4e8e8d2adc6c77d985f698447e8628b6010"
|
||||||
integrity sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw==
|
integrity sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw==
|
||||||
|
|
||||||
"@hapi/pinpoint@^2.0.0":
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-2.0.0.tgz#805b40d4dbec04fc116a73089494e00f073de8df"
|
|
||||||
integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==
|
|
||||||
|
|
||||||
"@hapi/topo@^5.0.0":
|
"@hapi/topo@^5.0.0":
|
||||||
version "5.0.0"
|
version "5.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7"
|
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7"
|
||||||
@ -277,6 +260,23 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@passport-next/passport-strategy" "1.x.x"
|
"@passport-next/passport-strategy" "1.x.x"
|
||||||
|
|
||||||
|
"@sideway/address@^4.1.0":
|
||||||
|
version "4.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.0.tgz#0b301ada10ac4e0e3fa525c90615e0b61a72b78d"
|
||||||
|
integrity sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA==
|
||||||
|
dependencies:
|
||||||
|
"@hapi/hoek" "^9.0.0"
|
||||||
|
|
||||||
|
"@sideway/formula@^3.0.0":
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c"
|
||||||
|
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==
|
||||||
|
|
||||||
|
"@sideway/pinpoint@^2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df"
|
||||||
|
integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==
|
||||||
|
|
||||||
"@sindresorhus/is@^0.14.0":
|
"@sindresorhus/is@^0.14.0":
|
||||||
version "0.14.0"
|
version "0.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
|
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
|
||||||
@ -3113,16 +3113,16 @@ istanbul-reports@^3.0.2:
|
|||||||
html-escaper "^2.0.0"
|
html-escaper "^2.0.0"
|
||||||
istanbul-lib-report "^3.0.0"
|
istanbul-lib-report "^3.0.0"
|
||||||
|
|
||||||
joi@^17.2.0:
|
joi@^17.3.0:
|
||||||
version "17.2.0"
|
version "17.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/joi/-/joi-17.2.0.tgz#81cba6c1145130482d57b6d50129c7ab0e7d8b0a"
|
resolved "https://registry.yarnpkg.com/joi/-/joi-17.3.0.tgz#f1be4a6ce29bc1716665819ac361dfa139fff5d2"
|
||||||
integrity sha512-9ZC8pMSitNlenuwKARENBGVvvGYHNlwWe5rexo2WxyogaxCB5dNHAgFA1BJQ6nsJrt/jz1p5vSqDT6W6kciDDw==
|
integrity sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@hapi/address" "^4.1.0"
|
|
||||||
"@hapi/formula" "^2.0.0"
|
|
||||||
"@hapi/hoek" "^9.0.0"
|
"@hapi/hoek" "^9.0.0"
|
||||||
"@hapi/pinpoint" "^2.0.0"
|
|
||||||
"@hapi/topo" "^5.0.0"
|
"@hapi/topo" "^5.0.0"
|
||||||
|
"@sideway/address" "^4.1.0"
|
||||||
|
"@sideway/formula" "^3.0.0"
|
||||||
|
"@sideway/pinpoint" "^2.0.0"
|
||||||
|
|
||||||
js-string-escape@^1.0.1:
|
js-string-escape@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
|
Loading…
Reference in New Issue
Block a user