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

fix: Cleanup new features API with env support (#929)

This commit is contained in:
Ivar Conradi Østhus 2021-09-13 10:23:57 +02:00 committed by GitHub
parent 15102fe318
commit 90962434d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 2170 additions and 1210 deletions

View File

@ -86,6 +86,7 @@
"errorhandler": "^1.5.1",
"express": "^4.17.1",
"express-session": "^1.17.1",
"fast-json-patch": "^3.1.0",
"gravatar-url": "^3.1.0",
"helmet": "^4.1.0",
"joi": "^17.3.0",

View File

@ -13,12 +13,6 @@ interface IEnvironmentsTable {
created_at?: Date;
}
interface IFeatureEnvironmentRow {
environment: string;
feature_name: string;
enabled: boolean;
}
function mapRow(row: IEnvironmentsTable): IEnvironment {
return {
name: row.name,
@ -100,71 +94,9 @@ export default class EnvironmentStore implements IEnvironmentStore {
return env;
}
async connectProject(
environment: string,
projectId: string,
): Promise<void> {
await this.db('project_environments').insert({
environment_name: environment,
project_id: projectId,
});
}
async connectFeatures(
environment: string,
projectId: string,
): Promise<void> {
const featuresToEnable = await this.db('features')
.select('name')
.where({
project: projectId,
});
const rows: IFeatureEnvironmentRow[] = featuresToEnable.map((f) => ({
environment,
feature_name: f.name,
enabled: false,
}));
if (rows.length > 0) {
await this.db<IFeatureEnvironmentRow>('feature_environments')
.insert(rows)
.onConflict(['environment', 'feature_name'])
.ignore();
}
}
async delete(name: string): Promise<void> {
await this.db(TABLE).where({ name }).del();
}
async disconnectProjectFromEnv(
environment: string,
projectId: string,
): Promise<void> {
await this.db('project_environments')
.where({ environment_name: environment, project_id: projectId })
.del();
}
async connectFeatureToEnvironmentsForProject(
featureName: string,
project_id: string,
): Promise<void> {
const environmentsToEnable = await this.db('project_environments')
.select('environment_name')
.where({ project_id });
await Promise.all(
environmentsToEnable.map(async (env) => {
await this.db('feature_environments')
.insert({
environment: env.environment_name,
feature_name: featureName,
enabled: false,
})
.onConflict(['environment', 'feature_name'])
.ignore();
}),
);
}
destroy(): void {}
}

View File

@ -12,6 +12,12 @@ import NotFoundError from '../error/notfound-error';
const T = { featureEnvs: 'feature_environments' };
interface IFeatureEnvironmentRow {
environment: string;
feature_name: string;
enabled: boolean;
}
export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
private db: Knex;
@ -86,18 +92,19 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
}));
}
async connectEnvironmentAndFeature(
feature_name: string,
async addEnvironmentToFeature(
featureName: string,
environment: string,
enabled: boolean = false,
): Promise<void> {
await this.db('feature_environments')
.insert({ feature_name, environment, enabled })
.insert({ feature_name: featureName, environment, enabled })
.onConflict(['environment', 'feature_name'])
.merge('enabled');
}
async disconnectEnvironmentFromProject(
// TODO: move to project store.
async disconnectFeatures(
environment: string,
project: string,
): Promise<void> {
@ -114,15 +121,6 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
});
}
async enableEnvironmentForFeature(
feature_name: string,
environment: string,
): Promise<void> {
await this.db(T.featureEnvs)
.update({ enabled: true })
.where({ feature_name, environment });
}
async featureHasEnvironment(
environment: string,
featureName: string,
@ -135,15 +133,6 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
return present;
}
async getAllFeatureEnvironments(): Promise<IFeatureEnvironment[]> {
const rows = await this.db(T.featureEnvs);
return rows.map((r) => ({
environment: r.environment,
featureName: r.feature_name,
enabled: r.enabled,
}));
}
async getEnvironmentMetaData(
environment: string,
featureName: string,
@ -176,13 +165,15 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
}
async removeEnvironmentForFeature(
feature_name: string,
featureName: string,
environment: string,
): Promise<void> {
await this.db(T.featureEnvs).where({ feature_name, environment }).del();
await this.db(T.featureEnvs)
.where({ feature_name: featureName, environment })
.del();
}
async toggleEnvironmentEnabledStatus(
async setEnvironmentEnabledStatus(
environment: string,
featureName: string,
enabled: boolean,
@ -192,4 +183,66 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
.where({ environment, feature_name: featureName });
return enabled;
}
async connectProject(
environment: string,
projectId: string,
): Promise<void> {
await this.db('project_environments').insert({
environment_name: environment,
project_id: projectId,
});
}
async connectFeatures(
environment: string,
projectId: string,
): Promise<void> {
const featuresToEnable = await this.db('features')
.select('name')
.where({
project: projectId,
});
const rows: IFeatureEnvironmentRow[] = featuresToEnable.map((f) => ({
environment,
feature_name: f.name,
enabled: false,
}));
if (rows.length > 0) {
await this.db<IFeatureEnvironmentRow>('feature_environments')
.insert(rows)
.onConflict(['environment', 'feature_name'])
.ignore();
}
}
async disconnectProject(
environment: string,
projectId: string,
): Promise<void> {
await this.db('project_environments')
.where({ environment_name: environment, project_id: projectId })
.del();
}
async connectFeatureToEnvironmentsForProject(
featureName: string,
projectId: string,
): Promise<void> {
const environmentsToEnable = await this.db('project_environments')
.select('environment_name')
.where({ project_id: projectId });
await Promise.all(
environmentsToEnable.map(async (env) => {
await this.db('feature_environments')
.insert({
environment: env.environment_name,
feature_name: featureName,
enabled: false,
})
.onConflict(['environment', 'feature_name'])
.ignore();
}),
);
}
}

View File

