1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: add ui-bootstrap endpoint (#790)

* feat: add ui-bootstrap endpoint

- Reducing calls needed for frontend to 1 instead of the current 6

fixes: #789
This commit is contained in:
Christopher Kolstad 2021-04-20 12:32:02 +02:00 committed by GitHub
parent 332f1c4544
commit 4246baee16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 410 additions and 124 deletions

View File

@ -1,5 +1,8 @@
'use strict';
import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
const COLUMNS = [
'name',
'description',
@ -10,7 +13,7 @@ const COLUMNS = [
];
const TABLE = 'context_fields';
const mapRow = row => ({
const mapRow: (IContextRow) => IContextField = row => ({
name: row.name,
description: row.description,
stickiness: row.stickiness,
@ -19,8 +22,30 @@ const mapRow = row => ({
createdAt: row.created_at,
});
export interface ICreateContextField {
name: string;
description: string;
stickiness: boolean;
sort_order: number;
legal_values?: string[];
updated_at: Date;
}
export interface IContextField {
name: string;
description: string;
stickiness: boolean;
sortOrder: number;
legalValues?: string[];
createdAt: Date;
}
class ContextFieldStore {
constructor(db, customContextFields, getLogger) {
private db: Knex;
private logger: Logger;
constructor(db: Knex, customContextFields, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('context-field-store.js');
this._createFromConfig(customContextFields);
@ -45,7 +70,7 @@ class ContextFieldStore {
}
}
fieldToRow(data) {
fieldToRow(data): ICreateContextField {
return {
name: data.name,
description: data.description,
@ -56,7 +81,7 @@ class ContextFieldStore {
};
}
async getAll() {
async getAll(): Promise<IContextField[]> {
const rows = await this.db
.select(COLUMNS)
.from(TABLE)
@ -65,7 +90,7 @@ class ContextFieldStore {
return rows.map(mapRow);
}
async get(name) {
async get(name): Promise<IContextField> {
return this.db
.first(COLUMNS)
.from(TABLE)
@ -73,21 +98,21 @@ class ContextFieldStore {
.then(mapRow);
}
async create(contextField) {
async create(contextField): Promise<void> {
return this.db(TABLE).insert(this.fieldToRow(contextField));
}
async update(data) {
async update(data): Promise<void> {
return this.db(TABLE)
.where({ name: data.name })
.update(this.fieldToRow(data));
}
async delete(name) {
async delete(name): Promise<void> {
return this.db(TABLE)
.where({ name })
.del();
}
}
export default ContextFieldStore;
module.exports = ContextFieldStore;

View File

@ -1,27 +0,0 @@
'use strict';
const COLUMNS = ['id', 'name', 'description', 'lifetime_days'];
const TABLE = 'feature_types';
class FeatureToggleStore {
constructor(db, getLogger) {
this.db = db;
this.logger = getLogger('feature-type-store.js');
}
async getAll() {
const rows = await this.db.select(COLUMNS).from(TABLE);
return rows.map(this.rowToFeatureType);
}
rowToFeatureType(row) {
return {
id: row.id,
name: row.name,
description: row.description,
lifetimeDays: row.lifetime_days,
};
}
}
module.exports = FeatureToggleStore;

View File

@ -0,0 +1,48 @@
'use strict';
import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
const COLUMNS = ['id', 'name', 'description', 'lifetime_days'];
const TABLE = 'feature_types';
export interface IFeatureType {
id: number;
name: string;
description: string;
lifetimeDays: number;
}
interface IFeatureTypeRow {
id: number;
name: string;
description: string;
lifetime_days: number;
}
class FeatureTypeStore {
private db: Knex;
private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('feature-type-store.js');
}
async getAll(): Promise<IFeatureType[]> {
const rows = await this.db.select(COLUMNS).from(TABLE);
return rows.map(this.rowToFeatureType);
}
rowToFeatureType(row: IFeatureTypeRow): IFeatureType {
return {
id: row.id,
name: row.name,
description: row.description,
lifetimeDays: row.lifetime_days,
};
}
}
export default FeatureTypeStore;
module.exports = FeatureTypeStore;

View File

@ -1,15 +1,40 @@
import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
const NotFoundError = require('../error/notfound-error');
const COLUMNS = ['id', 'name', 'description', 'created_at'];
const TABLE = 'projects';
export interface IProject {
id: number;
name: string;
description: string;
createdAt: Date;
}
interface IProjectInsert {
id: number;
name: string;
description: string;
}
interface IProjectArchived {
id: number;
archived: boolean;
}
class ProjectStore {
constructor(db, getLogger) {
private db: Knex;
private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('project-store.js');
}
fieldToRow(data) {
fieldToRow(data): IProjectInsert {
return {
id: data.id,
name: data.name,
@ -17,7 +42,7 @@ class ProjectStore {
};
}
async getAll() {
async getAll(): Promise<IProject[]> {
const rows = await this.db
.select(COLUMNS)
.from(TABLE)
@ -26,7 +51,7 @@ class ProjectStore {
return rows.map(this.mapRow);
}
async get(id) {
async get(id): Promise<IProject> {
return this.db
.first(COLUMNS)
.from(TABLE)
@ -34,7 +59,7 @@ class ProjectStore {
.then(this.mapRow);
}
async hasProject(id) {
async hasProject(id): Promise<IProjectArchived> {
return this.db
.first('id')
.from(TABLE)
@ -50,14 +75,14 @@ class ProjectStore {
});
}
async create(project) {
async create(project): Promise<IProject> {
const [id] = await this.db(TABLE)
.insert(this.fieldToRow(project))
.returning('id');
return { ...project, id };
}
async update(data) {
async update(data): Promise<void> {
try {
await this.db(TABLE)
.where({ id: data.id })
@ -67,7 +92,7 @@ class ProjectStore {
}
}
async importProjects(projects) {
async importProjects(projects): Promise<IProject[]> {
const rows = await this.db(TABLE)
.insert(projects.map(this.fieldToRow))
.returning(COLUMNS)
@ -79,11 +104,11 @@ class ProjectStore {
return [];
}
async dropProjects() {
async dropProjects(): Promise<void> {
await this.db(TABLE).del();
}
async delete(id) {
async delete(id): Promise<void> {
try {
await this.db(TABLE)
.where({ id })
@ -93,7 +118,7 @@ class ProjectStore {
}
}
mapRow(row) {
mapRow(row): IProject {
if (!row) {
throw new NotFoundError('No project found');
}
@ -106,5 +131,5 @@ class ProjectStore {
};
}
}
export default ProjectStore;
module.exports = ProjectStore;

View File

@ -1,5 +1,8 @@
'use strict';
import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
const NotFoundError = require('../error/notfound-error');
const STRATEGY_COLUMNS = [
@ -11,13 +14,49 @@ const STRATEGY_COLUMNS = [
];
const TABLE = 'strategies';
class StrategyStore {
constructor(db, getLogger) {
export interface IStrategy {
name: string;
editable: boolean;
description: string;
parameters: object;
deprecated: boolean;
}
export interface IEditableStrategy {
name: string;
description: string;
parameters: object;
deprecated: boolean;
}
export interface IMinimalStrategy {
name: string;
description: string;
parameters: string;
}
export interface IStrategyName {
name: string;
}
interface IStrategyRow {
name: string;
built_in: number;
description: string;
parameters: object;
deprecated: boolean;
}
export default class StrategyStore {
private db: Knex;
private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('strategy-store.js');
}
async getStrategies() {
async getStrategies(): Promise<IStrategy[]> {
const rows = await this.db
.select(STRATEGY_COLUMNS)
.from(TABLE)
@ -26,7 +65,7 @@ class StrategyStore {
return rows.map(this.rowToStrategy);
}
async getEditableStrategies() {
async getEditableStrategies(): Promise<IEditableStrategy[]> {
const rows = await this.db
.select(STRATEGY_COLUMNS)
.from(TABLE)
@ -35,7 +74,7 @@ class StrategyStore {
return rows.map(this.rowToEditableStrategy);
}
async getStrategy(name) {
async getStrategy(name: string): Promise<IStrategy> {
return this.db
.first(STRATEGY_COLUMNS)
.from(TABLE)
@ -43,7 +82,7 @@ class StrategyStore {
.then(this.rowToStrategy);
}
rowToStrategy(row) {
rowToStrategy(row: IStrategyRow): IStrategy {
if (!row) {
throw new NotFoundError('No strategy found');
}
@ -56,7 +95,7 @@ class StrategyStore {
};
}
rowToEditableStrategy(row) {
rowToEditableStrategy(row: IStrategyRow): IEditableStrategy {
if (!row) {
throw new NotFoundError('No strategy found');
}
@ -68,7 +107,7 @@ class StrategyStore {
};
}
eventDataToRow(data) {
eventDataToRow(data): IMinimalStrategy {
return {
name: data.name,
description: data.description,
@ -84,7 +123,7 @@ class StrategyStore {
);
}
async updateStrategy(data) {
async updateStrategy(data): Promise<void> {
this.db(TABLE)
.where({ name: data.name })
.update(this.eventDataToRow(data))
@ -93,7 +132,7 @@ class StrategyStore {
);
}
async deprecateStrategy({ name }) {
async deprecateStrategy({ name }: IStrategyName): Promise<void> {
this.db(TABLE)
.where({ name })
.update({ deprecated: true })
@ -102,7 +141,7 @@ class StrategyStore {
);
}
async reactivateStrategy({ name }) {
async reactivateStrategy({ name }: IStrategyName) {
this.db(TABLE)
.where({ name })
.update({ deprecated: false })
@ -114,7 +153,7 @@ class StrategyStore {
);
}
async deleteStrategy({ name }) {
async deleteStrategy({ name }: IStrategyName) {
return this.db(TABLE)
.where({ name })
.del()
@ -125,19 +164,14 @@ class StrategyStore {
async importStrategy(data) {
const rowData = this.eventDataToRow(data);
return this.db(TABLE)
.where({ name: rowData.name, built_in: 0 }) // eslint-disable-line
.update(rowData)
.then(result =>
result === 0 ? this.db(TABLE).insert(rowData) : result,
)
.catch(err =>
this.logger.error('Could not import strategy, error: ', err),
);
await this.db(TABLE)
.insert(rowData)
.onConflict(['name'])
.merge();
}
async dropStrategies() {
return this.db(TABLE)
async dropStrategies(): Promise<void> {
await this.db(TABLE)
.where({ built_in: 0 }) // eslint-disable-line
.delete()
.catch(err =>

View File

@ -1,5 +1,5 @@
class FeatureHasTagError extends Error {
constructor(message) {
constructor(message: string) {
super();
Error.captureStackTrace(this, this.constructor);
@ -7,8 +7,8 @@ class FeatureHasTagError extends Error {
this.message = message;
}
toJSON() {
const obj = {
toJSON(): object {
return {
isJoi: true,
name: this.constructor.name,
details: [
@ -17,7 +17,7 @@ class FeatureHasTagError extends Error {
},
],
};
return obj;
}
}
export default FeatureHasTagError;
module.exports = FeatureHasTagError;

View File

@ -1,7 +1,7 @@
'use strict';
class InvalidOperationError extends Error {
constructor(message) {
constructor(message: string) {
super();
Error.captureStackTrace(this, this.constructor);
@ -9,8 +9,8 @@ class InvalidOperationError extends Error {
this.message = message;
}
toJSON() {
const obj = {
toJSON(): object {
return {
isJoi: true,
name: this.constructor.name,
details: [
@ -19,8 +19,7 @@ class InvalidOperationError extends Error {
},
],
};
return obj;
}
}
export default InvalidOperationError;
module.exports = InvalidOperationError;

View File

@ -1,7 +1,7 @@
'use strict';
class NameExistsError extends Error {
constructor(message) {
constructor(message: string) {
super();
Error.captureStackTrace(this, this.constructor);
@ -9,8 +9,8 @@ class NameExistsError extends Error {
this.message = message;
}
toJSON() {
const obj = {
toJSON(): object {
return {
isJoi: true,
name: this.constructor.name,
details: [
@ -19,8 +19,7 @@ class NameExistsError extends Error {
},
],
};
return obj;
}
}
export default NameExistsError;
module.exports = NameExistsError;

View File

@ -1,7 +1,5 @@
'use strict';
class NotFoundError extends Error {
constructor(message) {
constructor(message?: string) {
super();
Error.captureStackTrace(this, this.constructor);
@ -9,5 +7,5 @@ class NotFoundError extends Error {
this.message = message;
}
}
export default NotFoundError;
module.exports = NotFoundError;

View File

@ -0,0 +1,115 @@
import { Response } from 'express';
import Controller from '../controller';
import { AuthedRequest, IUnleashConfig } from '../../types/core';
import { Logger } from '../../logger';
import ContextService from '../../services/context-service';
import FeatureTypeStore, { IFeatureType } from '../../db/feature-type-store';
import TagTypeService from '../../services/tag-type-service';
import StrategyService from '../../services/strategy-service';
import ProjectService from '../../services/project-service';
import { IContextField } from '../../db/context-field-store';
import { ITagType } from '../../db/tag-type-store';
import { IProject } from '../../db/project-store';
import { IStrategy } from '../../db/strategy-store';
import { IUserPermission } from '../../db/access-store';
import { AccessService } from '../../services/access-service';
import { EmailService } from '../../services/email-service';
export default class BootstrapController extends Controller {
private logger: Logger;
private contextService: ContextService;
private featureTypeStore: FeatureTypeStore;
private tagTypeService: TagTypeService;
private strategyService: StrategyService;
private projectService: ProjectService;
private accessService: AccessService;
private emailService: EmailService;
constructor(
config: IUnleashConfig,
{
contextService,
tagTypeService,
strategyService,
projectService,
accessService,
emailService,
},
) {
super(config);
this.contextService = contextService;
this.tagTypeService = tagTypeService;
this.strategyService = strategyService;
this.projectService = projectService;
this.accessService = accessService;
this.featureTypeStore = config.stores.featureTypeStore;
this.emailService = emailService;
this.logger = config.getLogger(
'routes/admin-api/bootstrap-controller.ts',
);
this.get('/', this.bootstrap);
}
private isContextEnabled(): boolean {
return this.config.ui && this.config.ui.flags && this.config.ui.flags.C;
}
private isProjectEnabled(): boolean {
return this.config.ui && this.config.ui.flags && this.config.ui.flags.P;
}
async bootstrap(req: AuthedRequest, res: Response): Promise<void> {
const jobs: [
Promise<IContextField[]>,
Promise<IFeatureType[]>,
Promise<ITagType[]>,
Promise<IStrategy[]>,
Promise<IProject[]>,
Promise<IUserPermission[]>,
] = [
this.isContextEnabled()
? this.contextService.getAll()
: Promise.resolve([]),
this.featureTypeStore.getAll(),
this.tagTypeService.getAll(),
this.strategyService.getStrategies(),
this.isProjectEnabled()
? this.projectService.getProjects()
: Promise.resolve([]),
this.accessService.getPermissionsForUser(req.user),
];
const [
context,
featureTypes,
tagTypes,
strategies,
projects,
userPermissions,
] = await Promise.all(jobs);
res.json({
...this.config.ui,
unleashUrl: this.config.unleashUrl,
baseUriPath: this.config.baseUriPath,
version: this.config.version,
user: { ...req.user, permissions: userPermissions },
email: this.emailService.isEnabled(),
context,
featureTypes,
tagTypes,
strategies,
projects,
});
}
}
module.exports = BootstrapController;

View File

@ -18,6 +18,7 @@ const ApiTokenController = require('./api-token-controller');
const EmailController = require('./email');
const UserAdminController = require('./user-admin');
const apiDef = require('./api-def.json');
const BootstrapController = require('./bootstrap-controller');
class AdminApi extends Controller {
constructor(config, services) {
@ -50,6 +51,10 @@ class AdminApi extends Controller {
'/ui-config',
new ConfigController(config, services).router,
);
this.app.use(
'/ui-bootstrap',
new BootstrapController(config, services).router,
);
this.app.use(
'/context',
new ContextController(config, services).router,

View File

@ -1,5 +1,10 @@
'use strict';
import ContextFieldStore from '../db/context-field-store';
import EventStore from '../db/event-store';
import ProjectStore from '../db/project-store';
import { Logger } from '../logger';
const { contextSchema, nameSchema } = require('./context-schema');
const NameExistsError = require('../error/name-exists-error');
@ -10,6 +15,14 @@ const {
} = require('../event-type');
class ContextService {
private projectStore: ProjectStore;
private eventStore: EventStore;
private contextFieldStore: ContextFieldStore;
private logger: Logger;
constructor(
{ projectStore, eventStore, contextFieldStore },
{ getLogger },
@ -88,5 +101,5 @@ class ContextService {
await this.validateUniqueName({ name });
}
}
export default ContextService;
module.exports = ContextService;

View File

@ -137,6 +137,10 @@ export class EmailService {
});
}
isEnabled(): boolean {
return this.mailer !== undefined;
}
private async compileTemplate(
templateName: string,
format: TemplateFormat,

View File

@ -1,31 +1,33 @@
import User from '../user';
import { AccessService, RoleName } from './access-service';
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');
const NotFoundError = require('../error/notfound-error');
interface IProject {
id: string;
name: string;
description?: string;
}
import { AccessService, IUserWithRole, RoleName } from './access-service';
import ProjectStore, { IProject } from '../db/project-store';
import EventStore from '../db/event-store';
import NameExistsError from '../error/name-exists-error';
import InvalidOperationError from '../error/invalid-operation-error';
import eventType from '../event-type';
import { nameType } from '../routes/admin-api/util';
import schema from './project-schema';
import NotFoundError from '../error/notfound-error';
import FeatureToggleStore from '../db/feature-toggle-store';
import { IRole } from '../db/access-store';
const getCreatedBy = (user: User) => user.email || user.username;
const DEFAULT_PROJECT = 'default';
class ProjectService {
private projectStore: any;
export interface UsersWithRoles {
users: IUserWithRole[];
roles: IRole[];
}
export default class ProjectService {
private projectStore: ProjectStore;
private accessService: AccessService;
private eventStore: any;
private eventStore: EventStore;
private featureToggleStore: any;
private featureToggleStore: FeatureToggleStore;
private logger: any;
@ -41,11 +43,11 @@ class ProjectService {
this.logger = config.getLogger('services/project-service.js');
}
async getProjects() {
async getProjects(): Promise<IProject[]> {
return this.projectStore.getAll();
}
async getProject(id) {
async getProject(id: number): Promise<IProject> {
return this.projectStore.get(id);
}
@ -127,7 +129,7 @@ class ProjectService {
}
// RBAC methods
async getUsersWithAccess(projectId: string) {
async getUsersWithAccess(projectId: string): Promise<UsersWithRoles> {
const [roles, users] = await this.accessService.getProjectRoleUsers(
projectId,
);

View File

@ -31,5 +31,5 @@ const strategySchema = joi
),
})
.options({ allowUnknown: false, stripUnknown: true, abortEarly: false });
export default strategySchema;
module.exports = strategySchema;

View File

@ -1,3 +1,8 @@
import { Logger } from '../logger';
import EventStore from '../db/event-store';
import StrategyStore, { IStrategy, IStrategyName } from '../db/strategy-store';
import { IUnleashConfig, IUnleashStores } from '../types/core';
const strategySchema = require('./strategy-schema');
const NameExistsError = require('../error/name-exists-error');
const {
@ -9,21 +14,36 @@ const {
} = require('../event-type');
class StrategyService {
constructor({ strategyStore, eventStore }, { getLogger }) {
private logger: Logger;
private strategyStore: StrategyStore;
private eventStore: EventStore;
constructor(
{
strategyStore,
eventStore,
}: Pick<IUnleashStores, 'strategyStore' | 'eventStore'>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
) {
this.strategyStore = strategyStore;
this.eventStore = eventStore;
this.logger = getLogger('services/strategy-service.js');
}
async getStrategies() {
async getStrategies(): Promise<IStrategy[]> {
return this.strategyStore.getStrategies();
}
async getStrategy(name) {
async getStrategy(name: string): Promise<IStrategy> {
return this.strategyStore.getStrategy(name);
}
async removeStrategy(strategyName, userName) {
async removeStrategy(
strategyName: string,
userName: string,
): Promise<void> {
const strategy = await this.strategyStore.getStrategy(strategyName);
await this._validateEditable(strategy);
await this.strategyStore.deleteStrategy({ name: strategyName });
@ -36,7 +56,10 @@ class StrategyService {
});
}
async deprecateStrategy(strategyName, userName) {
async deprecateStrategy(
strategyName: string,
userName: string,
): Promise<void> {
await this.strategyStore.getStrategy(strategyName); // Check existence
await this.strategyStore.deprecateStrategy({ name: strategyName });
await this.eventStore.store({
@ -48,7 +71,10 @@ class StrategyService {
});
}
async reactivateStrategy(strategyName, userName) {
async reactivateStrategy(
strategyName: string,
userName: string,
): Promise<void> {
await this.strategyStore.getStrategy(strategyName); // Check existence
await this.strategyStore.reactivateStrategy({ name: strategyName });
await this.eventStore.store({
@ -60,7 +86,7 @@ class StrategyService {
});
}
async createStrategy(value, userName) {
async createStrategy(value, userName: string): Promise<void> {
const strategy = await strategySchema.validateAsync(value);
strategy.deprecated = false;
await this._validateStrategyName(strategy);
@ -72,7 +98,7 @@ class StrategyService {
});
}
async updateStrategy(input, userName) {
async updateStrategy(input, userName: string): Promise<void> {
const value = await strategySchema.validateAsync(input);
const strategy = await this.strategyStore.getStrategy(input.name);
await this._validateEditable(strategy);
@ -84,7 +110,9 @@ class StrategyService {
});
}
async _validateStrategyName(data) {
private _validateStrategyName(
data: Pick<IStrategy, 'name'>,
): Promise<Pick<IStrategy, 'name'>> {
return new Promise((resolve, reject) => {
this.strategyStore
.getStrategy(data.name)
@ -100,11 +128,11 @@ class StrategyService {
}
// This check belongs in the store.
_validateEditable(strategy) {
_validateEditable(strategy: IStrategy): void {
if (strategy.editable === false) {
throw new Error(`Cannot edit strategy ${strategy.name}`);
}
}
}
export default StrategyService;
module.exports = StrategyService;

View File

@ -49,7 +49,7 @@ export default class TagTypeService {
return data;
}
async validateUnique({ name }: Partial<ITagType>): Promise<boolean> {
async validateUnique({ name }: Pick<ITagType, 'name'>): Promise<boolean> {
const exists = await this.tagTypeStore.exists(name);
if (exists) {
throw new NameExistsError(

View File

@ -1,5 +1,11 @@
import { Request } from 'express';
import { LogProvider } from '../logger';
import { IEmailOptions } from '../services/email-service';
import ProjectStore from '../db/project-store';
import EventStore from '../db/event-store';
import FeatureTypeStore from '../db/feature-type-store';
import User from '../user';
import StrategyStore from '../db/strategy-store';
interface IExperimentalFlags {
[key: string]: boolean;
@ -15,6 +21,14 @@ export interface IUnleashConfig {
};
unleashUrl: string;
email?: IEmailOptions;
stores?: IUnleashStores;
}
export interface IUnleashStores {
projectStore: ProjectStore;
eventStore: EventStore;
featureTypeStore: FeatureTypeStore;
strategyStore: StrategyStore;
}
export enum AuthenticationType {
@ -24,3 +38,7 @@ export enum AuthenticationType {
openSource = 'open-source',
enterprise = 'enterprise',
}
export interface AuthedRequest extends Request {
user: User;
}