diff --git a/package.json b/package.json index a5572211d4..56b368db8d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/db/environment-store.ts b/src/lib/db/environment-store.ts index 6e23c638f0..51d712e5e6 100644 --- a/src/lib/db/environment-store.ts +++ b/src/lib/db/environment-store.ts @@ -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 { - await this.db('project_environments').insert({ - environment_name: environment, - project_id: projectId, - }); - } - - async connectFeatures( - environment: string, - projectId: string, - ): Promise { - 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('feature_environments') - .insert(rows) - .onConflict(['environment', 'feature_name']) - .ignore(); - } - } - async delete(name: string): Promise { await this.db(TABLE).where({ name }).del(); } - async disconnectProjectFromEnv( - environment: string, - projectId: string, - ): Promise { - await this.db('project_environments') - .where({ environment_name: environment, project_id: projectId }) - .del(); - } - - async connectFeatureToEnvironmentsForProject( - featureName: string, - project_id: string, - ): Promise { - 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 {} } diff --git a/src/lib/db/feature-environment-store.ts b/src/lib/db/feature-environment-store.ts index 82a2befd23..219e758c4e 100644 --- a/src/lib/db/feature-environment-store.ts +++ b/src/lib/db/feature-environment-store.ts @@ -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 { 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 { @@ -114,15 +121,6 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore { }); } - async enableEnvironmentForFeature( - feature_name: string, - environment: string, - ): Promise { - 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 { - 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 { - 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 { + await this.db('project_environments').insert({ + environment_name: environment, + project_id: projectId, + }); + } + + async connectFeatures( + environment: string, + projectId: string, + ): Promise { + 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('feature_environments') + .insert(rows) + .onConflict(['environment', 'feature_name']) + .ignore(); + } + } + + async disconnectProject( + environment: string, + projectId: string, + ): Promise { + await this.db('project_environments') + .where({ environment_name: environment, project_id: projectId }) + .del(); + } + + async connectFeatureToEnvironmentsForProject( + featureName: string, + projectId: string, + ): Promise { + 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(); + }), + ); + } } diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index ba5302ccc8..7ccc0bab3f 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -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, ): Promise { 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 { - const stopTimer = this.timer('getAll'); - const rows = await this.db - .select(COLUMNS) - .where('feature_name', featureName) - .from(T.featureStrategies); - - stopTimer(); - return rows.map(mapRow); - } - - async getAllFeatureStrategies(): Promise { - const rows = await this.db(T.featureStrategies).select(COLUMNS); - return rows.map(mapRow); - } - - async getStrategiesForEnvironment( - environment: string, - ): Promise { - const stopTimer = this.timer('getAll'); - const rows = await this.db - .select(COLUMNS) - .where({ environment }) - .from(T.featureStrategies); - - stopTimer(); - return rows.map(mapRow); - } - - async removeAllStrategiesForEnv( - feature_name: string, environment: string, ): Promise { 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 { const stopTimer = this.timer('getForFeature'); const rows = await this.db( T.featureStrategies, ).where({ - project_name, - feature_name, + project_name: projectId, + feature_name: featureName, environment, }); stopTimer(); return rows.map(mapRow); } - async getStrategiesForEnv( - environment: string, - ): Promise { - const stopTimer = this.timer('getStrategiesForEnv'); - const rows = await this.db( - T.featureStrategies, - ).where({ - environment, - }); - stopTimer(); - return rows.map(mapRow); - } - - async getFeatureToggleAdmin( + async getFeatureToggleWithEnvs( featureName: string, archived: boolean = false, ): Promise { @@ -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 { - const environments = [':global:']; - if (featureQuery?.environment) { - environments.push(featureQuery.environment); - } - const stopTimer = this.timer('getFeatureAdmin'); - let query = this.db('features') + ): Promise { + 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 { @@ -432,31 +367,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { return strategy; } - async getStrategiesAndMetadataForEnvironment( - environment: string, - featureName: string, - ): Promise { - 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, diff --git a/src/lib/db/feature-toggle-client-store.ts b/src/lib/db/feature-toggle-client-store.ts new file mode 100644 index 0000000000..d6115f5186 --- /dev/null +++ b/src/lib/db/feature-toggle-client-store.ts @@ -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 { + 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 { + return this.getAll(featureQuery, false, false); + } + + async getAdmin( + featureQuery?: IFeatureToggleQuery, + archived: boolean = false, + ): Promise { + return this.getAll(featureQuery, archived, true); + } +} + +module.exports = FeatureToggleClientStore; diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index 6d21397d7c..7d4414195f 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -62,14 +62,6 @@ export default class FeatureToggleStore implements IFeatureToggleStore { .then((res) => Number(res[0].count)); } - async getFeatureMetadata(name: string): Promise { - return this.db - .first(FEATURE_COLUMNS) - .from(TABLE) - .where({ name, archived: 0 }) - .then(this.rowToFeature); - } - async deleteAll(): Promise { 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 { + async getAll( + query: { + archived?: boolean; + project?: string; + stale?: boolean; + } = { archived: false }, + ): Promise { 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 rather than returning a { featureName, archived } object - * @param name - */ - async hasFeature(name: string): Promise { - 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 { 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 { + async setLastSeen(toggleNames: string[]): Promise { 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 { @@ -222,7 +200,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return undefined; } - async updateFeature( + async update( project: string, data: FeatureToggleDTO, ): Promise { @@ -233,7 +211,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return this.rowToFeature(row[0]); } - async archiveFeature(name: string): Promise { + async archive(name: string): Promise { 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 { + async revive(name: string): Promise { 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 { - const rows = await this.db(TABLE).where(params); - return rows.map(this.rowToFeature); - } - - async getFeaturesByInternal(params: { - archived?: boolean; - project?: string; - stale?: boolean; - }): Promise { - return this.db(TABLE).where(params); - } } module.exports = FeatureToggleStore; diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index d0f5b5f796..02cadfa0d0 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -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( diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts index 543877317c..840d48abd3 100644 --- a/src/lib/db/project-store.ts +++ b/src/lib/db/project-store.ts @@ -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 { @@ -179,58 +175,6 @@ class ProjectStore implements IProjectStore { return members; } - async getProjectOverview( - projectId: string, - archived: boolean = false, - ): Promise { - 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 { 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) { diff --git a/src/lib/routes/admin-api/environments-controller.ts b/src/lib/routes/admin-api/environments-controller.ts index 69944eaa41..55860c904a 100644 --- a/src/lib/routes/admin-api/environments-controller.ts +++ b/src/lib/routes/admin-api/environments-controller.ts @@ -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 { - 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, res: Response, ): Promise { - 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 { 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 { 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 { 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(); } } diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index b112067315..7d1fd94bea 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -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 { 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 { 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 { 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, diff --git a/src/lib/routes/admin-api/metrics.ts b/src/lib/routes/admin-api/metrics.ts index fd0aab6c0f..8e0e0b5c73 100644 --- a/src/lib/routes/admin-api/metrics.ts +++ b/src/lib/routes/admin-api/metrics.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { 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 { 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 { - 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 { 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; diff --git a/src/lib/routes/admin-api/project/environments.ts b/src/lib/routes/admin-api/project/environments.ts index da1b1d7ea0..a1bf75cb84 100644 --- a/src/lib/routes/admin-api/project/environments.ts +++ b/src/lib/routes/admin-api/project/environments.ts @@ -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 { 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 { 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(); } } diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/features.ts index 4f331f77fd..fb52dfacb4 100644 --- a/src/lib/routes/admin-api/project/features.ts +++ b/src/lib/routes/admin-api/project/features.ts @@ -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, res: Response, ): Promise { 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, res: Response, ): Promise { @@ -130,6 +111,64 @@ export default class ProjectFeaturesController extends Controller { res.status(201).json(created); } + async getFeature( + req: Request, + res: Response, + ): Promise { + const { featureName } = req.params; + const feature = await this.featureService.getFeature(featureName); + res.status(200).json(feature); + } + + async updateFeature( + req: Request, + res: Response, + ): Promise { + 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 { + 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 { + const { featureName } = req.params; + const userName = extractUsername(req); + await this.featureService.archiveToggle(featureName, userName); + res.status(202).send(); + } + async getEnvironment( req: Request, res: Response, @@ -143,21 +182,13 @@ export default class ProjectFeaturesController extends Controller { res.status(200).json(environmentInfo); } - async getFeature( - req: Request, - res: Response, - ): Promise { - const { featureName } = req.params; - const feature = await this.featureService.getFeature(featureName); - res.status(200).json(feature); - } - async toggleEnvironmentOn( req: Request, res: Response, ): Promise { - 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, res: Response, ): Promise { - 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, res: Response, ): Promise { @@ -194,7 +226,7 @@ export default class ProjectFeaturesController extends Controller { res.status(200).json(featureStrategy); } - async getFeatureStrategies( + async getStrategies( req: Request, res: Response, ): Promise { @@ -220,6 +252,21 @@ export default class ProjectFeaturesController extends Controller { res.status(200).json(updatedStrategy); } + async patchStrategy( + req: Request, + res: Response, + ): Promise { + 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, 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, + res: Response, + ): Promise { + 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 { + 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, + res: Response, + ): Promise { + this.logger.info('Getting strategy parameters'); + const { strategyId } = req.params; + const strategy = await this.featureService.getStrategy(strategyId); + res.status(200).json(strategy.parameters); + } } diff --git a/src/lib/routes/admin-api/project/health-report.ts b/src/lib/routes/admin-api/project/health-report.ts index 4048bd427d..83f22f25d9 100644 --- a/src/lib/routes/admin-api/project/health-report.ts +++ b/src/lib/routes/admin-api/project/health-report.ts @@ -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 { 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 { 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, + }); } } diff --git a/src/lib/routes/admin-api/tag-type.ts b/src/lib/routes/admin-api/tag-type.ts index dcdd561369..5fb0faf518 100644 --- a/src/lib/routes/admin-api/tag-type.ts +++ b/src/lib/routes/admin-api/tag-type.ts @@ -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 { - 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 { - 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 { 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 { 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 { 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 { 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; diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 2220933eae..2555160f22 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -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 { 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 { - 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 { 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 { 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(); } } diff --git a/src/lib/routes/client-api/feature.test.ts b/src/lib/routes/client-api/feature.test.ts index e4daa7291f..5b19b7fcd3 100644 --- a/src/lib/routes/client-api/feature.test.ts +++ b/src/lib/routes/client-api/feature.test.ts @@ -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', }); diff --git a/src/lib/routes/client-api/feature.ts b/src/lib/routes/client-api/feature.ts index 676d380674..b9003aad22 100644 --- a/src/lib/routes/client-api/feature.ts +++ b/src/lib/routes/client-api/feature.ts @@ -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 { - 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 { - 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 { - 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(); } } diff --git a/src/lib/routes/client-api/metrics.test.ts b/src/lib/routes/client-api/metrics.test.ts index e84e51d1cf..58cf59dee9 100644 --- a/src/lib/routes/client-api/metrics.test.ts +++ b/src/lib/routes/client-api/metrics.test.ts @@ -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=', () => { test('should set lastSeen on toggle', async () => { expect.assertions(1); - stores.featureToggleStore.createFeature('default', { + stores.featureToggleStore.create('default', { name: 'toggleLastSeen', }); await request diff --git a/src/lib/routes/client-api/metrics.ts b/src/lib/routes/client-api/metrics.ts index 7105002a5f..1732c9b78c 100644 --- a/src/lib/routes/client-api/metrics.ts +++ b/src/lib/routes/client-api/metrics.ts @@ -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(); } } diff --git a/src/lib/routes/client-api/register.ts b/src/lib/routes/client-api/register.ts index bd12e833fa..31f55356ca 100644 --- a/src/lib/routes/client-api/register.ts +++ b/src/lib/routes/client-api/register.ts @@ -24,18 +24,8 @@ export default class RegisterController extends Controller { async handleRegister(req: Request, res: Response): Promise { 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(); } } diff --git a/src/lib/routes/controller.ts b/src/lib/routes/controller.ts index 87406f5012..d18d977cb8 100644 --- a/src/lib/routes/controller.ts +++ b/src/lib/routes/controller.ts @@ -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, diff --git a/src/lib/routes/util.ts b/src/lib/routes/util.ts index 3d4164214b..9903b41aba 100644 --- a/src/lib/routes/util.ts +++ b/src/lib/routes/util.ts @@ -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': diff --git a/src/lib/schema/project-schema.ts b/src/lib/schema/project-schema.ts new file mode 100644 index 0000000000..4e09d50046 --- /dev/null +++ b/src/lib/schema/project-schema.ts @@ -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 }); diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index 64ff4c3f8d..54982a0715 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -231,7 +231,7 @@ export class AccessService { } async createDefaultProjectRoles( - owner: User, + owner: IUser, projectId: string, ): Promise { if (!projectId) { diff --git a/src/lib/services/client-metrics/index.ts b/src/lib/services/client-metrics/index.ts index 6991307996..493eaaf4fa 100644 --- a/src/lib/services/client-metrics/index.ts +++ b/src/lib/services/client-metrics/index.ts @@ -140,7 +140,7 @@ export default class ClientMetricsService { ): Promise { 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 { diff --git a/src/lib/services/environment-service.ts b/src/lib/services/environment-service.ts index 549169e625..7e16ce594b 100644 --- a/src/lib/services/environment-service.ts +++ b/src/lib/services/environment-service.ts @@ -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 { 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 { - await this.featureEnvironmentStore.disconnectEnvironmentFromProject( + await this.featureEnvironmentStore.disconnectFeatures( environment, projectId, ); - await this.environmentStore.disconnectProjectFromEnv( + await this.featureEnvironmentStore.disconnectProject( environment, projectId, ); diff --git a/src/lib/services/feature-toggle-service-v2.ts b/src/lib/services/feature-toggle-service-v2.ts index 5ba49f452f..e957bf1c94 100644 --- a/src/lib/services/feature-toggle-service-v2.ts +++ b/src/lib/services/feature-toggle-service-v2.ts @@ -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, @@ -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, - projectName: string, + projectId: string, featureName: string, environment: string = GLOBAL_ENV, ): Promise { 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, - ): Promise { - const exists = await this.featureStrategiesStore.exists(id); - if (exists) { - return this.featureStrategiesStore.updateStrategy(id, updates); + ): Promise { + 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 { + 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 { + return this.featureStrategiesStore.delete(id); + } + async getStrategiesForEnvironment( - projectName: string, + project: string, featureName: string, environment: string = GLOBAL_ENV, ): Promise { @@ -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 { - return this.featureStrategiesStore.getFeatureToggleAdmin( + return this.featureStrategiesStore.getFeatureToggleWithEnvs( featureName, archived, ); } + async getFeatureMetadata(featureName: string): Promise { + return this.featureToggleStore.get(featureName); + } + async getClientFeatures( query?: IFeatureToggleQuery, - archived: boolean = false, ): Promise { - 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 { - return this.featureStrategiesStore.getFeatures(query, archived, true); + return this.featureToggleClientStore.getAdmin(query, archived); + } + + async getFeatureOverview( + projectId: string, + archived: boolean = false, + ): Promise { + return this.featureStrategiesStore.getFeatureOverview( + projectId, + archived, + ); } async getFeatureToggle( featureName: string, ): Promise { - 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 { 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 { - 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 { 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 { - 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 { - 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 { - 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 { - const feature = await this.featureStrategiesStore.getFeatureToggleAdmin(featureName); - const globalEnv = feature.environments.find(e => e.name === GLOBAL_ENV); + async getFeatureToggleLegacy( + featureName: string, + ): Promise { + 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 { - 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 { - 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 { - return this.featureToggleStore.getFeatures(archived); + return this.featureToggleStore.getAll({ archived }); } async getProjectId(name: string): Promise { - const { project } = await this.featureToggleStore.getFeatureMetadata( - name, - ); - return project; + return this.featureToggleStore.getProjectId(name); } } diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 7952074492..e47e66ea81 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -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, diff --git a/src/lib/services/project-health-service.ts b/src/lib/services/project-health-service.ts index ae2f3b7d7b..b65ebee4ce 100644 --- a/src/lib/services/project-health-service.ts +++ b/src/lib/services/project-health-service.ts @@ -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, + 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 { 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 { - const toggles = await this.featureToggleStore.getFeaturesBy({ + const toggles = await this.featureToggleStore.getAll({ project: project.id, }); diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 99bdbd2fe0..f920794736 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -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 { const project = await this.store.get(projectId); - const features = await this.store.getProjectOverview( + const features = await this.featureToggleService.getFeatureOverview( projectId, archived, ); diff --git a/src/lib/services/state-service.test.ts b/src/lib/services/state-service.test.ts index d3241b09c8..511afcd278 100644 --- a/src/lib/services/state-service.test.ts +++ b/src/lib/services/state-service.test.ts @@ -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: {}, diff --git a/src/lib/services/state-service.ts b/src/lib/services/state-service.ts index 843ebe44a1..dd2f07caa9 100644 --- a/src/lib/services/state-service.ts +++ b/src/lib/services/state-service.ts @@ -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 { 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 { 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, diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 2a344b38b7..70f958dbf2 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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 { diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 1144eb0e8e..00709e4c4e 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -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; diff --git a/src/lib/types/stores/environment-store.ts b/src/lib/types/stores/environment-store.ts index 1c44cba221..fe2988db74 100644 --- a/src/lib/types/stores/environment-store.ts +++ b/src/lib/types/stores/environment-store.ts @@ -2,16 +2,5 @@ import { IEnvironment } from '../model'; import { Store } from './store'; export interface IEnvironmentStore extends Store { - exists(name: string): Promise; upsert(env: IEnvironment): Promise; - connectProject(environment: string, projectId: string): Promise; - connectFeatures(environment: string, projectId: string): Promise; - disconnectProjectFromEnv( - environment: string, - projectId: string, - ): Promise; - connectFeatureToEnvironmentsForProject( - featureName: string, - project_id: string, - ): Promise; } diff --git a/src/lib/types/stores/feature-environment-store.ts b/src/lib/types/stores/feature-environment-store.ts index f38942dd0e..79fef3ad13 100644 --- a/src/lib/types/stores/feature-environment-store.ts +++ b/src/lib/types/stores/feature-environment-store.ts @@ -8,7 +8,6 @@ export interface FeatureEnvironmentKey { export interface IFeatureEnvironmentStore extends Store { - getAllFeatureEnvironments(): Promise; featureHasEnvironment( environment: string, featureName: string, @@ -17,7 +16,7 @@ export interface IFeatureEnvironmentStore featureName: string, environment: string, ): Promise; - toggleEnvironmentEnabledStatus( + setEnvironmentEnabledStatus( environment: string, featureName: string, enabled: boolean, @@ -26,21 +25,24 @@ export interface IFeatureEnvironmentStore environment: string, featureName: string, ): Promise; - disconnectEnvironmentFromProject( - environment: string, - project: string, - ): Promise; removeEnvironmentForFeature( - feature_name: string, + featureName: string, environment: string, ): Promise; - connectEnvironmentAndFeature( - feature_name: string, + addEnvironmentToFeature( + featureName: string, environment: string, enabled: boolean, ): Promise; - enableEnvironmentForFeature( - feature_name: string, - environment: string, + + disconnectFeatures(environment: string, project: string): Promise; + connectFeatures(environment: string, projectId: string): Promise; + + connectFeatureToEnvironmentsForProject( + featureName: string, + projectId: string, ): Promise; + + connectProject(environment: string, projectId: string): Promise; + disconnectProject(environment: string, projectId: string): Promise; } diff --git a/src/lib/types/stores/feature-strategies-store.ts b/src/lib/types/stores/feature-strategies-store.ts index 86dd3e0ea8..4191c165d1 100644 --- a/src/lib/types/stores/feature-strategies-store.ts +++ b/src/lib/types/stores/feature-strategies-store.ts @@ -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 { - createStrategyConfig( + createStrategyFeatureEnv( strategyConfig: Omit, ): Promise; - getStrategiesForToggle(featureName: string): Promise; - getAllFeatureStrategies(): Promise; - getStrategiesForEnvironment( - environment: string, - ): Promise; - removeAllStrategiesForEnv( - feature_name: string, + removeAllStrategiesForFeatureEnv( + featureName: string, environment: string, ): Promise; - getAll(): Promise; - getStrategiesForFeature( - project_name: string, - feature_name: string, + getStrategiesForFeatureEnv( + projectId: string, + featureName: string, environment: string, ): Promise; - getStrategiesForEnv(environment: string): Promise; - getFeatureToggleAdmin( + getFeatureToggleWithEnvs( featureName: string, archived?: boolean, ): Promise; - getFeatures( - featureQuery: Partial, + getFeatureOverview( + projectId: string, archived: boolean, - isAdmin: boolean, - ): Promise; + ): Promise; getStrategyById(id: string): Promise; updateStrategy( id: string, updates: Partial, ): Promise; - getStrategiesAndMetadataForEnvironment( - environment: string, - featureName: string, - ): Promise; deleteConfigurationsForProjectAndEnvironment( projectId: String, environment: String, diff --git a/src/lib/types/stores/feature-toggle-client-store.ts b/src/lib/types/stores/feature-toggle-client-store.ts new file mode 100644 index 0000000000..7b74689495 --- /dev/null +++ b/src/lib/types/stores/feature-toggle-client-store.ts @@ -0,0 +1,13 @@ +import { IFeatureToggleClient, IFeatureToggleQuery } from '../model'; + +export interface IFeatureToggleClientStore { + getClient( + featureQuery: Partial, + ): Promise; + + // @Deprecated + getAdmin( + featureQuery: Partial, + archived: boolean, + ): Promise; +} diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index 89b74441ea..edbd732653 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -7,29 +7,13 @@ export interface IFeatureToggleQuery { stale: boolean; } -export interface IHasFeature { - name: string; - archived: boolean; -} - export interface IFeatureToggleStore extends Store { - count(query: Partial): Promise; - getFeatureMetadata(name: string): Promise; - getFeatures(archived: boolean): Promise; - hasFeature(name: string): Promise; - updateLastSeenForToggles(toggleNames: string[]): Promise; + count(query?: Partial): Promise; + setLastSeen(toggleNames: string[]): Promise; getProjectId(name: string): Promise; - createFeature( - project: string, - data: FeatureToggleDTO, - ): Promise; - updateFeature( - project: string, - data: FeatureToggleDTO, - ): Promise; - archiveFeature(featureName: string): Promise; - reviveFeature(featureName: string): Promise; - getFeaturesBy( - query: Partial, - ): Promise; + create(project: string, data: FeatureToggleDTO): Promise; + update(project: string, data: FeatureToggleDTO): Promise; + archive(featureName: string): Promise; + revive(featureName: string): Promise; + getAll(query?: Partial): Promise; } diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts index e217b51b86..81427a8e9b 100644 --- a/src/lib/types/stores/project-store.ts +++ b/src/lib/types/stores/project-store.ts @@ -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 { deleteEnvironmentForProject(id: string, environment: string): Promise; getEnvironmentsForProject(id: string): Promise; getMembers(projectId: string): Promise; - getProjectOverview( - projectId: string, - archived: boolean, - ): Promise; count(): Promise; } diff --git a/src/test/e2e/api/admin/archive.test.ts b/src/test/e2e/api/admin/archive.test.ts index 48d0b29d2d..4c94cdb733 100644 --- a/src/test/e2e/api/admin/archive.test.ts +++ b/src/test/e2e/api/admin/archive.test.ts @@ -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); diff --git a/src/test/e2e/api/admin/boostrap.test.ts b/src/test/e2e/api/admin/bootstrap.test.ts similarity index 95% rename from src/test/e2e/api/admin/boostrap.test.ts rename to src/test/e2e/api/admin/bootstrap.test.ts index 4a0c20e709..1db7dbbff6 100644 --- a/src/test/e2e/api/admin/boostrap.test.ts +++ b/src/test/e2e/api/admin/bootstrap.test.ts @@ -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); }); diff --git a/src/test/e2e/api/admin/feature.e2e.test.ts b/src/test/e2e/api/admin/feature.e2e.test.ts index f6e5c3f23d..6604cd585b 100644 --- a/src/test/e2e/api/admin/feature.e2e.test.ts +++ b/src/test/e2e/api/admin/feature.e2e.test.ts @@ -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 = 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') diff --git a/src/test/e2e/api/admin/project/environments.e2e.test.ts b/src/test/e2e/api/admin/project/environments.e2e.test.ts new file mode 100644 index 0000000000..0bc8a46997 --- /dev/null +++ b/src/test/e2e/api/admin/project/environments.e2e.test.ts @@ -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); +}); diff --git a/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts b/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts index df13b4d456..3168bbfa50 100644 --- a/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts +++ b/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts @@ -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); +}); diff --git a/src/test/e2e/api/client/feature.e2e.test.js b/src/test/e2e/api/client/feature.e2e.test.ts similarity index 64% rename from src/test/e2e/api/client/feature.e2e.test.js rename to src/test/e2e/api/client/feature.e2e.test.ts index 19abc276d6..bad7a2b62d 100644 --- a/src/test/e2e/api/client/feature.e2e.test.js +++ b/src/test/e2e/api/client/feature.e2e.test.ts @@ -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'); }); }); diff --git a/src/test/e2e/helpers/database-init.ts b/src/test/e2e/helpers/database-init.ts index dd11152465..50bb93a234 100644 --- a/src/test/e2e/helpers/database-init.ts +++ b/src/test/e2e/helpers/database-init.ts @@ -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 { +async function connectProject(store: IFeatureEnvironmentStore): Promise { 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; + destroy: () => Promise; } export default async function init( databaseSchema: String = 'test', getLogger: LogProvider = noLoggerProvider, -): Promise { +): Promise { const config = createTestConfig({ db: { ...dbConfig.getDb(), diff --git a/src/test/e2e/services/environment-service.test.ts b/src/test/e2e/services/environment-service.test.ts index a89db8c611..c2c636bbbd 100644 --- a/src/test/e2e/services/environment-service.test.ts +++ b/src/test/e2e/services/environment-service.test.ts @@ -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', diff --git a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts index d1a4a6707f..834bd76d23 100644 --- a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts +++ b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts @@ -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); diff --git a/src/test/e2e/services/project-health-service.e2e.test.ts b/src/test/e2e/services/project-health-service.e2e.test.ts index 30b332f663..af1e53e3b9 100644 --- a/src/test/e2e/services/project-health-service.e2e.test.ts +++ b/src/test/e2e/services/project-health-service.e2e.test.ts @@ -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, diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index 0ae0496d98..0062b8f741 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -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, diff --git a/src/test/e2e/stores/feature-tag-store.e2e.test.ts b/src/test/e2e/stores/feature-tag-store.e2e.test.ts index f739e7a771..961f576772 100644 --- a/src/test/e2e/stores/feature-tag-store.e2e.test.ts +++ b/src/test/e2e/stores/feature-tag-store.e2e.test.ts @@ -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([ diff --git a/src/test/fixtures/fake-environment-store.ts b/src/test/fixtures/fake-environment-store.ts index eca4c77d85..a14d7b6faa 100644 --- a/src/test/fixtures/fake-environment-store.ts +++ b/src/test/fixtures/fake-environment-store.ts @@ -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 { - 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 { - return Promise.reject(new Error('Not implemented')); - } - async delete(name: string): Promise { 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 { - 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 { - return Promise.reject(new Error('Not implemented')); - } - async deleteAll(): Promise { this.environments = []; } diff --git a/src/test/fixtures/fake-feature-environment-store.ts b/src/test/fixtures/fake-feature-environment-store.ts index baa56b5c87..013c89d55e 100644 --- a/src/test/fixtures/fake-feature-environment-store.ts +++ b/src/test/fixtures/fake-feature-environment-store.ts @@ -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 { - const fE = await this.get({ featureName, environment }); - fE.enabled = true; - } - async exists(key: FeatureEnvironmentKey): Promise { return this.featureEnvironments.some( (fE) => @@ -85,10 +77,6 @@ export default class FakeFeatureEnvironmentStore return this.featureEnvironments; } - async getAllFeatureEnvironments(): Promise { - 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 { + 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 { + 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 { + 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 { + return Promise.reject(new Error('Not implemented')); + } } diff --git a/src/test/fixtures/fake-feature-strategies-store.ts b/src/test/fixtures/fake-feature-strategies-store.ts index c8de423c13..23a07211d6 100644 --- a/src/test/fixtures/fake-feature-strategies-store.ts +++ b/src/test/fixtures/fake-feature-strategies-store.ts @@ -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, ): Promise { const newStrat = { ...strategyConfig, id: randomUUID() }; @@ -34,14 +34,6 @@ export default class FakeFeatureStrategiesStore return Promise.resolve(newStrat); } - async getStrategiesForToggle( - featureName: string, - ): Promise { - return this.featureStrategies.filter( - (fS) => fS.featureName === featureName, - ); - } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async createFeature(feature: any): Promise { this.featureToggles.push({ @@ -53,24 +45,11 @@ export default class FakeFeatureStrategiesStore return Promise.resolve(); } - async getAllFeatureStrategies(): Promise { - return this.featureStrategies; - } - async deleteFeatureStrategies(): Promise { this.featureStrategies = []; return Promise.resolve(); } - async getStrategiesForEnvironment( - environment: string, - ): Promise { - const stratEnvs = this.featureStrategies.filter( - (fS) => fS.environment === environment, - ); - return Promise.resolve(stratEnvs); - } - async hasStrategy(id: string): Promise { 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 { @@ -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 { 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 { - return this.featureStrategies.filter( - (fS) => fS.environment === environment, - ); - } - - async getFeatureToggleAdmin( + async getFeatureToggleWithEnvs( featureName: string, archived: boolean = false, ): Promise { @@ -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 { + 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 { - 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 { - 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 { return Promise.resolve(enabled); } - - async getAllFeatureEnvironments(): Promise { - return Promise.resolve([]); - } } module.exports = FakeFeatureStrategiesStore; diff --git a/src/test/fixtures/fake-feature-toggle-client-store.ts b/src/test/fixtures/fake-feature-toggle-client-store.ts new file mode 100644 index 0000000000..823b754ce3 --- /dev/null +++ b/src/test/fixtures/fake-feature-toggle-client-store.ts @@ -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 { + 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 { + return this.getFeatures(query); + } + + async getAdmin( + query?: IFeatureToggleQuery, + archived: boolean = false, + ): Promise { + return this.getFeatures(query, archived); + } + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + async createFeature(feature: any): Promise { + this.featureToggles.push({ + project: feature.project || 'default', + createdAt: new Date(), + archived: false, + ...feature, + }); + return Promise.resolve(); + } +} diff --git a/src/test/fixtures/fake-feature-toggle-store.ts b/src/test/fixtures/fake-feature-toggle-store.ts index dff486809b..9b3926e09e 100644 --- a/src/test/fixtures/fake-feature-toggle-store.ts +++ b/src/test/fixtures/fake-feature-toggle-store.ts @@ -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 { + async archive(featureName: string): Promise { 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 { @@ -88,30 +87,19 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { return this.get(name); } - async getFeatures(archived: boolean): Promise { - return this.features.filter((f) => f.archived === archived); - } - - async getFeaturesBy( - query: Partial, - ): Promise { + async getBy(query: Partial): Promise { return this.features.filter(this.getFilterQuery(query)); } - async hasFeature(featureName: string): Promise { - const { name, archived } = await this.get(featureName); - return { name, archived }; - } - - async reviveFeature(featureName: string): Promise { + async revive(featureName: string): Promise { 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 { @@ -127,7 +115,7 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { throw new NotFoundError('Could not find feature to update'); } - async updateLastSeenForToggles(toggleNames: string[]): Promise { + async setLastSeen(toggleNames: string[]): Promise { toggleNames.forEach((t) => { const toUpdate = this.features.find((f) => f.name === t); if (toUpdate) { diff --git a/src/test/fixtures/fake-project-store.ts b/src/test/fixtures/fake-project-store.ts index 93dea6f670..5a08f275a2 100644 --- a/src/test/fixtures/fake-project-store.ts +++ b/src/test/fixtures/fake-project-store.ts @@ -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 { - return Promise.resolve([]); - } - async hasProject(id: string): Promise { return this.exists(id); } diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index ccfb75d149..68e9007fbe 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -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(), diff --git a/websitev2/docs/api/admin/feature-toggles-api-v2.md b/websitev2/docs/api/admin/feature-toggles-api-v2.md new file mode 100644 index 0000000000..2ccf0e4e01 --- /dev/null +++ b/websitev2/docs/api/admin/feature-toggles-api-v2.md @@ -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. \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4177c15224..f454027580 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"