@ -8,9 +8,9 @@ import NotFoundError from '../error/notfound-error';
import {
FeatureToggleWithEnvironment,
IConstraint,
IEnvironmentOverview,
IFeatureOverview,
IFeatureStrategy,
IFeatureToggleClient,
IFeatureToggleQuery,
IStrategyConfig,
} from '../types/model';
import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
@ -54,7 +54,7 @@ function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy {
return {
id: row.id,
featureName: row.feature_name,
projectName: row.project_name,
projectId: row.project_name,
environment: row.environment,
strategyName: row.strategy_name,
parameters: row.parameters,
@ -67,7 +67,7 @@ function mapInput(input: IFeatureStrategy): IFeatureStrategiesTable {
return {
id: input.id,
feature_name: input.featureName,
project_name: input.projectName,
project_name: input.projectId,
environment: input.environment,
strategy_name: input.strategyName,
parameters: input.parameters,
@ -136,10 +136,15 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
const row = await this.db(T.featureStrategies)
.where({ id: key })
.first();
if (!row) {
throw new NotFoundError(`Could not find strategy with id=${key}`);
}
return mapRow(row);
}
async createStrategyConfig(
async createStrategyFeatureEnv(
strategyConfig: Omit<IFeatureStrategy, 'id' | 'createdAt'>,
): Promise<IFeatureStrategy> {
const strategyRow = mapInput({ ...strategyConfig, id: uuidv4() });
@ -149,43 +154,12 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
return mapRow(rows[0]);
}
async getStrategiesForToggle(
async removeAllStrategiesForFeatureEnv(
featureName: string,
): Promise<IFeatureStrategy[]> {
const stopTimer = this.timer('getAll');
const rows = await this.db
.select(COLUMNS)
.where('feature_name', featureName)
.from<IFeatureStrategiesTable>(T.featureStrategies);
stopTimer();
return rows.map(mapRow);
}
async getAllFeatureStrategies(): Promise<IFeatureStrategy[]> {
const rows = await this.db(T.featureStrategies).select(COLUMNS);
return rows.map(mapRow);
}
async getStrategiesForEnvironment(
environment: string,
): Promise<IFeatureStrategy[]> {
const stopTimer = this.timer('getAll');
const rows = await this.db
.select(COLUMNS)
.where({ environment })
.from<IFeatureStrategiesTable>(T.featureStrategies);
stopTimer();
return rows.map(mapRow);
}
async removeAllStrategiesForEnv(
feature_name: string,
environment: string,
): Promise<void> {
await this.db('feature_strategies')
.where({ feature_name, environment })
.where({ feature_name: featureName, environment })
.del();
}
@ -199,37 +173,24 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
return rows.map(mapRow);
}
async getStrategiesForFeature(
project_name: string,
feature_name: string,
async getStrategiesForFeatureEnv(
projectId: string,
featureName: string,
environment: string,
): Promise<IFeatureStrategy[]> {
const stopTimer = this.timer('getForFeature');
const rows = await this.db<IFeatureStrategiesTable>(
T.featureStrategies,
).where({
project_name,
feature_name,
project_name: projectId,
feature_name: featureName,
environment,
});
stopTimer();
return rows.map(mapRow);
}
async getStrategiesForEnv(
environment: string,
): Promise<IFeatureStrategy[]> {
const stopTimer = this.timer('getStrategiesForEnv');
const rows = await this.db<IFeatureStrategiesTable>(
T.featureStrategies,
).where({
environment,
});
stopTimer();
return rows.map(mapRow);
}
async getFeatureToggleAdmin(
async getFeatureToggleWithEnvs(
featureName: string,
archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> {
@ -256,11 +217,17 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'feature_environments.feature_name',
'features.name',
)
.fullOuterJoin(
'feature_strategies',
'feature_strategies.feature_name',
'features.name',
)
.fullOuterJoin('feature_strategies', function () {
this.on(
'feature_strategies.feature_name',
'=',
'feature_environments.feature_name',
).andOn(
'feature_strategies.environment',
'=',
'feature_environments.environment',
);
})
.where({ name: featureName, archived: archived ? 1 : 0 });
stopTimer();
if (rows.length > 0) {
@ -286,7 +253,9 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
if (!env.strategies) {
env.strategies = [];
}
env.strategies.push(this.getAdminStrategy(r));
if (r.strategy_id) {
env.strategies.push(this.getAdminStrategy(r));
}
acc.environments[r.environment] = env;
return acc;
}, {});
@ -301,99 +270,65 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
);
}
async getFeatures(
featureQuery?: IFeatureToggleQuery,
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
private getEnvironment(r: any): IEnvironmentOverview {
return {
name: r.environment,
displayName: r.display_name,
enabled: r.enabled,
};
}
async getFeatureOverview(
projectId: string,
archived: boolean = false,
isAdmin: boolean = true,
): Promise<IFeatureToggleClient[]> {
const environments = [':global:'];
if (featureQuery?.environment) {
environments.push(featureQuery.environment);
}
const stopTimer = this.timer('getFeatureAdmin');
let query = this.db('features')
): Promise<IFeatureOverview[]> {
const rows = await this.db('features')
.where({ project: projectId, archived })
.select(
'features.name as name',
'features.description as description',
'features.name as feature_name',
'features.type as type',
'features.project as project',
'features.stale as stale',
'features.variants as variants',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'features.stale as stale',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'feature_strategies.id as strategy_id',
'feature_strategies.strategy_name as strategy_name',
'feature_strategies.parameters as parameters',
'feature_strategies.constraints as constraints',
'environments.display_name as display_name',
)
.where({ archived })
.whereIn('feature_environments.environment', environments)
.fullOuterJoin(
'feature_environments',
'feature_environments.feature_name',
'features.name',
)
.fullOuterJoin(
'feature_strategies',
'feature_strategies.feature_name',
'features.name',
'environments',
'feature_environments.environment',
'environments.name',
);
if (featureQuery) {
if (featureQuery.tag) {
const tagQuery = this.db
.from('feature_tag')
.select('feature_name')
.whereIn(['tag_type', 'tag_value'], featureQuery.tag);
query = query.whereIn('name', tagQuery);
}
if (featureQuery.project) {
query = query.whereIn('project', featureQuery.project);
}
if (featureQuery.namePrefix) {
query = query.where(
'name',
'like',
`${featureQuery.namePrefix}%`,
);
}
if (rows.length > 0) {
const overview = rows.reduce((acc, r) => {
if (acc[r.feature_name] !== undefined) {
acc[r.feature_name].environments.push(
this.getEnvironment(r),
);
} else {
acc[r.feature_name] = {
type: r.type,
name: r.feature_name,
createdAt: r.created_at,
lastSeenAt: r.last_seen_at,
stale: r.stale,
environments: [this.getEnvironment(r)],
};
}
return acc;
}, {});
return Object.values(overview).map((o: IFeatureOverview) => ({
...o,
environments: o.environments.filter((f) => f.name),
}));
}
const rows = await query;
stopTimer();
const featureToggles = rows.reduce((acc, r) => {
let feature;
if (acc[r.name]) {
feature = acc[r.name];
} else {
feature = {};
}
if (!feature.strategies) {
feature.strategies = [];
}
if (r.strategy_name) {
feature.strategies.push(this.getAdminStrategy(r, isAdmin));
}
if (feature.enabled === undefined) {
feature.enabled = r.enabled;
} else {
feature.enabled = feature.enabled && r.enabled;
}
feature.name = r.name;
feature.description = r.description;
feature.project = r.project;
feature.stale = r.stale;
feature.type = r.type;
feature.variants = r.variants;
feature.project = r.project;
if (isAdmin) {
feature.lastSeenAt = r.last_seen_at;
feature.createdAt = r.created_at;
}
acc[r.name] = feature;
return acc;
}, {});
return Object.values(featureToggles);
return [];
}
async getStrategyById(id: string): Promise<IFeatureStrategy> {
@ -432,31 +367,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
return strategy;
}
async getStrategiesAndMetadataForEnvironment(
environment: string,
featureName: string,
): Promise<void> {
const rows = await this.db(T.featureEnvs)
.select('*')
.fullOuterJoin(
T.featureStrategies,
`${T.featureEnvs}.feature_name`,
`${T.featureStrategies}.feature_name`,
)
.where(`${T.featureStrategies}.feature_name`, featureName)
.andWhere(`${T.featureEnvs}.environment`, environment);
return rows.reduce((acc, r) => {
if (acc.strategies !== undefined) {
acc.strategies.push(this.getAdminStrategy(r));
} else {
acc.enabled = r.enabled;
acc.environment = r.environment;
acc.strategies = [this.getAdminStrategy(r)];
}
return acc;
}, {});
}
async deleteConfigurationsForProjectAndEnvironment(
projectId: String,
environment: String,

View File

@ -0,0 +1,172 @@
import { Knex } from 'knex';
import EventEmitter from 'events';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import { Logger, LogProvider } from '../logger';
import {
IFeatureToggleClient,
IFeatureToggleQuery,
IStrategyConfig,
} from '../types/model';
import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store';
export interface FeaturesTable {
name: string;
description: string;
type: string;
stale: boolean;
variants: string;
project: string;
last_seen_at?: Date;
created_at?: Date;
}
export default class FeatureToggleClientStore
implements IFeatureToggleClientStore
{
private db: Knex;
private logger: Logger;
private timer: Function;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('feature-toggle-client-store.ts');
this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-toggle',
action,
});
}
private getAdminStrategy(
r: any,
includeId: boolean = true,
): IStrategyConfig {
const strategy = {
name: r.strategy_name,
constraints: r.constraints || [],
parameters: r.parameters,
id: r.strategy_id,
};
if (!includeId) {
delete strategy.id;
}
return strategy;
}
private async getAll(
featureQuery?: IFeatureToggleQuery,
archived: boolean = false,
isAdmin: boolean = true,
): Promise<IFeatureToggleClient[]> {
const environments = [':global:'];
if (featureQuery?.environment) {
environments.push(featureQuery.environment);
}
const stopTimer = this.timer('getFeatureAdmin');
let query = this.db('features')
.select(
'features.name as name',
'features.description as description',
'features.type as type',
'features.project as project',
'features.stale as stale',
'features.variants as variants',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'feature_strategies.id as strategy_id',
'feature_strategies.strategy_name as strategy_name',
'feature_strategies.parameters as parameters',
'feature_strategies.constraints as constraints',
)
.fullOuterJoin(
'feature_environments',
'feature_environments.feature_name',
'features.name',
)
.fullOuterJoin('feature_strategies', function () {
this.on(
'feature_strategies.feature_name',
'features.name',
).andOn(
'feature_strategies.environment',
'feature_environments.environment',
);
})
.whereIn('feature_environments.environment', environments)
.where({ archived });
if (featureQuery) {
if (featureQuery.tag) {
const tagQuery = this.db
.from('feature_tag')
.select('feature_name')
.whereIn(['tag_type', 'tag_value'], featureQuery.tag);
query = query.whereIn('name', tagQuery);
}
if (featureQuery.project) {
query = query.whereIn('project', featureQuery.project);
}
if (featureQuery.namePrefix) {
query = query.where(
'name',
'like',
`${featureQuery.namePrefix}%`,
);
}
}
const rows = await query;
stopTimer();
const featureToggles = rows.reduce((acc, r) => {
let feature;
if (acc[r.name]) {
feature = acc[r.name];
} else {
feature = {};
}
if (!feature.strategies) {
feature.strategies = [];
}
if (r.strategy_name) {
feature.strategies.push(this.getAdminStrategy(r, isAdmin));
}
if (feature.enabled === undefined) {
feature.enabled = r.enabled;
} else {
feature.enabled = feature.enabled && r.enabled;
}
feature.name = r.name;
feature.description = r.description;
feature.project = r.project;
feature.stale = r.stale;
feature.type = r.type;
feature.variants = r.variants;
feature.project = r.project;
if (isAdmin) {
feature.lastSeenAt = r.last_seen_at;
feature.createdAt = r.created_at;
}
acc[r.name] = feature;
return acc;
}, {});
return Object.values(featureToggles);
}
async getClient(
featureQuery?: IFeatureToggleQuery,
): Promise<IFeatureToggleClient[]> {
return this.getAll(featureQuery, false, false);
}
async getAdmin(
featureQuery?: IFeatureToggleQuery,
archived: boolean = false,
): Promise<IFeatureToggleClient[]> {
return this.getAll(featureQuery, archived, true);
}
}
module.exports = FeatureToggleClientStore;

View File

@ -62,14 +62,6 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.then((res) => Number(res[0].count));
}
async getFeatureMetadata(name: string): Promise<FeatureToggle> {
return this.db
.first(FEATURE_COLUMNS)
.from(TABLE)
.where({ name, archived: 0 })
.then(this.rowToFeature);
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
@ -80,15 +72,21 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return this.db
.first(FEATURE_COLUMNS)
.from(TABLE)
.where({ name, archived: 0 })
.where({ name })
.then(this.rowToFeature);
}
async getAll(): Promise<FeatureToggle[]> {
async getAll(
query: {
archived?: boolean;
project?: string;
stale?: boolean;
} = { archived: false },
): Promise<FeatureToggle[]> {
const rows = await this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
.where({ archived: false });
.where(query);
return rows.map(this.rowToFeature);
}
@ -117,26 +115,6 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
});
}
/**
* TODO: Should really be a Promise<boolean> rather than returning a { featureName, archived } object
* @param name
*/
async hasFeature(name: string): Promise<any> {
return this.db
.first('name', 'archived')
.from(TABLE)
.where({ name })
.then((row) => {
if (!row) {
throw new NotFoundError('No feature toggle found');
}
return {
name: row.name,
archived: row.archived,
};
});
}
async exists(name: string): Promise<boolean> {
const result = await this.db.raw(
'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',
@ -150,12 +128,12 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
const rows = await this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
.where({ archived: 1 })
.where({ archived: true })
.orderBy('name', 'asc');
return rows.map(this.rowToFeature);
}
async updateLastSeenForToggles(toggleNames: string[]): Promise<void> {
async setLastSeen(toggleNames: string[]): Promise<void> {
const now = new Date();
try {
await this.db(TABLE)
@ -206,7 +184,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return row;
}
async createFeature(
async create(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle> {
@ -222,7 +200,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return undefined;
}
async updateFeature(
async update(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle> {
@ -233,7 +211,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return this.rowToFeature(row[0]);
}
async archiveFeature(name: string): Promise<FeatureToggle> {
async archive(name: string): Promise<FeatureToggle> {
const row = await this.db(TABLE)
.where({ name })
.update({ archived: true })
@ -247,30 +225,13 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.del();
}
async reviveFeature(name: string): Promise<FeatureToggle> {
async revive(name: string): Promise<FeatureToggle> {
const row = await this.db(TABLE)
.where({ name })
.update({ archived: false })
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
}
async getFeaturesBy(params: {
archived?: boolean;
project?: string;
stale?: boolean;
}): Promise<FeatureToggle[]> {
const rows = await this.db(TABLE).where(params);
return rows.map(this.rowToFeature);
}
async getFeaturesByInternal(params: {
archived?: boolean;
project?: string;
stale?: boolean;
}): Promise<FeatureToggle[]> {
return this.db(TABLE).where(params);
}
}
module.exports = FeatureToggleStore;

View File

@ -24,6 +24,7 @@ import { AccessStore } from './access-store';
import { ResetTokenStore } from './reset-token-store';
import UserFeedbackStore from './user-feedback-store';
import FeatureStrategyStore from './feature-strategy-store';
import FeatureToggleClientStore from './feature-toggle-client-store';
import EnvironmentStore from './environment-store';
import FeatureTagStore from './feature-tag-store';
import { FeatureEnvironmentStore } from './feature-environment-store';
@ -70,6 +71,11 @@ export const createStores = (
eventBus,
getLogger,
),
featureToggleClientStore: new FeatureToggleClientStore(
db,
eventBus,
getLogger,
),
environmentStore: new EnvironmentStore(db, eventBus, getLogger),
featureTagStore: new FeatureTagStore(db, eventBus, getLogger),
featureEnvironmentStore: new FeatureEnvironmentStore(

View File

@ -2,11 +2,7 @@ import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error';
import {
IEnvironmentOverview,
IFeatureOverview,
IProject,
} from '../types/model';
import { IProject } from '../types/model';
import {
IProjectHealthUpdate,
IProjectInsert,
@ -159,7 +155,7 @@ class ProjectStore implements IProjectStore {
.where({
project_id: id,
})
.returning('environment_name');
.pluck('environment_name');
}
async getMembers(projectId: string): Promise<number> {
@ -179,58 +175,6 @@ class ProjectStore implements IProjectStore {
return members;
}
async getProjectOverview(
projectId: string,
archived: boolean = false,
): Promise<IFeatureOverview[]> {
const rows = await this.db('features')
.where({ project: projectId, archived })
.select(
'features.name as feature_name',
'features.type as type',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'features.stale as stale',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'environments.display_name as display_name',
)
.fullOuterJoin(
'feature_environments',
'feature_environments.feature_name',
'features.name',
)
.fullOuterJoin(
'environments',
'feature_environments.environment',
'environments.name',
);
if (rows.length > 0) {
const overview = rows.reduce((acc, r) => {
if (acc[r.feature_name] !== undefined) {
acc[r.feature_name].environments.push(
this.getEnvironment(r),
);
} else {
acc[r.feature_name] = {
type: r.type,
name: r.feature_name,
createdAt: r.created_at,
lastSeenAt: r.last_seen_at,
stale: r.stale,
environments: [this.getEnvironment(r)],
};
}
return acc;
}, {});
return Object.values(overview).map((o: IFeatureOverview) => ({
...o,
environments: o.environments.filter((f) => f.name),
}));
}
return [];
}
async count(): Promise<number> {
return this.db
.count('*')
@ -238,15 +182,6 @@ class ProjectStore implements IProjectStore {
.then((res) => Number(res[0].count));
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
private getEnvironment(r: any): IEnvironmentOverview {
return {
name: r.environment,
displayName: r.display_name,
enabled: r.enabled,
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
mapRow(row): IProject {
if (!row) {

View File

@ -5,7 +5,6 @@ import { IUnleashConfig } from '../../types/option';
import { IEnvironment } from '../../types/model';
import EnvironmentService from '../../services/environment-service';
import { Logger } from '../../logger';
import { handleErrors } from '../util';
import { ADMIN } from '../../types/permissions';
interface EnvironmentParam {
@ -32,24 +31,16 @@ export class EnvironmentsController extends Controller {
}
async getAll(req: Request, res: Response): Promise<void> {
try {
const environments = await this.service.getAll();
res.status(200).json({ version: 1, environments });
} catch (e) {
handleErrors(res, this.logger, e);
}
const environments = await this.service.getAll();
res.status(200).json({ version: 1, environments });
}
async createEnv(
req: Request<any, any, IEnvironment, any>,
res: Response,
): Promise<void> {
try {
const environment = await this.service.create(req.body);
res.status(201).json(environment);
} catch (e) {
handleErrors(res, this.logger, e);
}
const environment = await this.service.create(req.body);
res.status(201).json(environment);
}
async getEnv(
@ -57,12 +48,8 @@ export class EnvironmentsController extends Controller {
res: Response,
): Promise<void> {
const { name } = req.params;
try {
const env = await this.service.get(name);
res.status(200).json(env);
} catch (e) {
handleErrors(res, this.logger, e);
}
const env = await this.service.get(name);
res.status(200).json(env);
}
async updateEnv(
@ -70,12 +57,8 @@ export class EnvironmentsController extends Controller {
res: Response,
): Promise<void> {
const { name } = req.params;
try {
const env = await this.service.update(name, req.body);
res.status(200).json(env);
} catch (e) {
handleErrors(res, this.logger, e);
}
const env = await this.service.update(name, req.body);
res.status(200).json(env);
}
async deleteEnv(
@ -83,11 +66,7 @@ export class EnvironmentsController extends Controller {
res: Response,
): Promise<void> {
const { name } = req.params;
try {
await this.service.delete(name);
res.status(200).end();
} catch (e) {
handleErrors(res, this.logger, e);
}
await this.service.delete(name);
res.status(200).end();
}
}

View File

@ -177,7 +177,8 @@ class FeatureController extends Controller {
),
);
await this.featureService2.updateEnabled(
validatedToggle.name,
createdFeature.project,
createdFeature.name,
GLOBAL_ENV,
enabled,
userName,
@ -222,6 +223,7 @@ class FeatureController extends Controller {
);
}
await this.featureService2.updateEnabled(
projectId,
updatedFeature.name,
GLOBAL_ENV,
updatedFeature.enabled,
@ -236,9 +238,11 @@ class FeatureController extends Controller {
// Kept to keep backward compatibility
async toggle(req: Request, res: Response): Promise<void> {
const userName = extractUser(req);
const name = req.params.featureName;
const { featureName } = req.params;
const projectId = await this.featureService2.getProjectId(featureName);
const feature = await this.featureService2.toggle(
name,
projectId,
featureName,
GLOBAL_ENV,
userName,
);
@ -248,7 +252,9 @@ class FeatureController extends Controller {
async toggleOn(req: Request, res: Response): Promise<void> {
const { featureName } = req.params;
const userName = extractUser(req);
const projectId = await this.featureService2.getProjectId(featureName);
const feature = await this.featureService2.updateEnabled(
projectId,
featureName,
GLOBAL_ENV,
true,
@ -260,7 +266,9 @@ class FeatureController extends Controller {
async toggleOff(req: Request, res: Response): Promise<void> {
const { featureName } = req.params;
const userName = extractUser(req);
const projectId = await this.featureService2.getProjectId(featureName);
const feature = await this.featureService2.updateEnabled(
projectId,
featureName,
GLOBAL_ENV,
false,

View File

@ -1,6 +1,5 @@
import { Request, Response } from 'express';
import Controller from '../controller';
import { handleErrors } from '../util';
import { UPDATE_APPLICATION } from '../../types/permissions';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
@ -42,89 +41,57 @@ class MetricsController extends Controller {
}
async getSeenToggles(req: Request, res: Response): Promise<void> {
try {
const seenAppToggles = await this.metrics.getAppsWithToggles();
res.json(seenAppToggles);
} catch (e) {
handleErrors(res, this.logger, e);
}
const seenAppToggles = await this.metrics.getAppsWithToggles();
res.json(seenAppToggles);
}
async getSeenApps(req: Request, res: Response): Promise<void> {
try {
const seenApps = await this.metrics.getSeenApps();
res.json(seenApps);
} catch (e) {
handleErrors(res, this.logger, e);
}
const seenApps = await this.metrics.getSeenApps();
res.json(seenApps);
}
async getFeatureToggles(req: Request, res: Response): Promise<void> {
try {
const toggles = await this.metrics.getTogglesMetrics();
res.json(toggles);
} catch (e) {
handleErrors(res, this.logger, e);
}
const toggles = await this.metrics.getTogglesMetrics();
res.json(toggles);
}
async getFeatureToggle(req: Request, res: Response): Promise<void> {
try {
const { name } = req.params;
const data = await this.metrics.getTogglesMetrics();
const lastHour = data.lastHour[name] || {};
const lastMinute = data.lastMinute[name] || {};
res.json({
lastHour,
lastMinute,
});
} catch (e) {
handleErrors(res, this.logger, e);
}
const { name } = req.params;
const data = await this.metrics.getTogglesMetrics();
const lastHour = data.lastHour[name] || {};
const lastMinute = data.lastMinute[name] || {};
res.json({
lastHour,
lastMinute,
});
}
async deleteApplication(req: Request, res: Response): Promise<void> {
const { appName } = req.params;
try {
await this.metrics.deleteApplication(appName);
res.status(200).end();
} catch (e) {
handleErrors(res, this.logger, e);
}
await this.metrics.deleteApplication(appName);
res.status(200).end();
}
async createApplication(req: Request, res: Response): Promise<void> {
const input = { ...req.body, appName: req.params.appName };
try {
await this.metrics.createApplication(input);
res.status(202).end();
} catch (err) {
handleErrors(res, this.logger, err);
}
await this.metrics.createApplication(input);
res.status(202).end();
}
async getApplications(req: Request, res: Response): Promise<void> {
try {
const query = req.query.strategyName
? { strategyName: req.query.strategyName as string }
: {};
const applications = await this.metrics.getApplications(query);
res.json({ applications });
} catch (err) {
handleErrors(res, this.logger, err);
}
const query = req.query.strategyName
? { strategyName: req.query.strategyName as string }
: {};
const applications = await this.metrics.getApplications(query);
res.json({ applications });
}
async getApplication(req: Request, res: Response): Promise<void> {
const { appName } = req.params;
try {
const appDetails = await this.metrics.getApplication(appName);
res.json(appDetails);
} catch (err) {
handleErrors(res, this.logger, err);
}
const appDetails = await this.metrics.getApplication(appName);
res.json(appDetails);
}
}
export default MetricsController;

View File

@ -4,8 +4,8 @@ import { IUnleashConfig } from '../../../types/option';
import { IUnleashServices } from '../../../types/services';
import { Logger } from '../../../logger';
import EnvironmentService from '../../../services/environment-service';
import { handleErrors } from '../../util';
import { UPDATE_PROJECT } from '../../../types/permissions';
import { addEnvironment } from '../../../schema/project-schema';
const PREFIX = '/:projectId/environments';
@ -49,15 +49,14 @@ export default class EnvironmentsController extends Controller {
res: Response,
): Promise<void> {
const { projectId } = req.params;
try {
await this.environmentService.connectProjectToEnvironment(
req.body.environment,
projectId,
);
res.status(200).end();
} catch (e) {
handleErrors(res, this.logger, e);
}
const { environment } = await addEnvironment.validateAsync(req.body);
await this.environmentService.addEnvironmentToProject(
environment,
projectId,
);
res.status(200).end();
}
async removeEnvironmentFromProject(
@ -65,14 +64,10 @@ export default class EnvironmentsController extends Controller {
res: Response,
): Promise<void> {
const { projectId, environment } = req.params;
try {
await this.environmentService.removeEnvironmentFromProject(
environment,
projectId,
);
res.status(200).end();
} catch (e) {
handleErrors(res, this.logger, e);
}
await this.environmentService.removeEnvironmentFromProject(
environment,
projectId,
);
res.status(200).end();
}
}

View File

@ -1,17 +1,21 @@
import { Request, Response } from 'express';
import { applyPatch, Operation } from 'fast-json-patch';
import Controller from '../../controller';
import { IUnleashConfig } from '../../../types/option';
import { IUnleashServices } from '../../../types/services';
import FeatureToggleServiceV2 from '../../../services/feature-toggle-service-v2';
import { Logger } from '../../../logger';
import { CREATE_FEATURE, UPDATE_FEATURE } from '../../../types/permissions';
import {
CREATE_FEATURE,
DELETE_FEATURE,
UPDATE_FEATURE,
} from '../../../types/permissions';
import {
FeatureToggleDTO,
IConstraint,
IStrategyConfig,
} from '../../../types/model';
import extractUsername from '../../../extract-user';
import ProjectHealthService from '../../../services/project-health-service';
interface FeatureStrategyParams {
projectId: string;
@ -37,7 +41,11 @@ interface StrategyUpdateBody {
parameters?: object;
}
const PATH_PREFIX = '/:projectId/features/:featureName';
const PATH = '/:projectId/features';
const PATH_FEATURE = `${PATH}/:featureName`;
const PATH_ENV = `${PATH_FEATURE}/environments/:environment`;
const PATH_STRATEGIES = `${PATH_ENV}/strategies`;
const PATH_STRATEGY = `${PATH_STRATEGIES}/:strategyId`;
type ProjectFeaturesServices = Pick<
IUnleashServices,
@ -47,76 +55,49 @@ type ProjectFeaturesServices = Pick<
export default class ProjectFeaturesController extends Controller {
private featureService: FeatureToggleServiceV2;
private projectHealthService: ProjectHealthService;
private readonly logger: Logger;
constructor(
config: IUnleashConfig,
{
featureToggleServiceV2,
projectHealthService,
}: ProjectFeaturesServices,
{ featureToggleServiceV2 }: ProjectFeaturesServices,
) {
super(config);
this.featureService = featureToggleServiceV2;
this.projectHealthService = projectHealthService;
this.logger = config.getLogger('/admin-api/project/features.ts');
this.post(
`${PATH_PREFIX}/environments/:environment/strategies`,
this.createFeatureStrategy,
UPDATE_FEATURE,
);
this.get(
`${PATH_PREFIX}/environments/:environment`,
this.getEnvironment,
);
this.post(
`${PATH_PREFIX}/environments/:environment/on`,
this.toggleEnvironmentOn,
UPDATE_FEATURE,
);
this.get(`${PATH_ENV}`, this.getEnvironment);
this.post(`${PATH_ENV}/on`, this.toggleEnvironmentOn, UPDATE_FEATURE);
this.post(`${PATH_ENV}/off`, this.toggleEnvironmentOff, UPDATE_FEATURE);
this.post(
`${PATH_PREFIX}/environments/:environment/off`,
this.toggleEnvironmentOff,
UPDATE_FEATURE,
);
this.get(
`${PATH_PREFIX}/environments/:environment/strategies`,
this.getFeatureStrategies,
);
this.get(
`${PATH_PREFIX}/environments/:environment/strategies/:strategyId`,
this.getStrategy,
);
this.put(
`${PATH_PREFIX}/environments/:environment/strategies/:strategyId`,
this.updateStrategy,
UPDATE_FEATURE,
);
this.post(
'/:projectId/features',
this.createFeatureToggle,
CREATE_FEATURE,
);
this.get('/:projectId/features', this.getFeaturesForProject);
this.get(PATH_PREFIX, this.getFeature);
this.get(`${PATH_STRATEGIES}`, this.getStrategies);
this.post(`${PATH_STRATEGIES}`, this.addStrategy, UPDATE_FEATURE);
this.get(`${PATH_STRATEGY}`, this.getStrategy);
this.put(`${PATH_STRATEGY}`, this.updateStrategy, UPDATE_FEATURE);
this.patch(`${PATH_STRATEGY}`, this.patchStrategy, UPDATE_FEATURE);
this.delete(`${PATH_STRATEGY}`, this.deleteStrategy, DELETE_FEATURE);
this.get(PATH, this.getFeatures);
this.post(PATH, this.createFeature, CREATE_FEATURE);
this.get(PATH_FEATURE, this.getFeature);
this.put(PATH_FEATURE, this.updateFeature);
this.patch(PATH_FEATURE, this.patchFeature);
this.delete(PATH_FEATURE, this.archiveFeature);
}
async getFeaturesForProject(
async getFeatures(
req: Request<ProjectParam, any, any, any>,
res: Response,
): Promise<void> {
const { projectId } = req.params;
const features = await this.featureService.getFeatureToggles({
project: [projectId],
});
const features = await this.featureService.getFeatureOverview(
projectId,
);
res.json({ version: 1, features });
}
async createFeatureToggle(
async createFeature(
req: Request<ProjectParam, any, FeatureToggleDTO, any>,
res: Response,
): Promise<void> {
@ -130,6 +111,64 @@ export default class ProjectFeaturesController extends Controller {
res.status(201).json(created);
}
async getFeature(
req: Request<FeatureParams, any, any, any>,
res: Response,
): Promise<void> {
const { featureName } = req.params;
const feature = await this.featureService.getFeature(featureName);
res.status(200).json(feature);
}
async updateFeature(
req: Request<ProjectParam, any, FeatureToggleDTO, any>,
res: Response,
): Promise<void> {
const { projectId } = req.params;
const data = req.body;
const userName = extractUsername(req);
const created = await this.featureService.updateFeatureToggle(
projectId,
data,
userName,
);
res.status(200).json(created);
}
async patchFeature(
req: Request<
{ projectId: string; featureName: string },
any,
Operation[],
any
>,
res: Response,
): Promise<void> {
const { projectId, featureName } = req.params;
const featureToggle = await this.featureService.getFeatureMetadata(
featureName,
);
const { newDocument } = applyPatch(featureToggle, req.body);
const userName = extractUsername(req);
const updated = await this.featureService.updateFeatureToggle(
projectId,
newDocument,
userName,
);
res.status(200).json(updated);
}
// TODO: validate projectId
async archiveFeature(
req: Request<{ projectId: string; featureName: string }, any, any, any>,
res: Response,
): Promise<void> {
const { featureName } = req.params;
const userName = extractUsername(req);
await this.featureService.archiveToggle(featureName, userName);
res.status(202).send();
}
async getEnvironment(
req: Request<FeatureStrategyParams, any, any, any>,
res: Response,
@ -143,21 +182,13 @@ export default class ProjectFeaturesController extends Controller {
res.status(200).json(environmentInfo);
}
async getFeature(
req: Request<FeatureParams, any, any, any>,
res: Response,
): Promise<void> {
const { featureName } = req.params;
const feature = await this.featureService.getFeature(featureName);
res.status(200).json(feature);
}
async toggleEnvironmentOn(
req: Request<FeatureStrategyParams, any, any, any>,
res: Response,
): Promise<void> {
const { featureName, environment } = req.params;
const { featureName, environment, projectId } = req.params;
await this.featureService.updateEnabled(
projectId,
featureName,
environment,
true,
@ -170,8 +201,9 @@ export default class ProjectFeaturesController extends Controller {
req: Request<FeatureStrategyParams, any, any, any>,
res: Response,
): Promise<void> {
const { featureName, environment } = req.params;
const { featureName, environment, projectId } = req.params;
await this.featureService.updateEnabled(
projectId,
featureName,
environment,
false,
@ -180,7 +212,7 @@ export default class ProjectFeaturesController extends Controller {
res.status(200).end();
}
async createFeatureStrategy(
async addStrategy(
req: Request<FeatureStrategyParams, any, IStrategyConfig, any>,
res: Response,
): Promise<void> {
@ -194,7 +226,7 @@ export default class ProjectFeaturesController extends Controller {
res.status(200).json(featureStrategy);
}
async getFeatureStrategies(
async getStrategies(
req: Request<FeatureStrategyParams, any, any, any>,
res: Response,
): Promise<void> {
@ -220,6 +252,21 @@ export default class ProjectFeaturesController extends Controller {
res.status(200).json(updatedStrategy);
}
async patchStrategy(
req: Request<StrategyIdParams, any, Operation[], any>,
res: Response,
): Promise<void> {
const { strategyId } = req.params;
const patch = req.body;
const strategy = await this.featureService.getStrategy(strategyId);
const { newDocument } = applyPatch(strategy, patch);
const updatedStrategy = await this.featureService.updateStrategy(
strategyId,
newDocument,
);
res.status(200).json(updatedStrategy);
}
async getStrategy(
req: Request<StrategyIdParams, any, any, any>,
res: Response,
@ -230,4 +277,46 @@ export default class ProjectFeaturesController extends Controller {
const strategy = await this.featureService.getStrategy(strategyId);
res.status(200).json(strategy);
}
async deleteStrategy(
req: Request<StrategyIdParams, any, any, any>,
res: Response,
): Promise<void> {
this.logger.info('Deleting strategy');
const { strategyId } = req.params;
this.logger.info(strategyId);
const strategy = await this.featureService.deleteStrategy(strategyId);
res.status(200).json(strategy);
}
async updateStrategyParameter(
req: Request<
StrategyIdParams,
any,
{ name: string; value: string | number },
any
>,
res: Response,
): Promise<void> {
const { strategyId } = req.params;
const { name, value } = req.body;
const updatedStrategy =
await this.featureService.updateStrategyParameter(
strategyId,
name,
value,
);
res.status(200).json(updatedStrategy);
}
async getStrategyParameters(
req: Request<StrategyIdParams, any, any, any>,
res: Response,
): Promise<void> {
this.logger.info('Getting strategy parameters');
const { strategyId } = req.params;
const strategy = await this.featureService.getStrategy(strategyId);
res.status(200).json(strategy.parameters);
}
}

View File

@ -5,7 +5,6 @@ import { IUnleashConfig } from '../../../types/option';
import ProjectHealthService from '../../../services/project-health-service';
import { Logger } from '../../../logger';
import { IArchivedQuery, IProjectParam } from '../../../types/model';
import { handleErrors } from '../../util';
export default class ProjectHealthReport extends Controller {
private projectHealthService: ProjectHealthService;
@ -31,15 +30,11 @@ export default class ProjectHealthReport extends Controller {
): Promise<void> {
const { projectId } = req.params;
const { archived } = req.query;
try {
const overview = await this.projectHealthService.getProjectOverview(
projectId,
archived,
);
res.json(overview);
} catch (e) {
handleErrors(res, this.logger, e);
}
const overview = await this.projectHealthService.getProjectOverview(
projectId,
archived,
);
res.json(overview);
}
async getProjectHealthReport(
@ -47,17 +42,12 @@ export default class ProjectHealthReport extends Controller {
res: Response,
): Promise<void> {
const { projectId } = req.params;
try {
const overview =
await this.projectHealthService.getProjectHealthReport(
projectId,
);
res.json({
version: 2,
...overview,
});
} catch (e) {
handleErrors(res, this.logger, e);
}
const overview = await this.projectHealthService.getProjectHealthReport(
projectId,
);
res.json({
version: 2,
...overview,
});
}
}

View File

@ -2,7 +2,6 @@ import { Request, Response } from 'express';
import Controller from '../controller';
import { UPDATE_FEATURE } from '../../types/permissions';
import { handleErrors } from '../util';
import extractUsername from '../../extract-user';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
@ -32,70 +31,48 @@ class TagTypeController extends Controller {
}
async getTagTypes(req: Request, res: Response): Promise<void> {
try {
const tagTypes = await this.tagTypeService.getAll();
res.json({ version, tagTypes });
} catch (e) {
handleErrors(res, this.logger, e);
}
const tagTypes = await this.tagTypeService.getAll();
res.json({ version, tagTypes });
}
async validate(req: Request, res: Response): Promise<void> {
try {
await this.tagTypeService.validate(req.body);
res.status(200).json({ valid: true, tagType: req.body });
} catch (error) {
handleErrors(res, this.logger, error);
}
await this.tagTypeService.validate(req.body);
res.status(200).json({ valid: true, tagType: req.body });
}
async createTagType(req: Request, res: Response): Promise<void> {
const userName = extractUsername(req);
try {
const tagType = await this.tagTypeService.createTagType(
req.body,
userName,
);
res.status(201).json(tagType);
} catch (error) {
handleErrors(res, this.logger, error);
}
const tagType = await this.tagTypeService.createTagType(
req.body,
userName,
);
res.status(201).json(tagType);
}
async updateTagType(req: Request, res: Response): Promise<void> {
const { description, icon } = req.body;
const { name } = req.params;
const userName = extractUsername(req);
try {
await this.tagTypeService.updateTagType(
{ name, description, icon },
userName,
);
res.status(200).end();
} catch (error) {
handleErrors(res, this.logger, error);
}
await this.tagTypeService.updateTagType(
{ name, description, icon },
userName,
);
res.status(200).end();
}
async getTagType(req: Request, res: Response): Promise<void> {
const { name } = req.params;
try {
const tagType = await this.tagTypeService.getTagType(name);
res.json({ version, tagType });
} catch (error) {
handleErrors(res, this.logger, error);
}
const tagType = await this.tagTypeService.getTagType(name);
res.json({ version, tagType });
}
async deleteTagType(req: Request, res: Response): Promise<void> {
const { name } = req.params;
const userName = extractUsername(req);
try {
await this.tagTypeService.deleteTagType(name, userName);
res.status(200).end();
} catch (error) {
handleErrors(res, this.logger, error);
}
await this.tagTypeService.deleteTagType(name, userName);
res.status(200).end();
}
}
export default TagTypeController;

View File

@ -4,7 +4,6 @@ import { ADMIN } from '../../types/permissions';
import UserService from '../../services/user-service';
import { AccessService } from '../../services/access-service';
import { Logger } from '../../logger';
import { handleErrors } from '../util';
import { IUnleashConfig } from '../../types/option';
import { EmailService } from '../../services/email-service';
import ResetTokenService from '../../services/reset-token-service';
@ -72,14 +71,10 @@ export default class UserAdminController extends Controller {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async resetPassword(req, res): Promise<void> {
const { user } = req;
try {
const receiver = req.body.id;
const resetPasswordUrl =
await this.userService.createResetPasswordEmail(receiver, user);
res.json({ resetPasswordUrl });
} catch (e) {
handleErrors(res, this.logger, e);
}
const receiver = req.body.id;
const resetPasswordUrl =
await this.userService.createResetPasswordEmail(receiver, user);
res.json({ resetPasswordUrl });
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@ -103,12 +98,8 @@ export default class UserAdminController extends Controller {
}
async getActiveSessions(req: Request, res: Response): Promise<void> {
try {
const sessions = await this.sessionService.getActiveSessions();
res.json(sessions);
} catch (error) {
handleErrors(res, this.logger, error);
}
const sessions = await this.sessionService.getActiveSessions();
res.json(sessions);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@ -210,35 +201,23 @@ export default class UserAdminController extends Controller {
const { user, params } = req;
const { id } = params;
try {
await this.userService.deleteUser(+id, user);
res.status(200).send();
} catch (error) {
handleErrors(res, this.logger, error);
}
await this.userService.deleteUser(+id, user);
res.status(200).send();
}
async validatePassword(req: IAuthRequest, res: Response): Promise<void> {
const { password } = req.body;
try {
this.userService.validatePassword(password);
res.status(200).send();
} catch (e) {
res.status(400).send([{ msg: e.message }]);
}
this.userService.validatePassword(password);
res.status(200).send();
}
async changePassword(req: IAuthRequest, res: Response): Promise<void> {
const { id } = req.params;
const { password } = req.body;
try {
await this.userService.changePassword(+id, password);
res.status(200).send();
} catch (e) {
res.status(400).send([{ msg: e.message }]);
}
await this.userService.changePassword(+id, password);
res.status(200).send();
}
}

View File

@ -22,7 +22,7 @@ function getSetup() {
return {
base,
featureToggleStore: stores.featureToggleStore,
featureStrategiesStore: stores.featureStrategiesStore,
featureToggleClientStore: stores.featureToggleClientStore,
request: supertest(app),
destroy: () => {
services.versionService.destroy();
@ -35,13 +35,13 @@ function getSetup() {
let base;
let request;
let destroy;
let featureStrategiesStore;
let featureToggleClientStore;
beforeEach(() => {
const setup = getSetup();
base = setup.base;
request = setup.request;
featureStrategiesStore = setup.featureStrategiesStore;
featureToggleClientStore = setup.featureToggleClientStore;
destroy = setup.destroy;
});
@ -114,7 +114,7 @@ test('if caching is not enabled all calls goes to service', async () => {
test('fetch single feature', async () => {
expect.assertions(1);
await featureStrategiesStore.createFeature({
await featureToggleClientStore.createFeature({
name: 'test_',
strategies: [{ name: 'default' }],
});
@ -130,10 +130,10 @@ test('fetch single feature', async () => {
test('support name prefix', async () => {
expect.assertions(2);
await featureStrategiesStore.createFeature({ name: 'a_test1' });
await featureStrategiesStore.createFeature({ name: 'a_test2' });
await featureStrategiesStore.createFeature({ name: 'b_test1' });
await featureStrategiesStore.createFeature({ name: 'b_test2' });
await featureToggleClientStore.createFeature({ name: 'a_test1' });
await featureToggleClientStore.createFeature({ name: 'a_test2' });
await featureToggleClientStore.createFeature({ name: 'b_test1' });
await featureToggleClientStore.createFeature({ name: 'b_test2' });
const namePrefix = 'b_';
@ -149,11 +149,11 @@ test('support name prefix', async () => {
test('support filtering on project', async () => {
expect.assertions(2);
await featureStrategiesStore.createFeature({
await featureToggleClientStore.createFeature({
name: 'a_test1',
project: 'projecta',
});
await featureStrategiesStore.createFeature({
await featureToggleClientStore.createFeature({
name: 'b_test2',
project: 'projectb',
});

View File

@ -1,6 +1,5 @@
import memoizee from 'memoizee';
import { Request, Response } from 'express';
import { handleErrors } from '../util';
import Controller from '../controller';
import { IUnleashServices } from '../../types/services';
import { IUnleashConfig } from '../../types/option';
@ -8,6 +7,7 @@ import FeatureToggleServiceV2 from '../../services/feature-toggle-service-v2';
import { Logger } from '../../logger';
import { querySchema } from '../../schema/feature-schema';
import { IFeatureToggleQuery } from '../../types/model';
import NotFoundError from '../../error/notfound-error';
const version = 2;
@ -51,28 +51,25 @@ export default class FeatureController extends Controller {
}
async getAll(req: Request, res: Response): Promise<void> {
try {
const query = await this.prepQuery(req.query);
let features;
if (this.cache) {
features = await this.cachedFeatures(query);
} else {
features = await this.featureToggleServiceV2.getClientFeatures(
query,
);
}
res.json({ version, features });
} catch (e) {
handleErrors(res, this.logger, e);
const query = await this.prepQuery(req.query);
let features;
if (this.cache) {
features = await this.cachedFeatures(query);
} else {
features = await this.featureToggleServiceV2.getClientFeatures(
query,
);
}
res.json({ version, features });
}
async prepQuery({
tag,
project,
namePrefix,
environment,
}: IFeatureToggleQuery): Promise<IFeatureToggleQuery> {
if (!tag && !project && !namePrefix) {
if (!tag && !project && !namePrefix && !environment) {
return null;
}
const tagQuery = this.paramToArray(tag);
@ -81,6 +78,7 @@ export default class FeatureController extends Controller {
tag: tagQuery,
project: projectQuery,
namePrefix,
environment,
});
if (query.tag) {
query.tag = query.tag.map((q) => q.split(':'));
@ -97,18 +95,25 @@ export default class FeatureController extends Controller {
}
async getFeatureToggle(
req: Request<{ featureName: string }, any, any, any>,
req: Request<
{ featureName: string },
any,
any,
{ environment?: string }
>,
res: Response,
): Promise<void> {
try {
const name = req.params.featureName;
const featureToggle = await this.featureToggleServiceV2.getFeature(
name,
);
res.json(featureToggle).end();
} catch (err) {
res.status(404).json({ error: 'Could not find feature' });
const name = req.params.featureName;
const { environment } = req.query;
const toggles = await this.featureToggleServiceV2.getClientFeatures({
namePrefix: name,
environment,
});
const toggle = toggles.find((t) => t.name === name);
if (!toggle) {
throw new NotFoundError(`Could not find feature toggle ${name}`);
}
res.json(toggle).end();
}
}

View File

@ -5,6 +5,7 @@ import getApp from '../../app';
import { createTestConfig } from '../../../test/config/test-config';
import { clientMetricsSchema } from '../../services/client-metrics/client-metrics-schema';
import { createServices } from '../../services';
import { IUnleashStores } from '../../types';
const eventBus = new EventEmitter();
@ -27,7 +28,7 @@ function getSetup() {
}
let request;
let stores;
let stores: IUnleashStores;
let destroy;
beforeEach(() => {
@ -166,7 +167,7 @@ test('schema allow yes=<string nbr>', () => {
test('should set lastSeen on toggle', async () => {
expect.assertions(1);
stores.featureToggleStore.createFeature('default', {
stores.featureToggleStore.create('default', {
name: 'toggleLastSeen',
});
await request

View File

@ -30,18 +30,8 @@ export default class ClientMetricsController extends Controller {
const data = req.body;
const clientIp = req.ip;
try {
await this.metrics.registerClientMetrics(data, clientIp);
return res.status(202).end();
} catch (e) {
this.logger.warn('Failed to store metrics', e);
switch (e.name) {
case 'ValidationError':
return res.status(400).end();
default:
return res.status(500).end();
}
}
await this.metrics.registerClientMetrics(data, clientIp);
return res.status(202).end();
}
}

View File

@ -24,18 +24,8 @@ export default class RegisterController extends Controller {
async handleRegister(req: Request, res: Response): Promise<void> {
const data = req.body;
try {
const clientIp = req.ip;
await this.metrics.registerClient(data, clientIp);
return res.status(202).end();
} catch (err) {
this.logger.warn('failed to register client', err);
switch (err.name) {
case 'ValidationError':
return res.status(400).end();
default:
return res.status(500).end();
}
}
const clientIp = req.ip;
await this.metrics.registerClient(data, clientIp);
return res.status(202).end();
}
}

View File

@ -98,6 +98,20 @@ export default class Controller {
);
}
patch(
path: string,
handler: IRequestHandler,
permission?: string,
...acceptedContentTypes: string[]
): void {
this.app.patch(
path,
checkPermission(permission),
requireContentType(...acceptedContentTypes),
this.wrap(handler.bind(this)),
);
}
delete(path: string, handler: IRequestHandler, permission?: string): void {
this.app.delete(
path,

View File

@ -35,6 +35,7 @@ export const handleErrors: (
case 'NotFoundError':
return res.status(404).json(error).end();
case 'InvalidOperationError':
return res.status(403).json(error).end();
case 'NameExistsError':
return res.status(409).json(error).end();
case 'ValidationError':

View File

@ -0,0 +1,6 @@
import joi from 'joi';
export const addEnvironment = joi
.object()
.keys({ environment: joi.string().required() })
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false });

View File

@ -231,7 +231,7 @@ export class AccessService {
}
async createDefaultProjectRoles(
owner: User,
owner: IUser,
projectId: string,
): Promise<void> {
if (!projectId) {

View File

@ -140,7 +140,7 @@ export default class ClientMetricsService {
): Promise<void> {
const value = await clientMetricsSchema.validateAsync(data);
const toggleNames = Object.keys(value.bucket.toggles);
await this.featureToggleStore.updateLastSeenForToggles(toggleNames);
await this.featureToggleStore.setLastSeen(toggleNames);
await this.clientMetricsStore.insert(value);
await this.clientInstanceStore.insert({
appName: value.appName,
@ -262,7 +262,7 @@ export default class ClientMetricsService {
this.clientApplicationsStore.get(appName),
this.clientInstanceStore.getByAppName(appName),
this.strategyStore.getAll(),
this.featureToggleStore.getFeatures(false),
this.featureToggleStore.getAll(),
]);
return {

View File

@ -66,13 +66,19 @@ export default class EnvironmentService {
throw new NotFoundError(`Could not find environment ${name}`);
}
async connectProjectToEnvironment(
async addEnvironmentToProject(
environment: string,
projectId: string,
): Promise<void> {
try {
await this.environmentStore.connectProject(environment, projectId);
await this.environmentStore.connectFeatures(environment, projectId);
await this.featureEnvironmentStore.connectProject(
environment,
projectId,
);
await this.featureEnvironmentStore.connectFeatures(
environment,
projectId,
);
} catch (e) {
if (e.code === UNIQUE_CONSTRAINT_VIOLATION) {
throw new NameExistsError(
@ -87,11 +93,11 @@ export default class EnvironmentService {
environment: string,
projectId: string,
): Promise<void> {
await this.featureEnvironmentStore.disconnectEnvironmentFromProject(
await this.featureEnvironmentStore.disconnectFeatures(
environment,
projectId,
);
await this.environmentStore.disconnectProjectFromEnv(
await this.featureEnvironmentStore.disconnectProject(
environment,
projectId,
);

View File

@ -1,10 +1,10 @@
/* eslint-disable prettier/prettier */
import { IUnleashConfig } from '../types/option';
import { IUnleashStores } from '../types/stores';
import { Logger } from '../logger';
import BadDataError from '../error/bad-data-error';
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
import NameExistsError from '../error/name-exists-error';
import InvalidOperationError from '../error/invalid-operation-error';
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
import { featureMetadataSchema, nameSchema } from '../schema/feature-schema';
import {
FEATURE_ARCHIVED,
@ -22,9 +22,7 @@ import {
FeatureConfigurationClient,
IFeatureStrategiesStore,
} from '../types/stores/feature-strategies-store';
import { IFeatureTypeStore } from '../types/stores/feature-type-store';
import { IEventStore } from '../types/stores/event-store';
import { IEnvironmentStore } from '../types/stores/environment-store';
import { IProjectStore } from '../types/stores/project-store';
import { IFeatureTagStore } from '../types/stores/feature-tag-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
@ -34,11 +32,13 @@ import {
FeatureToggleWithEnvironment,
FeatureToggleWithEnvironmentLegacy,
IFeatureEnvironmentInfo,
IFeatureOverview,
IFeatureStrategy,
IFeatureToggleQuery,
IStrategyConfig,
} from '../types/model';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store';
class FeatureToggleServiceV2 {
private logger: Logger;
@ -47,37 +47,33 @@ class FeatureToggleServiceV2 {
private featureToggleStore: IFeatureToggleStore;
private featureToggleClientStore: IFeatureToggleClientStore;
private featureTagStore: IFeatureTagStore;
private featureEnvironmentStore: IFeatureEnvironmentStore;
private projectStore: IProjectStore;
private environmentStore: IEnvironmentStore;
private eventStore: IEventStore;
private featureTypeStore: IFeatureTypeStore;
constructor(
{
featureStrategiesStore,
featureToggleStore,
featureToggleClientStore,
projectStore,
eventStore,
featureTagStore,
environmentStore,
featureTypeStore,
featureEnvironmentStore,
}: Pick<
IUnleashStores,
| 'featureStrategiesStore'
| 'featureToggleStore'
| 'featureToggleClientStore'
| 'projectStore'
| 'eventStore'
| 'featureTagStore'
| 'environmentStore'
| 'featureTypeStore'
| 'featureEnvironmentStore'
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
@ -85,11 +81,10 @@ class FeatureToggleServiceV2 {
this.logger = getLogger('services/feature-toggle-service-v2.ts');
this.featureStrategiesStore = featureStrategiesStore;
this.featureToggleStore = featureToggleStore;
this.featureToggleClientStore = featureToggleClientStore;
this.featureTagStore = featureTagStore;
this.projectStore = projectStore;
this.eventStore = eventStore;
this.environmentStore = environmentStore;
this.featureTypeStore = featureTypeStore;
this.featureEnvironmentStore = featureEnvironmentStore;
}
@ -101,17 +96,17 @@ class FeatureToggleServiceV2 {
*/
async createStrategy(
strategyConfig: Omit<IStrategyConfig, 'id'>,
projectName: string,
projectId: string,
featureName: string,
environment: string = GLOBAL_ENV,
): Promise<IStrategyConfig> {
try {
const newFeatureStrategy =
await this.featureStrategiesStore.createStrategyConfig({
await this.featureStrategiesStore.createStrategyFeatureEnv({
strategyName: strategyConfig.name,
constraints: strategyConfig.constraints,
parameters: strategyConfig.parameters,
projectName,
projectId,
featureName,
environment,
});
@ -132,26 +127,72 @@ class FeatureToggleServiceV2 {
}
/**
* PUT /api/admin/projects/:projectName/features/:featureName/strategies/:strategyId ?
* PUT /api/admin/projects/:projectId/features/:featureName/strategies/:strategyId ?
* {
*
* }
* @param id
* @param updates
*/
// TODO: verify projectId is not changed from URL!
async updateStrategy(
id: string,
updates: Partial<IFeatureStrategy>,
): Promise<IFeatureStrategy> {
const exists = await this.featureStrategiesStore.exists(id);
if (exists) {
return this.featureStrategiesStore.updateStrategy(id, updates);
): Promise<IStrategyConfig> {
const existingStrategy = await this.featureStrategiesStore.get(id);
if (existingStrategy.id === id) {
const strategy = await this.featureStrategiesStore.updateStrategy(
id,
updates,
);
return {
id: strategy.id,
name: strategy.strategyName,
constraints: strategy.constraints || [],
parameters: strategy.parameters,
};
}
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
// TODO: verify projectId is not changed from URL!
async updateStrategyParameter(
id: string,
name: string,
value: string | number,
): Promise<IStrategyConfig> {
const existingStrategy = await this.featureStrategiesStore.get(id);
if (existingStrategy.id === id) {
existingStrategy.parameters[name] = value;
const strategy = await this.featureStrategiesStore.updateStrategy(
id,
existingStrategy,
);
return {
id: strategy.id,
name: strategy.strategyName,
constraints: strategy.constraints || [],
parameters: strategy.parameters,
};
}
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
/**
* DELETE /api/admin/projects/:projectId/features/:featureName/strategies/:strategyId ?
* {
*
* }
* @param id
* @param updates
*/
async deleteStrategy(id: string): Promise<void> {
return this.featureStrategiesStore.delete(id);
}
async getStrategiesForEnvironment(
projectName: string,
project: string,
featureName: string,
environment: string = GLOBAL_ENV,
): Promise<IStrategyConfig[]> {
@ -161,8 +202,8 @@ class FeatureToggleServiceV2 {
);
if (hasEnv) {
const featureStrategies =
await this.featureStrategiesStore.getStrategiesForFeature(
projectName,
await this.featureStrategiesStore.getStrategiesForFeatureEnv(
project,
featureName,
environment,
);
@ -179,7 +220,7 @@ class FeatureToggleServiceV2 {
}
/**
* GET /api/admin/projects/:projectName/features/:featureName
* GET /api/admin/projects/:project/features/:featureName
* @param featureName
* @param archived - return archived or non archived toggles
*/
@ -187,20 +228,27 @@ class FeatureToggleServiceV2 {
featureName: string,
archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> {
return this.featureStrategiesStore.getFeatureToggleAdmin(
return this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
archived,
);
}
async getFeatureMetadata(featureName: string): Promise<FeatureToggle> {
return this.featureToggleStore.get(featureName);
}
async getClientFeatures(
query?: IFeatureToggleQuery,
archived: boolean = false,
): Promise<FeatureConfigurationClient[]> {
return this.featureStrategiesStore.getFeatures(query, archived, false);
return this.featureToggleClientStore.getClient(query);
}
/**
*
* Warn: Legacy!
*
*
* Used to retrieve metadata of all feature toggles defined in Unleash.
* @param query - Allow you to limit search based on criteria such as project, tags, namePrefix. See @IFeatureToggleQuery
* @param archived - Return archived or active toggles
@ -211,13 +259,23 @@ class FeatureToggleServiceV2 {
query?: IFeatureToggleQuery,
archived: boolean = false,
): Promise<FeatureToggle[]> {
return this.featureStrategiesStore.getFeatures(query, archived, true);
return this.featureToggleClientStore.getAdmin(query, archived);
}
async getFeatureOverview(
projectId: string,
archived: boolean = false,
): Promise<IFeatureOverview[]> {
return this.featureStrategiesStore.getFeatureOverview(
projectId,
archived,
);
}
async getFeatureToggle(
featureName: string,
): Promise<FeatureToggleWithEnvironment> {
return this.featureStrategiesStore.getFeatureToggleAdmin(
return this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
false,
);
@ -235,11 +293,11 @@ class FeatureToggleServiceV2 {
const featureData = await featureMetadataSchema.validateAsync(
value,
);
const createdToggle = await this.featureToggleStore.createFeature(
const createdToggle = await this.featureToggleStore.create(
projectId,
featureData,
);
await this.environmentStore.connectFeatureToEnvironmentsForProject(
await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(
featureData.name,
projectId,
);
@ -263,15 +321,19 @@ class FeatureToggleServiceV2 {
userName: string,
): Promise<FeatureToggle> {
const featureName = updatedFeature.name;
this.logger.info(
`${userName} updates feature toggle ${featureName}`,
);
this.logger.info(`${userName} updates feature toggle ${featureName}`);
const featureToggle = await this.featureToggleStore.updateFeature(
projectId,
const featureData = await featureMetadataSchema.validateAsync(
updatedFeature,
);
const tags = await this.featureTagStore.getAllTagsForFeature(featureName);
const featureToggle = await this.featureToggleStore.update(
projectId,
featureData,
);
const tags = await this.featureTagStore.getAllTagsForFeature(
featureName,
);
await this.eventStore.store({
type: FEATURE_METADATA_UPDATED,
@ -293,7 +355,7 @@ class FeatureToggleServiceV2 {
toggleName: string,
environment: string = GLOBAL_ENV,
): Promise<void> {
await this.featureStrategiesStore.removeAllStrategiesForEnv(
await this.featureStrategiesStore.removeAllStrategiesForFeatureEnv(
toggleName,
environment,
);
@ -322,7 +384,7 @@ class FeatureToggleServiceV2 {
featureName,
);
const strategies =
await this.featureStrategiesStore.getStrategiesForFeature(
await this.featureStrategiesStore.getStrategiesForFeatureEnv(
project,
featureName,
environment,
@ -359,7 +421,7 @@ class FeatureToggleServiceV2 {
async validateUniqueFeatureName(name: string): Promise<void> {
let msg;
try {
const feature = await this.featureToggleStore.hasFeature(name);
const feature = await this.featureToggleStore.get(name);
msg = feature.archived
? 'An archived toggle with that name already exists'
: 'A toggle with that name already exists';
@ -378,12 +440,12 @@ class FeatureToggleServiceV2 {
isStale: boolean,
userName: string,
): Promise<any> {
const feature = await this.featureToggleStore.getFeatureMetadata(
const feature = await this.featureToggleStore.get(featureName);
feature.stale = isStale;
await this.featureToggleStore.update(feature.project, feature);
const tags = await this.featureTagStore.getAllTagsForFeature(
featureName,
);
feature.stale = isStale;
await this.featureToggleStore.updateFeature(feature.project, feature);
const tags = await this.featureTagStore.getAllTagsForFeature(featureName);
const data = await this.getFeatureToggleLegacy(featureName);
await this.eventStore.store({
@ -396,8 +458,8 @@ class FeatureToggleServiceV2 {
}
async archiveToggle(name: string, userName: string): Promise<void> {
await this.featureToggleStore.hasFeature(name);
await this.featureToggleStore.archiveFeature(name);
await this.featureToggleStore.get(name);
await this.featureToggleStore.archive(name);
const tags =
(await this.featureTagStore.getAllTagsForFeature(name)) || [];
await this.eventStore.store({
@ -409,6 +471,7 @@ class FeatureToggleServiceV2 {
}
async updateEnabled(
projectId: string,
featureName: string,
environment: string,
enabled: boolean,
@ -419,16 +482,29 @@ class FeatureToggleServiceV2 {
environment,
featureName,
);
if (hasEnvironment) {
await this.featureEnvironmentStore.toggleEnvironmentEnabledStatus(
environment,
if (enabled) {
const strategies = await this.getStrategiesForEnvironment(
projectId,
featureName,
enabled,
environment,
);
const feature = await this.featureToggleStore.getFeatureMetadata(
if (strategies.length === 0) {
throw new InvalidOperationError(
'You can not enable the environment before it has strategies',
);
}
}
await this.featureEnvironmentStore.setEnvironmentEnabledStatus(
environment,
featureName,
enabled,
);
const feature = await this.featureToggleStore.get(featureName);
const tags = await this.featureTagStore.getAllTagsForFeature(
featureName,
);
const tags = await this.featureTagStore.getAllTagsForFeature(featureName);
const data = await this.getFeatureToggleLegacy(featureName);
await this.eventStore.store({
@ -446,17 +522,19 @@ class FeatureToggleServiceV2 {
// @deprecated
async toggle(
projectId: string,
featureName: string,
environment: string,
userName: string,
): Promise<FeatureToggle> {
await this.featureToggleStore.hasFeature(featureName);
await this.featureToggleStore.get(featureName);
const isEnabled =
await this.featureEnvironmentStore.isEnvironmentEnabled(
featureName,
environment,
);
return this.updateEnabled(
projectId,
featureName,
environment,
!isEnabled,
@ -464,13 +542,20 @@ class FeatureToggleServiceV2 {
);
}
async getFeatureToggleLegacy(featureName: string): Promise<FeatureToggleWithEnvironmentLegacy> {
const feature = await this.featureStrategiesStore.getFeatureToggleAdmin(featureName);
const globalEnv = feature.environments.find(e => e.name === GLOBAL_ENV);
async getFeatureToggleLegacy(
featureName: string,
): Promise<FeatureToggleWithEnvironmentLegacy> {
const feature =
await this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
);
const globalEnv = feature.environments.find(
(e) => e.name === GLOBAL_ENV,
);
const strategies = globalEnv?.strategies || [];
const enabled = globalEnv?.enabled || false;
return {...feature, enabled, strategies };
return { ...feature, enabled, strategies };
}
// @deprecated
@ -482,14 +567,13 @@ class FeatureToggleServiceV2 {
userName: string,
event?: string,
): Promise<any> {
const feature = await this.featureToggleStore.getFeatureMetadata(
const feature = await this.featureToggleStore.get(featureName);
feature[field] = value;
await this.featureToggleStore.update(feature.project, feature);
const tags = await this.featureTagStore.getAllTagsForFeature(
featureName,
);
feature[field] = value;
await this.featureToggleStore.updateFeature(feature.project, feature);
const tags = await this.featureTagStore.getAllTagsForFeature(featureName);
// Workaround to support pre 4.1 format
const data = await this.getFeatureToggleLegacy(featureName);
@ -518,7 +602,7 @@ class FeatureToggleServiceV2 {
}
async reviveToggle(featureName: string, userName: string): Promise<void> {
const data = await this.featureToggleStore.reviveFeature(featureName);
const data = await this.featureToggleStore.revive(featureName);
const tags = await this.featureTagStore.getAllTagsForFeature(
featureName,
);
@ -533,14 +617,11 @@ class FeatureToggleServiceV2 {
async getMetadataForAllFeatures(
archived: boolean,
): Promise<FeatureToggle[]> {
return this.featureToggleStore.getFeatures(archived);
return this.featureToggleStore.getAll({ archived });
}
async getProjectId(name: string): Promise<string> {
const { project } = await this.featureToggleStore.getFeatureMetadata(
name,
);
return project;
return this.featureToggleStore.getProjectId(name);
}
}

View File

@ -58,7 +58,11 @@ export const createServices = (
const featureToggleServiceV2 = new FeatureToggleServiceV2(stores, config);
const environmentService = new EnvironmentService(stores, config);
const featureTagService = new FeatureTagService(stores, config);
const projectHealthService = new ProjectHealthService(stores, config);
const projectHealthService = new ProjectHealthService(
stores,
config,
featureToggleServiceV2,
);
const projectService = new ProjectService(
stores,
config,

View File

@ -16,6 +16,7 @@ import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IFeatureTypeStore } from '../types/stores/feature-type-store';
import { IProjectStore } from '../types/stores/project-store';
import Timer = NodeJS.Timer;
import FeatureToggleServiceV2 from './feature-toggle-service-v2';
export default class ProjectHealthService {
private logger: Logger;
@ -30,6 +31,8 @@ export default class ProjectHealthService {
private healthRatingTimer: Timer;
private featureToggleService: FeatureToggleServiceV2;
constructor(
{
projectStore,
@ -40,6 +43,7 @@ export default class ProjectHealthService {
'projectStore' | 'featureTypeStore' | 'featureToggleStore'
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
featureToggleService: FeatureToggleServiceV2,
) {
this.logger = getLogger('services/project-health-service.ts');
this.projectStore = projectStore;
@ -50,14 +54,16 @@ export default class ProjectHealthService {
() => this.setHealthRating(),
MILLISECONDS_IN_ONE_HOUR,
).unref();
this.featureToggleService = featureToggleService;
}
// TODO: duplicate from project-service.
async getProjectOverview(
projectId: string,
archived: boolean = false,
): Promise<IProjectOverview> {
const project = await this.projectStore.get(projectId);
const features = await this.projectStore.getProjectOverview(
const features = await this.featureToggleService.getFeatureOverview(
projectId,
archived,
);
@ -120,7 +126,7 @@ export default class ProjectHealthService {
}
async calculateHealthRating(project: IProject): Promise<number> {
const toggles = await this.featureToggleStore.getFeaturesBy({
const toggles = await this.featureToggleStore.getAll({
project: project.id,
});

View File

@ -24,6 +24,7 @@ import { GLOBAL_ENV } from '../types/environment';
import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureTypeStore } from '../types/stores/feature-type-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IProjectStore } from '../types/stores/project-store';
import { IRole } from '../types/stores/access-store';
import { IEventStore } from '../types/stores/event-store';
@ -51,6 +52,8 @@ export default class ProjectService {
private featureTypeStore: IFeatureTypeStore;
private featureEnvironmentStore: IFeatureEnvironmentStore;
private environmentStore: IEnvironmentStore;
private logger: any;
@ -64,6 +67,7 @@ export default class ProjectService {
featureToggleStore,
featureTypeStore,
environmentStore,
featureEnvironmentStore,
}: Pick<
IUnleashStores,
| 'projectStore'
@ -71,6 +75,7 @@ export default class ProjectService {
| 'featureToggleStore'
| 'featureTypeStore'
| 'environmentStore'
| 'featureEnvironmentStore'
>,
config: IUnleashConfig,
accessService: AccessService,
@ -78,6 +83,7 @@ export default class ProjectService {
) {
this.store = projectStore;
this.environmentStore = environmentStore;
this.featureEnvironmentStore = featureEnvironmentStore;
this.accessService = accessService;
this.eventStore = eventStore;
this.featureToggleStore = featureToggleStore;
@ -117,7 +123,7 @@ export default class ProjectService {
await this.store.create(data);
await this.environmentStore.connectProject(GLOBAL_ENV, data.id);
await this.featureEnvironmentStore.connectProject(GLOBAL_ENV, data.id);
await this.accessService.createDefaultProjectRoles(user, data.id);
@ -189,7 +195,7 @@ export default class ProjectService {
);
}
const toggles = await this.featureToggleStore.getFeaturesBy({
const toggles = await this.featureToggleStore.getAll({
project: id,
archived: false,
});
@ -292,7 +298,7 @@ export default class ProjectService {
archived: boolean = false,
): Promise<IProjectOverview> {
const project = await this.store.get(projectId);
const features = await this.store.getProjectOverview(
const features = await this.featureToggleService.getFeatureOverview(
projectId,
archived,
);

View File

@ -56,7 +56,7 @@ test('should not import an existing feature', async () => {
],
};
await stores.featureToggleStore.createFeature('default', data.features[0]);
await stores.featureToggleStore.create('default', data.features[0]);
await stateService.import({ data, keepExisting: true });
@ -77,7 +77,7 @@ test('should not keep existing feature if drop-before-import', async () => {
],
};
await stores.featureToggleStore.createFeature('default', data.features[0]);
await stores.featureToggleStore.create('default', data.features[0]);
await stateService.import({
data,
@ -206,7 +206,7 @@ test('should not accept gibberish', async () => {
test('should export featureToggles', async () => {
const { stateService, stores } = getSetup();
await stores.featureToggleStore.createFeature('default', {
await stores.featureToggleStore.create('default', {
name: 'a-feature',
});
@ -487,18 +487,18 @@ test('exporting to new format works', async () => {
name: 'prod',
displayName: 'Production',
});
await stores.featureToggleStore.createFeature('fancy', {
await stores.featureToggleStore.create('fancy', {
name: 'Some-feature',
});
await stores.strategyStore.createStrategy({ name: 'format' });
await stores.featureEnvironmentStore.connectEnvironmentAndFeature(
await stores.featureEnvironmentStore.addEnvironmentToFeature(
'Some-feature',
'dev',
true,
);
await stores.featureStrategiesStore.createStrategyConfig({
await stores.featureStrategiesStore.createStrategyFeatureEnv({
featureName: 'Some-feature',
projectName: 'fancy',
projectId: 'fancy',
strategyName: 'format',
environment: 'dev',
parameters: {},
@ -527,18 +527,18 @@ test('featureStrategies can keep existing', async () => {
name: 'prod',
displayName: 'Production',
});
await stores.featureToggleStore.createFeature('fancy', {
await stores.featureToggleStore.create('fancy', {
name: 'Some-feature',
});
await stores.strategyStore.createStrategy({ name: 'format' });
await stores.featureEnvironmentStore.connectEnvironmentAndFeature(
await stores.featureEnvironmentStore.addEnvironmentToFeature(
'Some-feature',
'dev',
true,
);
await stores.featureStrategiesStore.createStrategyConfig({
await stores.featureStrategiesStore.createStrategyFeatureEnv({
featureName: 'Some-feature',
projectName: 'fancy',
projectId: 'fancy',
strategyName: 'format',
environment: 'dev',
parameters: {},
@ -573,18 +573,18 @@ test('featureStrategies should not keep existing if dropBeforeImport', async ()
name: 'prod',
displayName: 'Production',
});
await stores.featureToggleStore.createFeature('fancy', {
await stores.featureToggleStore.create('fancy', {
name: 'Some-feature',
});
await stores.strategyStore.createStrategy({ name: 'format' });
await stores.featureEnvironmentStore.connectEnvironmentAndFeature(
await stores.featureEnvironmentStore.addEnvironmentToFeature(
'Some-feature',
'dev',
true,
);
await stores.featureStrategiesStore.createStrategyConfig({
await stores.featureStrategiesStore.createStrategyFeatureEnv({
featureName: 'Some-feature',
projectName: 'fancy',
projectId: 'fancy',
strategyName: 'format',
environment: 'dev',
parameters: {},

View File

@ -26,6 +26,7 @@ import {
ITag,
IImportData,
IProject,
IStrategyConfig,
} from '../types/model';
import { GLOBAL_ENV } from '../types/environment';
import { Logger } from '../logger';
@ -196,7 +197,7 @@ export default class StateService {
async importFeatureEnvironments({ featureEnvironments }): Promise<void> {
await Promise.all(
featureEnvironments.map((env) =>
this.featureEnvironmentStore.connectEnvironmentAndFeature(
this.featureEnvironmentStore.addEnvironmentToFeature(
env.featureName,
env.environment,
env.enabled,
@ -213,7 +214,7 @@ export default class StateService {
}): Promise<void> {
const oldFeatureStrategies = dropBeforeImport
? []
: await this.featureStrategiesStore.getAllFeatureStrategies();
: await this.featureStrategiesStore.getAll();
if (dropBeforeImport) {
this.logger.info(
'Dropping existing strategies for feature toggles',
@ -227,7 +228,7 @@ export default class StateService {
: featureStrategies;
await Promise.all(
strategiesToImport.map((featureStrategy) =>
this.featureStrategiesStore.createStrategyConfig(
this.featureStrategiesStore.createStrategyFeatureEnv(
featureStrategy,
),
),
@ -239,9 +240,9 @@ export default class StateService {
features,
}): Promise<{ features; featureStrategies; featureEnvironments }> {
const strategies = features.flatMap((f) =>
f.strategies.map((strategy) => ({
f.strategies.map((strategy: IStrategyConfig) => ({
featureName: f.name,
projectName: f.project,
projectId: f.project,
constraints: strategy.constraints || [],
parameters: strategy.parameters || {},
environment: GLOBAL_ENV,
@ -289,7 +290,7 @@ export default class StateService {
.filter(filterEqual(oldToggles))
.map((feature) =>
this.toggleStore
.createFeature(feature.project, feature)
.create(feature.project, feature)
.then(() => {
this.eventStore.store({
type: FEATURE_IMPORT,

View File

@ -18,7 +18,7 @@ export interface IStrategyConfig {
export interface IFeatureStrategy {
id: string;
featureName: string;
projectName: string;
projectId: string;
environment: string;
strategyName: string;
parameters: object;
@ -84,12 +84,12 @@ export interface IVariant {
name: string;
weight: number;
weightType: string;
payload: {
payload?: {
type: string;
value: string;
};
stickiness: string;
overrides: {
overrides?: {
contextName: string;
values: string[];
}[];
@ -297,8 +297,8 @@ export interface IProject {
id: string;
name: string;
description: string;
health: number;
createdAt: Date;
health?: number;
createdAt?: Date;
}
export interface IProjectWithCount extends IProject {

View File

@ -21,6 +21,7 @@ import { IUserFeedbackStore } from './stores/user-feedback-store';
import { IFeatureEnvironmentStore } from './stores/feature-environment-store';
import { IFeatureStrategiesStore } from './stores/feature-strategies-store';
import { IEnvironmentStore } from './stores/environment-store';
import { IFeatureToggleClientStore } from './stores/feature-toggle-client-store';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -36,6 +37,7 @@ export interface IUnleashStores {
featureStrategiesStore: IFeatureStrategiesStore;
featureTagStore: IFeatureTagStore;
featureToggleStore: IFeatureToggleStore;
featureToggleClientStore: IFeatureToggleClientStore;
featureTypeStore: IFeatureTypeStore;
projectStore: IProjectStore;
resetTokenStore: IResetTokenStore;

View File

@ -2,16 +2,5 @@ import { IEnvironment } from '../model';
import { Store } from './store';
export interface IEnvironmentStore extends Store<IEnvironment, string> {
exists(name: string): Promise<boolean>;
upsert(env: IEnvironment): Promise<IEnvironment>;
connectProject(environment: string, projectId: string): Promise<void>;
connectFeatures(environment: string, projectId: string): Promise<void>;
disconnectProjectFromEnv(
environment: string,
projectId: string,
): Promise<void>;
connectFeatureToEnvironmentsForProject(
featureName: string,
project_id: string,
): Promise<void>;
}

View File

@ -8,7 +8,6 @@ export interface FeatureEnvironmentKey {
export interface IFeatureEnvironmentStore
extends Store<IFeatureEnvironment, FeatureEnvironmentKey> {
getAllFeatureEnvironments(): Promise<IFeatureEnvironment[]>;
featureHasEnvironment(
environment: string,
featureName: string,
@ -17,7 +16,7 @@ export interface IFeatureEnvironmentStore
featureName: string,
environment: string,
): Promise<boolean>;
toggleEnvironmentEnabledStatus(
setEnvironmentEnabledStatus(
environment: string,
featureName: string,
enabled: boolean,
@ -26,21 +25,24 @@ export interface IFeatureEnvironmentStore
environment: string,
featureName: string,
): Promise<IFeatureEnvironment>;
disconnectEnvironmentFromProject(
environment: string,
project: string,
): Promise<void>;
removeEnvironmentForFeature(
feature_name: string,
featureName: string,
environment: string,
): Promise<void>;
connectEnvironmentAndFeature(
feature_name: string,
addEnvironmentToFeature(
featureName: string,
environment: string,
enabled: boolean,
): Promise<void>;
enableEnvironmentForFeature(
feature_name: string,
environment: string,
disconnectFeatures(environment: string, project: string): Promise<void>;
connectFeatures(environment: string, projectId: string): Promise<void>;
connectFeatureToEnvironmentsForProject(
featureName: string,
projectId: string,
): Promise<void>;
connectProject(environment: string, projectId: string): Promise<void>;
disconnectProject(environment: string, projectId: string): Promise<void>;
}

View File

@ -1,8 +1,7 @@
import {
FeatureToggleWithEnvironment,
IFeatureOverview,
IFeatureStrategy,
IFeatureToggleClient,
IFeatureToggleQuery,
IStrategyConfig,
IVariant,
} from '../model';
@ -18,43 +17,31 @@ export interface FeatureConfigurationClient {
}
export interface IFeatureStrategiesStore
extends Store<IFeatureStrategy, string> {
createStrategyConfig(
createStrategyFeatureEnv(
strategyConfig: Omit<IFeatureStrategy, 'id' | 'createdAt'>,
): Promise<IFeatureStrategy>;
getStrategiesForToggle(featureName: string): Promise<IFeatureStrategy[]>;
getAllFeatureStrategies(): Promise<IFeatureStrategy[]>;
getStrategiesForEnvironment(
environment: string,
): Promise<IFeatureStrategy[]>;
removeAllStrategiesForEnv(
feature_name: string,
removeAllStrategiesForFeatureEnv(
featureName: string,
environment: string,
): Promise<void>;
getAll(): Promise<IFeatureStrategy[]>;
getStrategiesForFeature(
project_name: string,
feature_name: string,
getStrategiesForFeatureEnv(
projectId: string,
featureName: string,
environment: string,
): Promise<IFeatureStrategy[]>;
getStrategiesForEnv(environment: string): Promise<IFeatureStrategy[]>;
getFeatureToggleAdmin(
getFeatureToggleWithEnvs(
featureName: string,
archived?: boolean,
): Promise<FeatureToggleWithEnvironment>;
getFeatures(
featureQuery: Partial<IFeatureToggleQuery>,
getFeatureOverview(
projectId: string,
archived: boolean,
isAdmin: boolean,
): Promise<IFeatureToggleClient[]>;
): Promise<IFeatureOverview[]>;
getStrategyById(id: string): Promise<IFeatureStrategy>;
updateStrategy(
id: string,
updates: Partial<IFeatureStrategy>,
): Promise<IFeatureStrategy>;
getStrategiesAndMetadataForEnvironment(
environment: string,
featureName: string,
): Promise<void>;
deleteConfigurationsForProjectAndEnvironment(
projectId: String,
environment: String,

View File

@ -0,0 +1,13 @@
import { IFeatureToggleClient, IFeatureToggleQuery } from '../model';
export interface IFeatureToggleClientStore {
getClient(
featureQuery: Partial<IFeatureToggleQuery>,
): Promise<IFeatureToggleClient[]>;
// @Deprecated
getAdmin(
featureQuery: Partial<IFeatureToggleQuery>,
archived: boolean,
): Promise<IFeatureToggleClient[]>;
}

View File

@ -7,29 +7,13 @@ export interface IFeatureToggleQuery {
stale: boolean;
}
export interface IHasFeature {
name: string;
archived: boolean;
}
export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
count(query: Partial<IFeatureToggleQuery>): Promise<number>;
getFeatureMetadata(name: string): Promise<FeatureToggle>;
getFeatures(archived: boolean): Promise<FeatureToggle[]>;
hasFeature(name: string): Promise<IHasFeature>;
updateLastSeenForToggles(toggleNames: string[]): Promise<void>;
count(query?: Partial<IFeatureToggleQuery>): Promise<number>;
setLastSeen(toggleNames: string[]): Promise<void>;
getProjectId(name: string): Promise<string>;
createFeature(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle>;
updateFeature(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle>;
archiveFeature(featureName: string): Promise<FeatureToggle>;
reviveFeature(featureName: string): Promise<FeatureToggle>;
getFeaturesBy(
query: Partial<IFeatureToggleQuery>,
): Promise<FeatureToggle[]>;
create(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
archive(featureName: string): Promise<FeatureToggle>;
revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
}

View File

@ -1,4 +1,4 @@
import { IFeatureOverview, IProject } from '../model';
import { IProject } from '../model';
import { Store } from './store';
export interface IProjectInsert {
@ -27,9 +27,5 @@ export interface IProjectStore extends Store<IProject, string> {
deleteEnvironmentForProject(id: string, environment: string): Promise<void>;
getEnvironmentsForProject(id: string): Promise<string[]>;
getMembers(projectId: string): Promise<number>;
getProjectOverview(
projectId: string,
archived: boolean,
): Promise<IFeatureOverview[]>;
count(): Promise<number>;
}

View File

@ -1,9 +1,9 @@
import dbInit from '../../helpers/database-init';
import { setupApp } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
let app;
let db;
let app: IUnleashTest;
let db: ITestDb;
beforeAll(async () => {
db = await dbInit('archive_test_serial', getLogger);

View File

@ -8,7 +8,7 @@ let db;
const email = 'user@getunleash.io';
beforeAll(async () => {
db = await dbInit('user_api_serial', getLogger);
db = await dbInit('ui_bootstrap_serial', getLogger);
app = await setupAppWithAuth(db.stores);
});

View File

@ -1,132 +1,148 @@
import faker from 'faker';
import dbInit from '../../helpers/database-init';
import { setupApp } from '../../helpers/test-helper';
import { FeatureToggleDTO, IStrategyConfig } from 'lib/types/model';
import dbInit, { ITestDb } from '../../helpers/database-init';
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
let app;
let db;
let app: IUnleashTest;
let db: ITestDb;
const defaultStrategy = {
name: 'default',
parameters: {},
constraints: [],
};
beforeAll(async () => {
db = await dbInit('feature_api_serial', getLogger);
app = await setupApp(db.stores);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
name: 'featureX',
description: 'the #1 feature',
strategies: [
{
name: 'default',
parameters: {},
},
],
},
'test',
);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
const createToggle = async (
toggle: FeatureToggleDTO,
strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
projectId: string = 'default',
username: string = 'test',
) => {
await app.services.featureToggleServiceV2.createFeatureToggle(
projectId,
toggle,
username,
);
await app.services.featureToggleServiceV2.createStrategy(
strategy,
projectId,
toggle.name,
);
};
await createToggle({
name: 'featureX',
description: 'the #1 feature',
});
await createToggle(
{
name: 'featureY',
description: 'soon to be the #1 feature',
strategies: [
{
name: 'baz',
parameters: {
foo: 'bar',
},
},
],
},
'userName',
{
name: 'baz',
constraints: [],
parameters: {
foo: 'bar',
},
},
);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
await createToggle(
{
name: 'featureZ',
description: 'terrible feature',
strategies: [
{
name: 'baz',
parameters: {
foo: 'rab',
},
},
],
},
'test',
{
name: 'baz',
constraints: [],
parameters: {
foo: 'rab',
},
},
);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
await createToggle(
{
name: 'featureArchivedX',
description: 'the #1 feature',
strategies: [
{
name: 'default',
parameters: {},
},
],
},
'test',
{
name: 'default',
constraints: [],
parameters: {},
},
);
await app.services.featureToggleServiceV2.archiveToggle(
'featureArchivedX',
'test',
);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
await createToggle(
{
name: 'featureArchivedY',
description: 'soon to be the #1 feature',
strategies: [
{
name: 'baz',
parameters: {
foo: 'bar',
},
},
],
},
'test',
{
name: 'baz',
constraints: [],
parameters: {
foo: 'bar',
},
},
);
await app.services.featureToggleServiceV2.archiveToggle(
'featureArchivedY',
'test',
);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
await createToggle(
{
name: 'featureArchivedZ',
description: 'terrible feature',
strategies: [
{
name: 'baz',
parameters: {
foo: 'rab',
},
},
],
},
'test',
{
name: 'baz',
constraints: [],
parameters: {
foo: 'rab',
},
},
);
await app.services.featureToggleServiceV2.archiveToggle(
'featureArchivedZ',
'test',
);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
name: 'feature.with.variants',
description: 'A feature toggle with variants',
enabled: true,
strategies: [{ name: 'default' }],
variants: [
{ name: 'control', weight: 50 },
{ name: 'new', weight: 50 },
],
},
'test',
);
await createToggle({
name: 'feature.with.variants',
description: 'A feature toggle with variants',
variants: [
{
name: 'control',
weight: 50,
weightType: 'variable',
overrides: [],
stickiness: 'default',
},
{
name: 'new',
weight: 50,
weightType: 'variable',
overrides: [],
stickiness: 'default',
},
],
});
});
afterAll(async () => {
@ -256,14 +272,20 @@ test('can not toggle of feature that does not exist', async () => {
test('can toggle a feature that does exist', async () => {
expect.assertions(0);
const featureName = 'existing.feature';
const feature =
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
name: 'existing.feature',
name: featureName,
},
'test',
);
await app.services.featureToggleServiceV2.createStrategy(
defaultStrategy,
'default',
featureName,
);
return app.request
.post(`/api/admin/features/${feature.name}/toggle`)
.set('Content-Type', 'application/json')

View File

@ -0,0 +1,83 @@
import dbInit, { ITestDb } from '../../../helpers/database-init';
import { IUnleashTest, setupApp } from '../../../helpers/test-helper';
import getLogger from '../../../../fixtures/no-logger';
let app: IUnleashTest;
let db: ITestDb;
beforeAll(async () => {
db = await dbInit('project_environments_api_serial', getLogger);
app = await setupApp(db.stores);
});
afterEach(async () => {
const all = await db.stores.projectStore.getEnvironmentsForProject(
'default',
);
await Promise.all(
all
.filter((env) => env !== ':global:')
.map(async (env) =>
db.stores.projectStore.deleteEnvironmentForProject(
'default',
env,
),
),
);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('Should add environment to project', async () => {
await app.request
.post('/api/admin/environments')
.send({ name: 'test', displayName: 'Test Env' })
.set('Content-Type', 'application/json')
.expect(201);
await app.request
.post('/api/admin/projects/default/environments')
.send({ environment: 'test' })
.expect(200);
const envs = await db.stores.projectStore.getEnvironmentsForProject(
'default',
);
const environment = envs.find((env) => env === 'test');
expect(environment).toBeDefined();
expect(envs).toHaveLength(2);
});
test('Should validate environment', async () => {
await app.request
.post('/api/admin/projects/default/environments')
.send({ name: 'test' })
.expect(400);
});
test('Should remove environment to project', async () => {
const name = 'test-delete';
await app.request
.post('/api/admin/environments')
.send({ name, displayName: 'Test Env' })
.set('Content-Type', 'application/json')
.expect(201);
await app.request
.post('/api/admin/projects/default/environments')
.send({ environment: name })
.expect(200);
await app.request
.delete(`/api/admin/projects/default/environments/${name}`)
.expect(200);
const envs = await db.stores.projectStore.getEnvironmentsForProject(
'default',
);
expect(envs).toHaveLength(1);
});

View File

@ -1,15 +1,31 @@
import dbInit from '../../../helpers/database-init';
import { setupApp } from '../../../helpers/test-helper';
import dbInit, { ITestDb } from '../../../helpers/database-init';
import { IUnleashTest, setupApp } from '../../../helpers/test-helper';
import getLogger from '../../../../fixtures/no-logger';
let app;
let db;
let app: IUnleashTest;
let db: ITestDb;
beforeAll(async () => {
db = await dbInit('feature_strategy_api_serial', getLogger);
app = await setupApp(db.stores);
});
afterEach(async () => {
const all = await db.stores.projectStore.getEnvironmentsForProject(
'default',
);
await Promise.all(
all
.filter((env) => env !== ':global:')
.map(async (env) =>
db.stores.projectStore.deleteEnvironmentForProject(
'default',
env,
),
),
);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
@ -168,8 +184,9 @@ test('Disconnecting environment from project, removes environment from features
});
});
test('Can enable/disable environment for feature', async () => {
test('Can enable/disable environment for feature with strategies', async () => {
const envName = 'enable-feature-environment';
const featureName = 'com.test.enable.environment';
// Create environment
await app.request
.post('/api/admin/environments')
@ -191,23 +208,35 @@ test('Can enable/disable environment for feature', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'com.test.enable.environment',
strategies: [{ name: 'default' }],
name: featureName,
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.name).toBe('com.test.enable.environment');
expect(res.body.name).toBe(featureName);
expect(res.body.createdAt).toBeTruthy();
});
// Add strategy to it
await app.request
.post(
'/api/admin/projects/default/features/com.test.enable.environment/environments/enable-feature-environment/on',
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
)
.send({})
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
await app.request
.get('/api/admin/projects/default/features/com.test.enable.environment')
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/on`,
)
.set('Content-Type', 'application/json')
.expect(200);
await app.request
.get(`/api/admin/projects/default/features/${featureName}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
@ -219,12 +248,12 @@ test('Can enable/disable environment for feature', async () => {
});
await app.request
.post(
'/api/admin/projects/default/features/com.test.enable.environment/environments/enable-feature-environment/off',
`/api/admin/projects/default/features/${featureName}/environments/${envName}/off`,
)
.send({})
.expect(200);
await app.request
.get('/api/admin/projects/default/features/com.test.enable.environment')
.get(`/api/admin/projects/default/features/${featureName}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
@ -261,6 +290,18 @@ test('Can use new project feature toggle endpoint to create feature toggle witho
});
});
test('Can create feature toggle without strategies', async () => {
const name = 'new.toggle.without.strategy.2';
await app.request
.post('/api/admin/projects/default/features')
.send({ name });
const { body: toggle } = await app.request.get(
`/api/admin/projects/default/features/${name}`,
);
expect(toggle.environments).toHaveLength(1);
expect(toggle.environments[0].strategies).toHaveLength(0);
});
test('Still validates feature toggle input when creating', async () => {
await app.request
.post('/api/admin/projects/default/features')
@ -369,6 +410,100 @@ test('Getting feature that does not exist should yield 404', async () => {
.expect(404);
});
test('Should update feature toggle', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.toggle.update';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
await app.request
.put(`${url}/${name}`)
.send({ name, description: 'updated', type: 'kill-switch' })
.expect(200);
const { body: toggle } = await app.request.get(`${url}/${name}`);
expect(toggle.name).toBe(name);
expect(toggle.description).toBe('updated');
expect(toggle.type).toBe('kill-switch');
expect(toggle.archived).toBeFalsy();
});
test('Should not change name of feature toggle', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.toggle.update.2';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
await app.request
.put(`${url}/${name}`)
.send({ name: 'new name', description: 'updated', type: 'kill-switch' })
.expect(400);
});
test('Should not change project of feature toggle even if it is part of body', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.toggle.update.3';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
const { body } = await app.request
.put(`${url}/${name}`)
.send({
name,
description: 'updated',
type: 'kill-switch',
project: 'new',
})
.expect(200);
expect(body.project).toBe('default');
});
test('Should patch feature toggle', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.toggle.patch';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
await app.request
.patch(`${url}/${name}`)
.send([
{ op: 'replace', path: '/description', value: 'New desc' },
{ op: 'replace', path: '/type', value: 'kill-switch' },
])
.expect(200);
const { body: toggle } = await app.request.get(`${url}/${name}`);
expect(toggle.name).toBe(name);
expect(toggle.description).toBe('New desc');
expect(toggle.type).toBe('kill-switch');
expect(toggle.archived).toBeFalsy();
});
test('Should archive feature toggle', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.toggle.archive';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
await app.request.delete(`${url}/${name}`);
await app.request.get(`${url}/${name}`).expect(404);
const { body } = await app.request
.get(`/api/admin/archive/features`)
.expect(200);
const toggle = body.features.find((f) => f.name === name);
expect(toggle).toBeDefined();
});
test('Can add strategy to feature toggle', async () => {
const envName = 'add-strategy';
// Create environment
@ -555,6 +690,65 @@ test('Trying to update a non existing feature strategy should yield 404', async
.expect(404);
});
test('Can patch a strategy based on id', async () => {
const BASE_URI = '/api/admin/projects/default';
const envName = 'feature.patch.strategies';
const featureName = 'feature.patch.strategies';
// Create environment
await app.request
.post('/api/admin/environments')
.send({
name: envName,
displayName: 'Enable feature for environment',
})
.set('Content-Type', 'application/json')
.expect(201);
// Connect environment to project
await app.request
.post(`${BASE_URI}/environments`)
.send({
environment: envName,
})
.expect(200);
await app.request
.post(`${BASE_URI}/features`)
.send({ name: featureName })
.expect(201);
let strategy;
await app.request
.post(
`${BASE_URI}/features/${featureName}/environments/${envName}/strategies`,
)
.send({
name: 'flexibleRollout',
parameters: {
groupId: 'demo',
rollout: 20,
stickiness: 'default',
},
})
.expect(200)
.expect((res) => {
strategy = res.body;
});
await app.request
.patch(
`${BASE_URI}/features/${featureName}/environments/${envName}/strategies/${strategy.id}`,
)
.send([{ op: 'replace', path: '/parameters/rollout', value: 42 }])
.expect(200);
await app.request
.get(
`${BASE_URI}/features/${featureName}/environments/${envName}/strategies/${strategy.id}`,
)
.expect(200)
.expect((res) => {
expect(res.body.parameters.rollout).toBe(42);
});
});
test('Trying to get a non existing feature strategy should yield 404', async () => {
const envName = 'feature.non.existing.strategy.get';
// Create environment
@ -584,3 +778,95 @@ test('Trying to get a non existing feature strategy should yield 404', async ()
)
.expect(404);
});
test('Can not enable environment for feature without strategies', async () => {
const environment = 'some-env';
const featureName = 'com.test.enable.environment.disabled';
// Create environment
await app.request
.post('/api/admin/environments')
.send({
name: environment,
displayName: 'Enable feature for environment',
})
.set('Content-Type', 'application/json')
.expect(201);
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({ environment })
.expect(200);
// Create feature
await app.request
.post('/api/admin/projects/default/features')
.send({
name: featureName,
})
.set('Content-Type', 'application/json')
.expect(201);
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${environment}/on`,
)
.set('Content-Type', 'application/json')
.expect(403);
await app.request
.get('/api/admin/projects/default/features/com.test.enable.environment')
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
const enabledFeatureEnv = res.body.environments.find(
(e) => e.name === environment,
);
expect(enabledFeatureEnv.enabled).toBe(false);
});
});
test('Can delete strategy from feature toggle', async () => {
const envName = 'del-strategy';
const featureName = 'feature.strategy.toggle.delete.strategy';
// Create environment
await app.request
.post('/api/admin/environments')
.send({
name: envName,
displayName: 'Enable feature for environment',
})
.set('Content-Type', 'application/json')
.expect(201);
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
const { body } = await app.request.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
);
const strategies = body;
const strategyId = strategies[0].id;
await app.request
.delete(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/${strategyId}`,
)
.expect(200);
});

View File

@ -1,11 +1,9 @@
'use strict';
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
const { setupApp } = require('../../helpers/test-helper');
const dbInit = require('../../helpers/database-init');
const getLogger = require('../../../fixtures/no-logger');
let app;
let db;
let app: IUnleashTest;
let db: ITestDb;
beforeAll(async () => {
db = await dbInit('feature_api_client', getLogger);
@ -18,10 +16,14 @@ beforeAll(async () => {
},
'test',
);
await app.services.featureToggleServiceV2.createFeatureToggle('default', {
name: 'featureY',
description: 'soon to be the #1 feature',
});
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
name: 'featureY',
description: 'soon to be the #1 feature',
},
'test',
);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
@ -38,6 +40,7 @@ beforeAll(async () => {
},
'test',
);
await app.services.featureToggleServiceV2.archiveToggle(
'featureArchivedX',
'test',
@ -51,6 +54,7 @@ beforeAll(async () => {
},
'test',
);
await app.services.featureToggleServiceV2.archiveToggle(
'featureArchivedY',
'test',
@ -73,8 +77,18 @@ beforeAll(async () => {
name: 'feature.with.variants',
description: 'A feature toggle with variants',
variants: [
{ name: 'control', weight: 50 },
{ name: 'new', weight: 50 },
{
name: 'control',
weight: 50,
weightType: 'fix',
stickiness: 'default',
},
{
name: 'new',
weight: 50,
weightType: 'fix',
stickiness: 'default',
},
],
},
'test',
@ -86,14 +100,15 @@ afterAll(async () => {
await db.destroy();
});
test('returns four feature toggles', async () =>
test('returns four feature toggles', async () => {
app.request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features.length).toBe(4);
}));
expect(res.body.features).toHaveLength(4);
});
});
test('returns four feature toggles without createdAt', async () =>
app.request
@ -101,6 +116,7 @@ test('returns four feature toggles without createdAt', async () =>
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(4);
expect(res.body.features[0].createdAt).toBeFalsy();
}));
@ -130,11 +146,64 @@ test('Can filter features by namePrefix', async () => {
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features.length).toBe(1);
expect(res.body.features).toHaveLength(1);
expect(res.body.features[0].name).toBe('feature.with.variants');
});
});
test('Can get strategies for specific environment', async () => {
const featureName = 'test.feature.with.env';
// Create feature toggle
await app.request.post('/api/admin/projects/default/features').send({
name: featureName,
type: 'killswitch',
});
// Add global strategy
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/:global:/strategies`,
)
.send({
name: 'default',
})
.expect(200);
// create new env
await db.stores.environmentStore.upsert({
name: 'testing',
displayName: 'simple test',
});
await app.services.environmentService.addEnvironmentToProject(
'testing',
'default',
);
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/testing/strategies`,
)
.send({
name: 'custom1',
})
.expect(200);
await app.request
.get(`/api/client/features/${featureName}?environment=testing`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.name).toBe(featureName);
expect(res.body.strategies).toHaveLength(2);
expect(
res.body.strategies.find((s) => s.name === 'custom1'),
).toBeDefined();
});
});
test('Can use multiple filters', async () => {
expect.assertions(3);
@ -174,13 +243,13 @@ test('Can use multiple filters', async () => {
.get('/api/client/features?tag=simple:Crazy')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => expect(res.body.features.length).toBe(2));
.expect((res) => expect(res.body.features).toHaveLength(2));
await app.request
.get('/api/client/features?namePrefix=test&tag=simple:Crazy')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features.length).toBe(1);
expect(res.body.features).toHaveLength(1);
expect(res.body.features[0].name).toBe('test.feature');
});
});

View File

@ -8,6 +8,8 @@ import dbState from './database.json';
import { LogProvider } from '../../../lib/logger';
import noLoggerProvider from '../../fixtures/no-logger';
import EnvironmentStore from '../../../lib/db/environment-store';
import { IUnleashStores } from '../../../lib/types';
import { IFeatureEnvironmentStore } from '../../../lib/types/stores/feature-environment-store';
// require('db-migrate-shared').log.silence(false);
@ -51,7 +53,7 @@ function createTagTypes(store) {
return dbState.tag_types.map((t) => store.createTagType(t));
}
async function connectProject(store: EnvironmentStore): Promise<void> {
async function connectProject(store: IFeatureEnvironmentStore): Promise<void> {
await store.connectProject(':global:', 'default');
}
@ -65,13 +67,19 @@ async function setupDatabase(stores) {
await Promise.all(createContextFields(stores.contextFieldStore));
await Promise.all(createProjects(stores.projectStore));
await Promise.all(createTagTypes(stores.tagTypeStore));
await connectProject(stores.environmentStore);
await connectProject(stores.featureEnvironmentStore);
}
export interface ITestDb {
stores: IUnleashStores;
reset: () => Promise<void>;
destroy: () => Promise<void>;
}
export default async function init(
databaseSchema: String = 'test',
getLogger: LogProvider = noLoggerProvider,
): Promise<any> {
): Promise<ITestDb> {
const config = createTestConfig({
db: {
...dbConfig.getDb(),

View File

@ -63,15 +63,15 @@ test('Can update display name', async () => {
test('Can connect environment to project', async () => {
await service.create({ name: 'test-connection', displayName: '' });
await stores.featureToggleStore.createFeature('default', {
await stores.featureToggleStore.create('default', {
name: 'test-connection',
type: 'release',
description: '',
stale: false,
variants: [],
});
await service.connectProjectToEnvironment('test-connection', 'default');
const overview = await stores.projectStore.getProjectOverview(
await service.addEnvironmentToProject('test-connection', 'default');
const overview = await stores.featureStrategiesStore.getFeatureOverview(
'default',
false,
);
@ -88,12 +88,12 @@ test('Can connect environment to project', async () => {
test('Can remove environment from project', async () => {
await service.create({ name: 'removal-test', displayName: '' });
await stores.featureToggleStore.createFeature('default', {
await stores.featureToggleStore.create('default', {
name: 'removal-test',
});
await service.removeEnvironmentFromProject('test-connection', 'default');
await service.connectProjectToEnvironment('removal-test', 'default');
let overview = await stores.projectStore.getProjectOverview(
await service.addEnvironmentToProject('removal-test', 'default');
let overview = await stores.featureStrategiesStore.getFeatureOverview(
'default',
false,
);
@ -108,7 +108,10 @@ test('Can remove environment from project', async () => {
]);
});
await service.removeEnvironmentFromProject('removal-test', 'default');
overview = await stores.projectStore.getProjectOverview('default', false);
overview = await stores.featureStrategiesStore.getFeatureOverview(
'default',
false,
);
expect(overview.length).toBeGreaterThan(0);
overview.forEach((o) => {
expect(o.environments).toEqual([]);
@ -120,9 +123,9 @@ test('Adding same environment twice should throw a NameExistsError', async () =>
await service.removeEnvironmentFromProject('test-connection', 'default');
await service.removeEnvironmentFromProject('removal-test', 'default');
await service.connectProjectToEnvironment('uniqueness-test', 'default');
await service.addEnvironmentToProject('uniqueness-test', 'default');
return expect(async () =>
service.connectProjectToEnvironment('uniqueness-test', 'default'),
service.addEnvironmentToProject('uniqueness-test', 'default'),
).rejects.toThrow(
new NameExistsError(
'default already has the environment uniqueness-test enabled',

View File

@ -97,7 +97,13 @@ test('Should include legacy props in event log when updating strategy configurat
);
await service.createStrategy(config, 'default', featureName);
await service.updateEnabled(featureName, GLOBAL_ENV, true, userName);
await service.updateEnabled(
'default',
featureName,
GLOBAL_ENV,
true,
userName,
);
const events = await eventService.getEventsForToggle(featureName);
expect(events[0].type).toBe(FEATURE_UPDATED);

View File

@ -1,18 +1,20 @@
import dbInit from '../helpers/database-init';
import dbInit, { ITestDb } from '../helpers/database-init';
import getLogger from '../../fixtures/no-logger';
import FeatureToggleServiceV2 from '../../../lib/services/feature-toggle-service-v2';
import { AccessService } from '../../../lib/services/access-service';
import ProjectService from '../../../lib/services/project-service';
import ProjectHealthService from '../../../lib/services/project-health-service';
import { createTestConfig } from '../../config/test-config';
import { IUnleashStores } from '../../../lib/types';
import { IUser } from '../../../lib/server-impl';
let stores;
let db;
let stores: IUnleashStores;
let db: ITestDb;
let projectService;
let accessService;
let projectHealthService;
let featureToggleService;
let user;
let user: IUser;
beforeAll(async () => {
const config = createTestConfig();
@ -30,7 +32,11 @@ beforeAll(async () => {
accessService,
featureToggleService,
);
projectHealthService = new ProjectHealthService(stores, config);
projectHealthService = new ProjectHealthService(
stores,
config,
featureToggleService,
);
});
afterAll(async () => {
@ -43,12 +49,12 @@ test('Project with no stale toggles should have 100% health rating', async () =>
description: 'Fancy',
};
const savedProject = await projectService.createProject(project, user);
await stores.featureToggleStore.createFeature('health-rating', {
await stores.featureToggleStore.create('health-rating', {
name: 'health-rating-not-stale',
description: 'new',
stale: false,
});
await stores.featureToggleStore.createFeature('health-rating', {
await stores.featureToggleStore.create('health-rating', {
name: 'health-rating-not-stale-2',
description: 'new too',
stale: false,
@ -66,22 +72,22 @@ test('Project with two stale toggles and two non stale should have 50% health ra
description: 'Fancy',
};
const savedProject = await projectService.createProject(project, user);
await stores.featureToggleStore.createFeature('health-rating-2', {
await stores.featureToggleStore.create('health-rating-2', {
name: 'health-rating-2-not-stale',
description: 'new',
stale: false,
});
await stores.featureToggleStore.createFeature('health-rating-2', {
await stores.featureToggleStore.create('health-rating-2', {
name: 'health-rating-2-not-stale-2',
description: 'new too',
stale: false,
});
await stores.featureToggleStore.createFeature('health-rating-2', {
await stores.featureToggleStore.create('health-rating-2', {
name: 'health-rating-2-stale-1',
description: 'stale',
stale: true,
});
await stores.featureToggleStore.createFeature('health-rating-2', {
await stores.featureToggleStore.create('health-rating-2', {
name: 'health-rating-2-stale-2',
description: 'stale too',
stale: true,
@ -99,19 +105,19 @@ test('Project with one non-stale, one potentially stale and one stale should hav
description: 'Fancy',
};
const savedProject = await projectService.createProject(project, user);
await stores.featureToggleStore.createFeature('health-rating-3', {
await stores.featureToggleStore.create('health-rating-3', {
name: 'health-rating-3-not-stale',
description: 'new',
stale: false,
});
await stores.featureToggleStore.createFeature('health-rating-3', {
await stores.featureToggleStore.create('health-rating-3', {
name: 'health-rating-3-potentially-stale',
description: 'new too',
type: 'release',
stale: false,
createdAt: new Date(Date.UTC(2020, 1, 1)),
});
await stores.featureToggleStore.createFeature('health-rating-3', {
await stores.featureToggleStore.create('health-rating-3', {
name: 'health-rating-3-stale',
description: 'stale',
stale: true,

View File

@ -1,4 +1,4 @@
import dbInit from '../helpers/database-init';
import dbInit, { ITestDb } from '../helpers/database-init';
import getLogger from '../../fixtures/no-logger';
import FeatureToggleServiceV2 from '../../../lib/services/feature-toggle-service-v2';
import ProjectService from '../../../lib/services/project-service';
@ -13,7 +13,7 @@ import { createTestConfig } from '../../config/test-config';
import { RoleName } from '../../../lib/types/model';
let stores;
let db;
let db: ITestDb;
let projectService;
let accessService;
@ -104,7 +104,7 @@ test('should not be able to delete project with toggles', async () => {
description: 'Blah',
};
await projectService.createProject(project, user);
await stores.featureToggleStore.createFeature(project.id, {
await stores.featureToggleStore.create(project.id, {
name: 'test-project-delete',
project: project.id,
enabled: false,

View File

@ -17,7 +17,7 @@ beforeAll(async () => {
featureTagStore = stores.featureTagStore;
featureToggleStore = stores.featureToggleStore;
await stores.tagStore.createTag(tag);
await featureToggleStore.createFeature('default', { name: featureName });
await featureToggleStore.create('default', { name: featureName });
});
afterAll(async () => {
@ -83,7 +83,7 @@ test('should throw if feature have tag', async () => {
test('get all feature tags', async () => {
await featureTagStore.tagFeature(featureName, tag);
await featureToggleStore.createFeature('default', {
await featureToggleStore.create('default', {
name: 'some-other-toggle',
});
await featureTagStore.tagFeature('some-other-toggle', tag);
@ -92,7 +92,7 @@ test('get all feature tags', async () => {
});
test('should import feature tags', async () => {
await featureToggleStore.createFeature('default', {
await featureToggleStore.create('default', {
name: 'some-other-toggle-import',
});
await featureTagStore.importFeatureTags([

View File

@ -31,47 +31,11 @@ export default class FakeEnvironmentStore implements IEnvironmentStore {
return Promise.resolve(env);
}
async connectProject(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
async connectFeatures(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
async delete(name: string): Promise<void> {
this.environments = this.environments.filter((e) => e.name !== name);
return Promise.resolve();
}
async disconnectProjectFromEnv(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
async connectFeatureToEnvironmentsForProject(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureName: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
project_id: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
async deleteAll(): Promise<void> {
this.environments = [];
}

View File

@ -10,7 +10,7 @@ export default class FakeFeatureEnvironmentStore
{
featureEnvironments: IFeatureEnvironment[] = [];
async connectEnvironmentAndFeature(
async addEnvironmentToFeature(
featureName: string,
environment: string,
enabled: boolean,
@ -35,7 +35,7 @@ export default class FakeFeatureEnvironmentStore
destroy(): void {}
async disconnectEnvironmentFromProject(
async disconnectFeatures(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -44,14 +44,6 @@ export default class FakeFeatureEnvironmentStore
return Promise.resolve(undefined);
}
async enableEnvironmentForFeature(
featureName: string,
environment: string,
): Promise<void> {
const fE = await this.get({ featureName, environment });
fE.enabled = true;
}
async exists(key: FeatureEnvironmentKey): Promise<boolean> {
return this.featureEnvironments.some(
(fE) =>
@ -85,10 +77,6 @@ export default class FakeFeatureEnvironmentStore
return this.featureEnvironments;
}
async getAllFeatureEnvironments(): Promise<IFeatureEnvironment[]> {
return this.getAll();
}
getEnvironmentMetaData(
environment: string,
featureName: string,
@ -115,7 +103,7 @@ export default class FakeFeatureEnvironmentStore
return this.delete({ featureName, environment });
}
async toggleEnvironmentEnabledStatus(
async setEnvironmentEnabledStatus(
environment: string,
featureName: string,
enabled: boolean,
@ -124,4 +112,40 @@ export default class FakeFeatureEnvironmentStore
fE.enabled = enabled;
return enabled;
}
async connectProject(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
async connectFeatures(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
async disconnectProject(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
async connectFeatureToEnvironmentsForProject(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureName: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
}

View File

@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
import {
FeatureToggle,
FeatureToggleWithEnvironment,
IFeatureEnvironment,
IFeatureOverview,
IFeatureStrategy,
IFeatureToggleClient,
IFeatureToggleQuery,
@ -26,7 +26,7 @@ export default class FakeFeatureStrategiesStore
featureToggles: FeatureToggle[] = [];
async createStrategyConfig(
async createStrategyFeatureEnv(
strategyConfig: Omit<IFeatureStrategy, 'id' | 'createdAt'>,
): Promise<IFeatureStrategy> {
const newStrat = { ...strategyConfig, id: randomUUID() };
@ -34,14 +34,6 @@ export default class FakeFeatureStrategiesStore
return Promise.resolve(newStrat);
}
async getStrategiesForToggle(
featureName: string,
): Promise<IFeatureStrategy[]> {
return this.featureStrategies.filter(
(fS) => fS.featureName === featureName,
);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async createFeature(feature: any): Promise<void> {
this.featureToggles.push({
@ -53,24 +45,11 @@ export default class FakeFeatureStrategiesStore
return Promise.resolve();
}
async getAllFeatureStrategies(): Promise<IFeatureStrategy[]> {
return this.featureStrategies;
}
async deleteFeatureStrategies(): Promise<void> {
this.featureStrategies = [];
return Promise.resolve();
}
async getStrategiesForEnvironment(
environment: string,
): Promise<IFeatureStrategy[]> {
const stratEnvs = this.featureStrategies.filter(
(fS) => fS.environment === environment,
);
return Promise.resolve(stratEnvs);
}
async hasStrategy(id: string): Promise<boolean> {
return this.featureStrategies.some((s) => s.id === id);
}
@ -98,7 +77,7 @@ export default class FakeFeatureStrategiesStore
throw new Error('Method not implemented.');
}
async removeAllStrategiesForEnv(
async removeAllStrategiesForFeatureEnv(
feature_name: string,
environment: string,
): Promise<void> {
@ -122,29 +101,21 @@ export default class FakeFeatureStrategiesStore
return Promise.resolve(this.featureStrategies);
}
async getStrategiesForFeature(
async getStrategiesForFeatureEnv(
project_name: string,
feature_name: string,
environment: string,
): Promise<IFeatureStrategy[]> {
const rows = this.featureStrategies.filter(
(fS) =>
fS.projectName === project_name &&
fS.projectId === project_name &&
fS.featureName === feature_name &&
fS.environment === environment,
);
return Promise.resolve(rows);
}
async getStrategiesForEnv(
environment: string,
): Promise<IFeatureStrategy[]> {
return this.featureStrategies.filter(
(fS) => fS.environment === environment,
);
}
async getFeatureToggleAdmin(
async getFeatureToggleWithEnvs(
featureName: string,
archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> {
@ -159,6 +130,15 @@ export default class FakeFeatureStrategiesStore
);
}
async getFeatureOverview(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
archived: boolean,
): Promise<IFeatureOverview[]> {
return Promise.resolve([]);
}
async getFeatures(
featureQuery?: IFeatureToggleQuery,
archived: boolean = false,
@ -214,31 +194,6 @@ export default class FakeFeatureStrategiesStore
return Promise.resolve();
}
async enableEnvironmentForFeature(
feature_name: string,
environment: string,
): Promise<void> {
if (!this.environmentAndFeature.has(environment)) {
this.environmentAndFeature.set(environment, [
{
featureName: feature_name,
enabled: true,
},
]);
}
const features = this.environmentAndFeature
.get(environment)
.map((f) => {
if (f.featureName === feature_name) {
// eslint-disable-next-line no-param-reassign
f.enabled = true;
}
return f;
});
this.environmentAndFeature.set(environment, features);
return Promise.resolve();
}
async removeEnvironmentForFeature(
feature_name: string,
environment: string,
@ -275,15 +230,6 @@ export default class FakeFeatureStrategiesStore
return Promise.resolve(this.featureStrategies.find((f) => f.id === id));
}
async getStrategiesAndMetadataForEnvironment(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureName: string,
): Promise<void> {
return Promise.resolve();
}
async deleteConfigurationsForProjectAndEnvironment(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: String,
@ -304,17 +250,13 @@ export default class FakeFeatureStrategiesStore
return Promise.resolve(enabled);
}
async toggleEnvironmentEnabledStatus(
async setEnvironmentEnabledStatus(
environment: string,
featureName: string,
enabled: boolean,
): Promise<boolean> {
return Promise.resolve(enabled);
}
async getAllFeatureEnvironments(): Promise<IFeatureEnvironment[]> {
return Promise.resolve([]);
}
}
module.exports = FakeFeatureStrategiesStore;

View File

@ -0,0 +1,67 @@
import {
FeatureToggle,
IFeatureToggleClient,
IFeatureToggleQuery,
} from '../../lib/types/model';
import { IFeatureToggleClientStore } from '../../lib/types/stores/feature-toggle-client-store';
export default class FakeFeatureToggleClientStore
implements IFeatureToggleClientStore
{
featureToggles: FeatureToggle[] = [];
async getFeatures(
featureQuery?: IFeatureToggleQuery,
archived: boolean = false,
): Promise<IFeatureToggleClient[]> {
const rows = this.featureToggles.filter((toggle) => {
if (featureQuery.namePrefix) {
if (featureQuery.project) {
return (
toggle.name.startsWith(featureQuery.namePrefix) &&
featureQuery.project.includes(toggle.project)
);
}
return toggle.name.startsWith(featureQuery.namePrefix);
}
if (featureQuery.project) {
return featureQuery.project.includes(toggle.project);
}
return toggle.archived === archived;
});
const clientRows: IFeatureToggleClient[] = rows.map((t) => ({
...t,
enabled: true,
strategies: [],
description: t.description || '',
type: t.type || 'Release',
stale: t.stale || false,
variants: [],
}));
return Promise.resolve(clientRows);
}
async getClient(
query?: IFeatureToggleQuery,
): Promise<IFeatureToggleClient[]> {
return this.getFeatures(query);
}
async getAdmin(
query?: IFeatureToggleQuery,
archived: boolean = false,
): Promise<IFeatureToggleClient[]> {
return this.getFeatures(query, archived);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async createFeature(feature: any): Promise<void> {
this.featureToggles.push({
project: feature.project || 'default',
createdAt: new Date(),
archived: false,
...feature,
});
return Promise.resolve();
}
}

View File

@ -1,7 +1,6 @@
import {
IFeatureToggleQuery,
IFeatureToggleStore,
IHasFeature,
} from '../../lib/types/stores/feature-toggle-store';
import { FeatureToggle, FeatureToggleDTO } from '../../lib/types/model';
import NotFoundError from '../../lib/error/notfound-error';
@ -9,7 +8,7 @@ import NotFoundError from '../../lib/error/notfound-error';
export default class FakeFeatureToggleStore implements IFeatureToggleStore {
features: FeatureToggle[] = [];
async archiveFeature(featureName: string): Promise<FeatureToggle> {
async archive(featureName: string): Promise<FeatureToggle> {
const feature = this.features.find((f) => f.name === featureName);
if (feature) {
feature.archived = true;
@ -46,7 +45,7 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
};
}
async createFeature(
async create(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle> {
@ -88,30 +87,19 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return this.get(name);
}
async getFeatures(archived: boolean): Promise<FeatureToggle[]> {
return this.features.filter((f) => f.archived === archived);
}
async getFeaturesBy(
query: Partial<IFeatureToggleQuery>,
): Promise<FeatureToggle[]> {
async getBy(query: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]> {
return this.features.filter(this.getFilterQuery(query));
}
async hasFeature(featureName: string): Promise<IHasFeature> {
const { name, archived } = await this.get(featureName);
return { name, archived };
}
async reviveFeature(featureName: string): Promise<FeatureToggle> {
async revive(featureName: string): Promise<FeatureToggle> {
const revive = this.features.find((f) => f.name === featureName);
if (revive) {
revive.archived = false;
}
return this.updateFeature(revive.project, revive);
return this.update(revive.project, revive);
}
async updateFeature(
async update(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle> {
@ -127,7 +115,7 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
throw new NotFoundError('Could not find feature to update');
}
async updateLastSeenForToggles(toggleNames: string[]): Promise<void> {
async setLastSeen(toggleNames: string[]): Promise<void> {
toggleNames.forEach((t) => {
const toUpdate = this.features.find((f) => f.name === t);
if (toUpdate) {

View File

@ -3,7 +3,7 @@ import {
IProjectInsert,
IProjectStore,
} from '../../lib/types/stores/project-store';
import { IFeatureOverview, IProject } from '../../lib/types/model';
import { IProject } from '../../lib/types/model';
import NotFoundError from '../../lib/error/notfound-error';
export default class FakeProjectStore implements IProjectStore {
@ -89,15 +89,6 @@ export default class FakeProjectStore implements IProjectStore {
return Promise.resolve(0);
}
async getProjectOverview(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
archived: boolean,
): Promise<IFeatureOverview[]> {
return Promise.resolve([]);
}
async hasProject(id: string): Promise<boolean> {
return this.exists(id);
}

View File

@ -22,6 +22,7 @@ import FakeFeatureEnvironmentStore from './fake-feature-environment-store';
import FakeApiTokenStore from './fake-api-token-store';
import FakeFeatureTypeStore from './fake-feature-type-store';
import FakeResetTokenStore from './fake-reset-token-store';
import FakeFeatureToggleClientStore from './fake-feature-toggle-client-store';
const createStores: () => IUnleashStores = () => {
const db = {
@ -36,6 +37,7 @@ const createStores: () => IUnleashStores = () => {
clientMetricsStore: new FakeClientMetricsStore(),
clientInstanceStore: new FakeClientInstanceStore(),
featureToggleStore: new FakeFeatureToggleStore(),
featureToggleClientStore: new FakeFeatureToggleClientStore(),
tagStore: new FakeTagStore(),
tagTypeStore: new FakeTagTypeStore(),
eventStore: new FakeEventStore(),

View File

@ -0,0 +1,456 @@
---
id: feature-toggles-v2
title: /api/admin/projects/:projectId
---
> In order to access the admin API endpoints you need to identify yourself. You'll need to [create an ADMIN token](/user_guide/api-token) and add an Authorization header using the token.
In this document we will guide you on how you can work with feature toggles and their configuration. Please remember the following details:
- All feature toggles exists _inside a project_.
- A feature toggles exists _across all environments_.
- A feature toggle can take different configuration, activation strategies, per environment.
TODO: Need to explain the following in a bit more details:
- The _:global:: environment
> We will in this guide use [HTTPie](https://httpie.io) commands to show examples on how to interact with the API.
### Get Project Overview {#fetching-project}
`http://localhost:4242/api/admin/projects/:projectId`
This endpoint will give you an general overview of a project. It will return essential details about a project, in addition it will return all feature toggles and high level environment details per feature toggle.
**Example Query**
`http GET http://localhost:4242/api/admin/projects/default Authorization:$KEY`
**Example response:**
```json
{
"description": "Default project",
"features": [
{
"createdAt": "2021-08-31T08:00:33.335Z",
"environments": [
{
"displayName": "Development",
"enabled": false,
"name": "development"
},
{
"displayName": "Production",
"enabled": false,
"name": "production"
}
],
"lastSeenAt": null,
"name": "demo",
"stale": false,
"type": "release"
},
{
"createdAt": "2021-08-31T09:43:13.686Z",
"environments": [
{
"displayName": "Development",
"enabled": false,
"name": "development"
},
{
"displayName": "Production",
"enabled": false,
"name": "production"
}
],
"lastSeenAt": null,
"name": "demo.test",
"stale": false,
"type": "release"
}
],
"health": 100,
"members": 2,
"name": "Default",
"version": 1
}
```
From the results we can see that we have received two feature toggles, _demo_, _demo.test_, and other useful metadata about the project.
### Get All Feature Toggles {#fetching-toggles}
`http://localhost:4242/api/admin/projects/:projectId/features`
This endpoint will return all feature toggles and high level environment details per feature toggle for a given _projectId_
**Example Query**
`http GET http://localhost:4242/api/admin/projects/default/features Authorization:$KEY`
**Example response:**
```json
{
"features": [
{
"createdAt": "2021-08-31T08:00:33.335Z",
"environments": [
{
"displayName": "Development",
"enabled": false,
"name": "development"
},
{
"displayName": "Production",
"enabled": false,
"name": "production"
}
],
"lastSeenAt": null,
"name": "demo",
"stale": false,
"type": "release"
},
{
"createdAt": "2021-08-31T09:43:13.686Z",
"environments": [
{
"displayName": "Development",
"enabled": false,
"name": "development"
},
{
"displayName": "Production",
"enabled": false,
"name": "production"
}
],
"lastSeenAt": null,
"name": "demo.test",
"stale": false,
"type": "release"
}
],
"version": 1
}
```
### Create Feature Toggle {#create-toggle}
`http://localhost:4242/api/admin/projects/:projectId/features`
This endpoint will accept HTTP POST request to create a new feature toggle for a given _projectId_
**Example Query**
```sh
echo '{"name": "demo2", "description": "A new feature toggle"}' | http POST http://localhost:4242/api/admin/projects/default/features Authorization:$KEY`
```
**Example response:**
```json
HTTP/1.1 201 Created
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 159
Content-Type: application/json; charset=utf-8
Date: Tue, 07 Sep 2021 20:16:02 GMT
ETag: W/"9f-4btEokgk0N74zuBVKKxws0IBu4w"
Keep-Alive: timeout=60
Vary: Accept-Encoding
{
"createdAt": "2021-09-07T20:16:02.614Z",
"description": "A new feature toggle",
"lastSeenAt": null,
"name": "demo2",
"project": "default",
"stale": false,
"type": "release",
"variants": null
}
```
Possible Errors:
- _409 Conflict_ - A toggle with that name already exists
### Get Feature Toggle {#get-toggle}
`http://localhost:4242/api/admin/projects/:projectId/features/:featureName`
This endpoint will return the feature toggles with the defined name and _projectId_. We will also see the list of environments and all activation strategies configured per environment.
**Example Query**
```sh
http GET http://localhost:4242/api/admin/projects/default/features/demo Authorization:$KEY`
```
**Example response:**
```json
{
"archived": false,
"createdAt": "2021-08-31T08:00:33.335Z",
"description": null,
"environments": [
{
"enabled": false,
"name": "development",
"strategies": [
{
"constraints": [],
"id": "8eaa8abb-0e03-4dbb-a440-f3bf193917ad",
"name": "default",
"parameters": null
}
]
},
{
"enabled": false,
"name": "production",
"strategies": []
}
],
"lastSeenAt": null,
"name": "demo",
"project": "default",
"stale": false,
"type": "release",
"variants": null
}
```
Possible Errors:
- _404 Not Found_ - Could not find feature toggle with the provided name.
### Update Feature Toggle {#update-toggle}
`http://localhost:4242/api/admin/projects/:projectId/features/:featureName`
This endpoint will accept HTTP PUT request to update the feature toggle metadata.
**Example Query**
```sh
echo '{"name": "demo", "description": "An update feature toggle", "type": "kill-switch"}' | http PUT http://localhost:4242/api/admin/projects/default/features/demo Authorization:$KEY`
```
**Example response:**
```json
{
"createdAt": "2021-09-07T20:16:02.614Z",
"description": "An update feature toggle",
"lastSeenAt": null,
"name": "demo",
"project": "default",
"stale": false,
"type": "kill-switch",
"variants": null
}
```
Some fields is not possible to change via this endpoint:
- name
- project
- createdAt
- lastSeen
## Patch Feature Toggle {#patch-toggle}
`http://localhost:4242/api/admin/projects/:projectId/features/:featureName`
This endpoint will accept HTTP PATCH request to update the feature toggle metadata.
**Example Query**
```sh
echo '[{"op": "replace", "path": "/description", "value": "patched desc"}]' | http PATCH http://localhost:4242/api/admin/projects/default/features/demo Authorization:$KEY`
```
**Example response:**
```json
{
"createdAt": "2021-09-07T20:16:02.614Z",
"description": "patched desc",
"lastSeenAt": null,
"name": "demo",
"project": "default",
"stale": false,
"type": "release",
"variants": null
}
```
Some fields is not possible to change via this endpoint:
- name
- project
- createdAt
- lastSeen
### Archive Feature Toggle {#archive-toggle}
`http://localhost:4242/api/admin/projects/:projectId/features/:featureName`
This endpoint will accept HTTP PUT request to update the feature toggle metadata.
**Example Query**
```sh
http DELETE http://localhost:4242/api/admin/projects/default/features/demo Authorization:$KEY`
```
**Example response:**
```sh
HTTP/1.1 202 Accepted
Access-Control-Allow-Origin: *
Connection: keep-alive
Date: Wed, 08 Sep 2021 20:09:21 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
```
### Add strategy to Feature Toggle {#add-strategy}
`http://localhost:4242/api/admin/projects/:projectId/features/:featureName/environments/:environment/strategies`
This endpoint will allow you to add a new strategy to a feature toggle in a given environment.
**Example Query**
```sh
echo '{"name": "flexibleRollout", "parameters": { "rollout": 20, "groupId": "demo", "stickiness": "default" }}' | \
http POST http://localhost:4242/api/admin/projects/default/features/demo/environments/production/strategies Authorization:$KEY
```
**Example response:**
```json
{
"constraints": [],
"id": "77bbe972-ffce-49b2-94d9-326593e2228e",
"name": "flexibleRollout",
"parameters": {
"groupId": "demo",
"rollout": 20,
"stickiness": "default"
}
}
```
### Update strategy configuration {#update-strategy}
**Example Query**
```sh
echo '{"name": "flexibleRollout", "parameters": { "rollout": 25, "groupId": "demo","stickiness": "default" }}' | \
http PUT http://localhost:4242/api/admin/projects/default/features/demo/environments/production/strategies/77bbe972-ffce-49b2-94d9-326593e2228e Authorization:$KEY
```
**Example response:**
```json
{
"constraints": [],
"id": "77bbe972-ffce-49b2-94d9-326593e2228e",
"name": "flexibleRollout",
"parameters": {
"groupId": "demo",
"rollout": 20,
"stickiness": "default"
}
}
```
## Patch strategy configuration {#patch-strategy}
**Example Query**
```sh
echo '[{"op": "replace", "path": "/parameters/rollout", "value": 50}]' | \
http PATCH http://localhost:4242/api/admin/projects/default/features/demo/environments/production/strategies/ea5404e5-0c0d-488c-93b2-0a2200534827 Authorization:$KEY
```
**Example response:**
```json
{
"constraints": [],
"id": "ea5404e5-0c0d-488c-93b2-0a2200534827",
"name": "flexibleRollout",
"parameters": {
"groupId": "demo",
"rollout": 50,
"stickiness": "default"
}
}
```
### Delete strategy from Feature Toggle {#delete-strategy}
**Example Query**
```sh
http DELETE http://localhost:4242/api/admin/projects/default/features/demo/environments/production/strategies/77bbe972-ffce-49b2-94d9-326593e2228e Authorization:$KEY
```
**Example response:**
```sh
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Type: application/json; charset=utf-8
Date: Tue, 07 Sep 2021 20:47:55 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
Vary: Accept-Encoding
```
### Enable environment for Feature Toggle {#enable-env}
**Example Query**
```sh
http POST http://localhost:4242/api/admin/projects/default/features/demo/environments/development/on Authorization:$KEY --json
```
**Example response:**
```sh
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Connection: keep-alive
Date: Tue, 07 Sep 2021 20:49:51 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
```
Possible Errors:
- _409 Conflict_ - You can not enable the environment before it has strategies.

View File

@ -2815,6 +2815,11 @@ fast-glob@^3.1.1:
merge2 "^1.3.0"
micromatch "^4.0.4"
fast-json-patch@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-3.1.0.tgz#ec8cd9b9c4c564250ec8b9140ef7a55f70acaee6"
integrity sha512-IhpytlsVTRndz0hU5t0/MGzS/etxLlfrpG5V5M9mVbuj9TrJLWaMfsox9REM5rkuGX0T+5qjpe8XA1o0gZ42nA==
fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0:
version "2.1.0"
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"