unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
1,024 lines (1,023 loc) • 60.9 kB
JavaScript
import { CREATE_FEATURE_STRATEGY, EnvironmentVariantEvent, FeatureArchivedEvent, FeatureChangeProjectEvent, FeatureCreatedEvent, FeatureDeletedEvent, FeatureEnvironmentEvent, FeatureMetadataUpdateEvent, FeatureRevivedEvent, FeatureStaleEvent, FeatureStrategyAddEvent, FeatureStrategyRemoveEvent, FeatureStrategyUpdateEvent, PotentiallyStaleOnEvent, SKIP_CHANGE_REQUEST, StrategiesOrderChangedEvent, SYSTEM_USER_AUDIT, WeightType, } from '../../types/index.js';
import { ForbiddenError, FOREIGN_KEY_VIOLATION, OperationDeniedError, PatternError, PermissionError, BadDataError, NameExistsError, InvalidOperationError, } from '../../error/index.js';
import { constraintSchema, featureMetadataSchema, nameSchema, variantsArraySchema, } from '../../schema/feature-schema.js';
import NotFoundError from '../../error/notfound-error.js';
import { DATE_OPERATORS, DEFAULT_ENV, NUM_OPERATORS, SEMVER_OPERATORS, STRING_OPERATORS, } from '../../util/index.js';
import fastJsonPatch from 'fast-json-patch';
const { applyPatch, deepClone } = fastJsonPatch;
import { validateDate, validateLegalValues, validateNumber, validateSemver, validateString, } from '../../util/validators/constraint-types.js';
import { getDefaultStrategy, getProjectDefaultStrategy, } from '../playground/feature-evaluator/helpers.js';
import { unique } from '../../util/unique.js';
import { checkFeatureFlagNamesAgainstPattern } from '../feature-naming-pattern/feature-naming-validation.js';
import ArchivedFeatureError from '../../error/archivedfeature-error.js';
import { FEATURES_CREATED_BY_PROCESSED } from '../../metric-events.js';
import { allSettledWithRejection } from '../../util/allSettledWithRejection.js';
import { throwExceedsLimitError } from '../../error/exceeds-limit-error.js';
import { sortStrategies } from '../../util/sortStrategies.js';
const oneOf = (values, match) => {
return values.some((value) => value === match);
};
export class FeatureToggleService {
constructor({ featureStrategiesStore, featureToggleStore, clientFeatureToggleStore, projectStore, featureTagStore, featureEnvironmentStore, contextFieldStore, strategyStore, }, { getLogger, flagResolver, eventBus }, { segmentService, accessService, eventService, changeRequestAccessReadModel, dependentFeaturesReadModel, dependentFeaturesService, featureLifecycleReadModel, featureCollaboratorsReadModel, featureLinksReadModel, featureLinkService, resourceLimitsService, }) {
this.logger = getLogger('services/feature-toggle-service.ts');
this.featureStrategiesStore = featureStrategiesStore;
this.strategyStore = strategyStore;
this.featureToggleStore = featureToggleStore;
this.clientFeatureToggleStore = clientFeatureToggleStore;
this.tagStore = featureTagStore;
this.projectStore = projectStore;
this.featureEnvironmentStore = featureEnvironmentStore;
this.contextFieldStore = contextFieldStore;
this.segmentService = segmentService;
this.accessService = accessService;
this.eventService = eventService;
this.flagResolver = flagResolver;
this.changeRequestAccessReadModel = changeRequestAccessReadModel;
this.dependentFeaturesReadModel = dependentFeaturesReadModel;
this.dependentFeaturesService = dependentFeaturesService;
this.featureLifecycleReadModel = featureLifecycleReadModel;
this.featureCollaboratorsReadModel = featureCollaboratorsReadModel;
this.featureLinksReadModel = featureLinksReadModel;
this.featureLinkService = featureLinkService;
this.eventBus = eventBus;
this.resourceLimitsService = resourceLimitsService;
}
async validateFeaturesContext(featureNames, projectId) {
const features = await this.featureToggleStore.getAllByNames(featureNames);
const invalidProjects = unique(features
.map((feature) => feature.project)
.filter((project) => project !== projectId));
if (invalidProjects.length > 0) {
throw new InvalidOperationError(`The operation could not be completed. The features exist, but the provided project ids ("${invalidProjects.join(',')}") does not match the project provided in request URL ("${projectId}").`);
}
}
async validateFeatureBelongsToProject({ featureName, projectId, }) {
const id = await this.featureToggleStore.getProjectId(featureName);
if (id !== projectId) {
throw new NotFoundError(`There's no feature named "${featureName}" in project "${projectId}"${id === undefined
? '.'
: `, but there's a feature with that name in project "${id}"`}`);
}
}
async validateFeatureIsNotArchived(featureName, _project) {
const toggle = await this.featureToggleStore.get(featureName);
if (toggle === undefined) {
throw new NotFoundError(`Could not find feature ${featureName}`);
}
if (toggle.archived || Boolean(toggle.archivedAt)) {
throw new ArchivedFeatureError();
}
}
async validateNoChildren(featureName) {
const children = await this.dependentFeaturesReadModel.getChildren([
featureName,
]);
if (children.length > 0) {
throw new InvalidOperationError('You can not archive/delete this feature since other features depend on it.');
}
}
async validateNoOrphanParents(featureNames) {
if (featureNames.length === 0)
return;
const parents = await this.dependentFeaturesReadModel.getOrphanParents(featureNames);
if (parents.length > 0) {
throw new InvalidOperationError(featureNames.length > 1
? `You can not archive/delete those features since other features depend on them.`
: `You can not archive/delete this feature since other features depend on it.`);
}
}
validateUpdatedProperties({ featureName, projectId }, existingStrategy) {
if (existingStrategy.projectId !== projectId) {
throw new InvalidOperationError('You can not change the projectId for an activation strategy.');
}
if (existingStrategy.featureName !== featureName) {
throw new InvalidOperationError('You can not change the featureName for an activation strategy.');
}
}
async validateProjectCanAccessSegments(projectId, segmentIds) {
if (segmentIds && segmentIds.length > 0) {
await Promise.all(segmentIds.map((segmentId) => this.segmentService.get(segmentId))).then((segments) => {
const mismatchedSegments = segments.filter((segment) => segment?.project && segment.project !== projectId);
if (mismatchedSegments.length > 0) {
throw new BadDataError(`The segments ${mismatchedSegments.map((s) => `${s.name} with id ${s.id}`).join(',')} does not belong to project "${projectId}"`);
}
});
}
}
async validateStrategyLimit(featureEnv) {
const { featureEnvironmentStrategies: limit } = await this.resourceLimitsService.getResourceLimits();
const existingCount = (await this.featureStrategiesStore.getStrategiesForFeatureEnv(featureEnv.projectId, featureEnv.featureName, featureEnv.environment)).length;
if (existingCount >= limit) {
throwExceedsLimitError(this.eventBus, {
resource: 'strategy',
limit,
});
}
}
async validateConstraintsLimit(constraints) {
const { constraints: constraintsLimit, constraintValues: constraintValuesLimit, } = await this.resourceLimitsService.getResourceLimits();
if (constraints.updated.length > constraintsLimit &&
constraints.updated.length > constraints.existing.length) {
throwExceedsLimitError(this.eventBus, {
resource: 'constraints',
limit: constraintsLimit,
});
}
const isSameLength = constraints.existing.length === constraints.updated.length;
const constraintOverLimit = constraints.updated.find((constraint, i) => {
const updatedCount = constraint.values?.length ?? 0;
const existingCount = constraints.existing[i]?.values?.length ?? 0;
const isOverLimit = Array.isArray(constraint.values) &&
updatedCount > constraintValuesLimit;
const allowAnyway = isSameLength && existingCount >= updatedCount;
return isOverLimit && !allowAnyway;
});
if (constraintOverLimit) {
throwExceedsLimitError(this.eventBus, {
resource: `constraint values for ${constraintOverLimit.contextName}`,
limit: constraintValuesLimit,
resourceNameOverride: 'constraint values',
});
}
}
async validateStrategyType(strategyName) {
if (strategyName !== undefined) {
const exists = await this.strategyStore.exists(strategyName);
if (!exists) {
throw new BadDataError(`Could not find strategy type with name ${strategyName}`);
}
}
}
async validateConstraints(constraints) {
const validations = constraints.map((constraint) => {
return this.validateConstraint(constraint);
});
return Promise.all(validations);
}
async validateConstraint(input) {
const constraint = await constraintSchema.validateAsync(input);
const { operator } = constraint;
if (oneOf(NUM_OPERATORS, operator)) {
await validateNumber(constraint.value);
}
if (oneOf(STRING_OPERATORS, operator)) {
await validateString(constraint.values);
}
if (oneOf(SEMVER_OPERATORS, operator)) {
// Semver library is not asynchronous, so we do not
// need to await here.
validateSemver(constraint.value);
}
if (oneOf(DATE_OPERATORS, operator)) {
await validateDate(constraint.value);
}
if (await this.contextFieldStore.exists(constraint.contextName)) {
const contextDefinition = await this.contextFieldStore.get(constraint.contextName);
if (contextDefinition?.legalValues &&
contextDefinition.legalValues.length > 0) {
const valuesToValidate = oneOf([...DATE_OPERATORS, ...SEMVER_OPERATORS, ...NUM_OPERATORS], operator)
? constraint.value
: constraint.values;
validateLegalValues(contextDefinition.legalValues, valuesToValidate);
}
}
return constraint;
}
async patchFeature(project, featureName, operations, auditUser) {
const featureToggle = await this.getFeatureMetadata(featureName);
if (operations.some((op) => op.path.indexOf('/variants') >= 0)) {
throw new OperationDeniedError(`Changing variants is done via PATCH operation to /api/admin/projects/:project/features/:feature/variants`);
}
const { newDocument } = applyPatch(deepClone(featureToggle), operations);
const updated = await this.updateFeatureToggle(project, newDocument, featureName, auditUser);
if (featureToggle.stale !== newDocument.stale) {
await this.eventService.storeEvent(new FeatureStaleEvent({
stale: newDocument.stale,
project,
featureName,
auditUser,
}));
}
return updated;
}
featureStrategyToPublic(featureStrategy, segments = []) {
const result = {
id: featureStrategy.id,
name: featureStrategy.strategyName,
title: featureStrategy.title,
disabled: featureStrategy.disabled,
constraints: featureStrategy.constraints || [],
parameters: featureStrategy.parameters,
variants: featureStrategy.variants || [],
sortOrder: featureStrategy.sortOrder,
segments: segments.map((segment) => segment.id) ?? [],
};
return result;
}
async updateStrategiesSortOrder(context, sortOrders, auditUser, user) {
await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user);
return this.unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser);
}
async unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser) {
const { featureName, environment, projectId: project } = context;
const existingOrder = (await this.getStrategiesForEnvironment(project, featureName, environment))
.sort(sortStrategies)
.map((strategy) => strategy.id);
const eventPreData = { strategyIds: existingOrder };
await Promise.all(sortOrders.map(({ id, sortOrder }) => this.featureStrategiesStore.updateSortOrder(id, sortOrder)));
const newOrder = (await this.getStrategiesForEnvironment(project, featureName, environment))
.sort(sortStrategies)
.map((strategy) => strategy.id);
const eventData = { strategyIds: newOrder };
const event = new StrategiesOrderChangedEvent({
featureName,
environment,
project,
preData: eventPreData,
data: eventData,
auditUser,
});
await this.eventService.storeEvent(event);
}
async createStrategy(strategyConfig, context, auditUser, user) {
await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user);
return this.unprotectedCreateStrategy(strategyConfig, context, auditUser);
}
async parametersWithDefaults(projectId, featureName, strategyName, params) {
if (strategyName === 'flexibleRollout') {
const stickiness = !params?.stickiness || params?.stickiness === ''
? await this.featureStrategiesStore.getDefaultStickiness(projectId)
: params?.stickiness;
return {
...params,
rollout: params?.rollout ?? '100',
stickiness,
groupId: params?.groupId ?? featureName,
};
}
else {
// We don't really have good defaults for the other kinds of known strategies, so return an empty map.
return params ?? {};
}
}
async standardizeStrategyConfig(projectId, featureName, strategyConfig, existing) {
const { name, title, disabled, sortOrder } = strategyConfig;
let { constraints, parameters, variants } = strategyConfig;
if (constraints && constraints.length > 0) {
await this.validateConstraintsLimit({
updated: constraints,
existing: existing?.constraints ?? [],
});
constraints = await this.validateConstraints(constraints);
}
parameters = await this.parametersWithDefaults(projectId, featureName, name, strategyConfig.parameters);
if (variants && variants.length > 0) {
await variantsArraySchema.validateAsync(variants);
const fixedVariants = this.fixVariantWeights(variants);
variants = fixedVariants;
}
return {
name,
title,
disabled,
sortOrder,
constraints,
variants,
parameters,
};
}
async unprotectedCreateStrategy(strategyConfig, context, auditUser) {
const { featureName, projectId, environment } = context;
await this.validateFeatureBelongsToProject(context);
await this.validateStrategyType(strategyConfig.name);
await this.validateProjectCanAccessSegments(projectId, strategyConfig.segments);
const standardizedConfig = await this.standardizeStrategyConfig(projectId, featureName, strategyConfig);
await this.validateStrategyLimit({
featureName,
projectId,
environment,
});
try {
const newFeatureStrategy = await this.featureStrategiesStore.createStrategyFeatureEnv({
...standardizedConfig,
strategyName: standardizedConfig.name,
constraints: standardizedConfig.constraints || [],
variants: standardizedConfig.variants || [],
parameters: standardizedConfig.parameters || {},
projectId,
featureName,
environment,
});
if (strategyConfig.segments &&
Array.isArray(strategyConfig.segments)) {
await this.segmentService.updateStrategySegments(newFeatureStrategy.id, strategyConfig.segments);
}
const segments = await this.segmentService.getByStrategy(newFeatureStrategy.id);
const strategy = this.featureStrategyToPublic(newFeatureStrategy, segments);
await this.eventService.storeEvent(new FeatureStrategyAddEvent({
project: projectId,
featureName,
environment,
data: strategy,
auditUser,
}));
return strategy;
}
catch (e) {
if (e.code === FOREIGN_KEY_VIOLATION) {
throw new BadDataError('You have not added the current environment to the project');
}
throw e;
}
}
/**
* PUT /api/admin/projects/:projectId/features/:featureName/strategies/:strategyId ?
* {
*
* }
* @param id
* @param updates
* @param context - Which context does this strategy live in (projectId, featureName, environment)
* @param auditUser - Audit info about the user performing the update
* @param user - Optional User object performing the action
*/
async updateStrategy(id, updates, context, auditUser, user) {
await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user);
return this.unprotectedUpdateStrategy(id, updates, context, auditUser, user);
}
async optionallyDisableFeature(featureName, environment, projectId, auditUser, user) {
const strategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(projectId, featureName, environment);
const hasOnlyDisabledStrategies = strategies.every((strategy) => strategy.disabled);
if (hasOnlyDisabledStrategies) {
await this.unprotectedUpdateEnabled(projectId, featureName, environment, false, auditUser, user);
}
}
async unprotectedUpdateStrategy(id, updates, context, auditUser, user) {
const { projectId, environment, featureName } = context;
const existingStrategy = await this.featureStrategiesStore.get(id);
if (existingStrategy === undefined) {
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
this.validateUpdatedProperties(context, existingStrategy);
await this.validateStrategyType(updates.name);
await this.validateProjectCanAccessSegments(projectId, updates.segments);
const existingSegments = await this.segmentService.getByStrategy(id);
if (existingStrategy.id === id) {
const standardizedUpdates = await this.standardizeStrategyConfig(projectId, featureName, { ...updates, name: updates.name }, existingStrategy);
const strategy = await this.featureStrategiesStore.updateStrategy(id, standardizedUpdates);
if (updates.segments && Array.isArray(updates.segments)) {
await this.segmentService.updateStrategySegments(strategy.id, updates.segments);
}
const segments = await this.segmentService.getByStrategy(strategy.id);
// Store event!
const data = this.featureStrategyToPublic(strategy, segments);
const preData = this.featureStrategyToPublic(existingStrategy, existingSegments);
await this.eventService.storeEvent(new FeatureStrategyUpdateEvent({
project: projectId,
featureName,
environment,
data,
preData,
auditUser,
}));
await this.optionallyDisableFeature(featureName, environment, projectId, auditUser, user);
return data;
}
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
async updateStrategyParameter(id, name, value, context, auditUser) {
const { projectId, environment, featureName } = context;
const existingStrategy = await this.featureStrategiesStore.get(id);
if (existingStrategy === undefined) {
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
this.validateUpdatedProperties(context, existingStrategy);
if (existingStrategy.id === id) {
existingStrategy.parameters[name] = String(value);
const existingSegments = await this.segmentService.getByStrategy(id);
const strategy = await this.featureStrategiesStore.updateStrategy(id, existingStrategy);
const segments = await this.segmentService.getByStrategy(strategy.id);
const data = this.featureStrategyToPublic(strategy, segments);
const preData = this.featureStrategyToPublic(existingStrategy, existingSegments);
await this.eventService.storeEvent(new FeatureStrategyUpdateEvent({
featureName,
project: projectId,
environment,
data,
preData,
auditUser,
}));
return data;
}
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
/**
* DELETE /api/admin/projects/:projectId/features/:featureName/environments/:environmentName/strategies/:strategyId
* {
*
* }
* @param id - strategy id
* @param context - Which context does this strategy live in (projectId, featureName, environment)
* @param auditUser - Audit information about user performing the action (userid, username, ip)
* @param user
*/
async deleteStrategy(id, context, auditUser, user) {
await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user);
return this.unprotectedDeleteStrategy(id, context, auditUser);
}
async unprotectedDeleteStrategy(id, context, auditUser) {
const existingStrategy = await this.featureStrategiesStore.get(id);
if (!existingStrategy) {
// If the strategy doesn't exist, do nothing.
return;
}
const { featureName, projectId, environment } = context;
this.validateUpdatedProperties(context, existingStrategy);
await this.featureStrategiesStore.delete(id);
// Disable the feature in the environment if it only has disabled strategies
await this.optionallyDisableFeature(featureName, environment, projectId, auditUser);
const preData = this.featureStrategyToPublic(existingStrategy);
await this.eventService.storeEvent(new FeatureStrategyRemoveEvent({
featureName,
project: projectId,
environment,
auditUser,
preData,
}));
// If there are no strategies left for environment disable it
await this.featureEnvironmentStore.disableEnvironmentIfNoStrategies(featureName, environment);
}
async getStrategiesForEnvironment(project, featureName, environment = DEFAULT_ENV) {
this.logger.debug('getStrategiesForEnvironment');
const hasEnv = await this.featureEnvironmentStore.featureHasEnvironment(environment, featureName);
if (hasEnv) {
const featureStrategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(project, featureName, environment);
const result = [];
for (const strat of featureStrategies) {
const segments = (await this.segmentService.getByStrategy(strat.id)).map((segment) => segment.id) ?? [];
result.push({
id: strat.id,
name: strat.strategyName,
constraints: strat.constraints,
parameters: strat.parameters,
variants: strat.variants,
title: strat.title,
disabled: strat.disabled,
sortOrder: strat.sortOrder,
milestoneId: strat.milestoneId,
segments,
});
}
return result;
}
throw new NotFoundError(`Feature ${featureName} does not have environment ${environment}`);
}
/**
* GET /api/admin/projects/:project/features/:featureName
* @param featureName
* @param archived - return archived or non archived toggles
* @param projectId - provide if you're requesting the feature in the context of a specific project.
* @param userId
*/
async getFeature({ featureName, archived, projectId, environmentVariants, userId, }) {
if (projectId) {
await this.validateFeatureBelongsToProject({
featureName,
projectId,
});
}
let dependencies = [];
let children = [];
let lifecycle;
let collaborators = [];
let links = [];
[dependencies, children, lifecycle, collaborators, links] =
await Promise.all([
this.dependentFeaturesReadModel.getParents(featureName),
this.dependentFeaturesReadModel.getChildren([featureName]),
this.featureLifecycleReadModel.findCurrentStage(featureName),
this.featureCollaboratorsReadModel.getFeatureCollaborators(featureName),
this.featureLinksReadModel.getLinks(featureName),
]);
if (environmentVariants) {
const result = await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(featureName, userId, archived);
return {
...result,
dependencies,
children,
lifecycle,
links: links.map((link) => ({
id: link.id,
url: link.url,
title: link.title ?? null,
})),
collaborators: { users: collaborators },
};
}
else {
const result = await this.featureStrategiesStore.getFeatureToggleWithEnvs(featureName, userId, archived);
return {
...result,
dependencies,
children,
lifecycle,
links,
collaborators: { users: collaborators },
};
}
}
async getVariantsForEnv(featureName, environment) {
const featureEnvironment = await this.featureEnvironmentStore.get({
featureName,
environment,
});
return featureEnvironment?.variants || [];
}
async getFeatureMetadata(featureName) {
const metaData = await this.featureToggleStore.get(featureName);
if (metaData === undefined) {
throw new NotFoundError(`Could find metadata for feature with name ${featureName}`);
}
return metaData;
}
async getClientFeatures(query) {
const result = await this.clientFeatureToggleStore.getFrontendApiClient(query || {});
return result.map(({ name, type, enabled, project, stale, strategies, variants, description, impressionData, dependencies, }) => ({
name,
type,
enabled,
project,
stale: stale || false,
strategies,
variants,
description,
impressionData,
dependencies,
}));
}
async getPlaygroundFeatures(query) {
const features = await this.featureToggleStore.getPlaygroundFeatures(query);
return features;
}
async getFeatureOverview(params) {
return this.featureStrategiesStore.getFeatureOverview(params);
}
async getFeatureTypeCounts(params) {
return this.featureToggleStore.getFeatureTypeCounts(params);
}
async getFeatureToggle(featureName) {
return this.featureStrategiesStore.getFeatureToggleWithEnvs(featureName);
}
async validateFeatureFlagLimit() {
const currentFlagCount = await this.featureToggleStore.count();
const { featureFlags: limit } = await this.resourceLimitsService.getResourceLimits();
if (currentFlagCount >= limit) {
throwExceedsLimitError(this.eventBus, {
resource: 'feature flag',
limit,
});
}
}
async validateActiveProject(projectId) {
const hasActiveProject = await this.projectStore.hasActiveProject(projectId);
if (!hasActiveProject) {
throw new NotFoundError(`Active project with id ${projectId} does not exist`);
}
}
async createFeatureToggle(projectId, value, auditUser, isValidated = false) {
this.logger.info(`${auditUser.username} creates feature flag ${value.name}`);
await this.validateName(value.name);
await this.validateFeatureFlagNameAgainstPattern(value.name, projectId);
const projectExists = await this.projectStore.hasActiveProject(projectId);
if (await this.projectStore.isFeatureLimitReached(projectId)) {
throw new InvalidOperationError('You have reached the maximum number of feature flags for this project.');
}
await this.validateFeatureFlagLimit();
if (projectExists) {
let featureData;
if (isValidated) {
featureData = { createdByUserId: auditUser.id, ...value };
}
else {
const validated = await featureMetadataSchema.validateAsync(value);
featureData = {
createdByUserId: auditUser.id,
...validated,
};
}
const featureName = featureData.name;
const createdToggle = await this.featureToggleStore.create(projectId, featureData);
await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(featureName, projectId);
if (value.variants && value.variants.length > 0) {
const environments = await this.featureEnvironmentStore.getEnvironmentsForFeature(featureName);
await this.featureEnvironmentStore.setVariantsToFeatureEnvironments(featureName, environments.map((env) => env.environment), value.variants);
}
if (value.tags && value.tags.length > 0) {
const mapTagsToFeatureTagInserts = value.tags.map((tag) => ({
tagValue: tag.value,
tagType: tag.type,
createdByUserId: auditUser.id,
featureName: featureName,
}));
await this.tagStore.tagFeatures(mapTagsToFeatureTagInserts);
}
await this.eventService.storeEvent(new FeatureCreatedEvent({
featureName,
project: projectId,
data: createdToggle,
auditUser,
}));
await this.addLinksFromTemplates(projectId, featureName, auditUser);
return createdToggle;
}
throw new NotFoundError(`Active project with id ${projectId} does not exist`);
}
async checkFeatureFlagNamesAgainstProjectPattern(projectId, featureNames) {
try {
const project = await this.projectStore.get(projectId);
if (project === undefined) {
throw new NotFoundError(`Could not find project with id: ${projectId}`);
}
const patternData = project.featureNaming;
const namingPattern = patternData?.pattern;
if (namingPattern) {
const result = checkFeatureFlagNamesAgainstPattern(featureNames, namingPattern);
if (result.state === 'invalid') {
return {
...result,
featureNaming: patternData,
};
}
}
}
catch (error) {
// the project doesn't exist, so there's nothing to
// validate against
this.logger.info("Got an error when trying to validate flag naming patterns. It is probably because the target project doesn't exist. Here's the error:", error.message);
return { state: 'valid' };
}
return { state: 'valid' };
}
async validateFeatureFlagNameAgainstPattern(featureName, projectId) {
if (projectId) {
const result = await this.checkFeatureFlagNamesAgainstProjectPattern(projectId, [featureName]);
if (result.state === 'invalid') {
const namingPattern = result.featureNaming.pattern;
const namingExample = result.featureNaming.example;
const namingDescription = result.featureNaming.description;
const error = `The feature flag name "${featureName}" does not match the project's naming pattern: "${namingPattern}".`;
const example = namingExample
? ` Here's an example of a name that does match the pattern: "${namingExample}"."`
: '';
const description = namingDescription
? ` The pattern's description is: "${namingDescription}"`
: '';
throw new PatternError(`${error}${example}${description}`, [
`The flag name does not match the pattern.`,
]);
}
}
}
async cloneFeatureToggle(featureName, projectId, newFeatureName, auditUser, replaceGroupId = true) {
const changeRequestEnabled = await this.changeRequestAccessReadModel.isChangeRequestsEnabledForProject(projectId);
if (changeRequestEnabled) {
throw new ForbiddenError(`Cloning not allowed. Project ${projectId} has change requests enabled.`);
}
this.logger.info(`${auditUser.username} clones feature flag ${featureName} to ${newFeatureName}`);
await this.validateName(newFeatureName);
const cToggle = await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(featureName);
const newToggle = {
...cToggle,
name: newFeatureName,
variants: undefined,
createdAt: undefined,
};
const created = await this.createFeatureToggle(projectId, newToggle, auditUser);
const variantTasks = newToggle.environments.map((e) => {
return this.featureEnvironmentStore.addVariantsToFeatureEnvironment(newToggle.name, e.name, e.variants);
});
const strategyTasks = newToggle.environments.flatMap((e) => e.strategies.map((s) => {
if (replaceGroupId &&
s.parameters &&
s.parameters.hasOwnProperty('groupId')) {
s.parameters.groupId = newFeatureName;
}
const context = {
projectId,
featureName: newFeatureName,
environment: e.name,
};
return this.createStrategy(s, context, auditUser);
}));
const cloneDependencies = this.dependentFeaturesService.cloneDependencies({
featureName,
newFeatureName,
projectId,
}, auditUser);
await Promise.all([
...strategyTasks,
...variantTasks,
cloneDependencies,
]);
return created;
}
async updateFeatureToggle(projectId, updatedFeature, featureName, auditUser) {
await this.validateFeatureBelongsToProject({
featureName,
projectId,
});
this.logger.info(`${auditUser.username} updates feature flag ${featureName}`);
const featureData = await featureMetadataSchema.validateAsync(updatedFeature);
const preData = await this.featureToggleStore.get(featureName);
const featureToggle = await this.featureToggleStore.update(projectId, {
...featureData,
name: featureName,
});
if (preData === undefined) {
throw new NotFoundError(`Could find feature toggle with name ${featureName}`);
}
await this.eventService.storeEvent(new FeatureMetadataUpdateEvent({
auditUser,
data: featureToggle,
preData,
featureName,
project: projectId,
}));
return featureToggle;
}
async getFeatureCountForProject(projectId) {
return this.featureToggleStore.count({
archived: false,
project: projectId,
});
}
async removeAllStrategiesForEnv(toggleName, environment = DEFAULT_ENV) {
await this.featureStrategiesStore.removeAllStrategiesForFeatureEnv(toggleName, environment);
}
async getStrategy(strategyId) {
const strategy = await this.featureStrategiesStore.getStrategyById(strategyId);
const segments = await this.segmentService.getByStrategy(strategyId);
let result = {
id: strategy.id,
name: strategy.strategyName,
constraints: strategy.constraints || [],
parameters: strategy.parameters,
variants: strategy.variants || [],
segments: [],
title: strategy.title,
disabled: strategy.disabled,
sortOrder: strategy.sortOrder,
};
if (segments && segments.length > 0) {
result = {
...result,
segments: segments.map((segment) => segment.id),
};
}
return result;
}
async getEnvironmentInfo(project, environment, featureName) {
const envMetadata = await this.featureEnvironmentStore.getEnvironmentMetaData(environment, featureName);
const strategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(project, featureName, environment);
const defaultStrategy = await this.projectStore.getDefaultStrategy(project, environment);
return {
name: featureName,
environment,
enabled: envMetadata.enabled,
strategies,
defaultStrategy,
};
}
// todo: store events for this change.
async deleteEnvironment(projectId, environment) {
await this.featureStrategiesStore.deleteConfigurationsForProjectAndEnvironment(projectId, environment);
await this.projectStore.deleteEnvironmentForProject(projectId, environment);
}
/** Validations */
async validateName(name) {
await nameSchema.validateAsync({ name });
await this.validateUniqueFeatureName(name);
return name;
}
async validateUniqueFeatureName(name) {
let msg;
try {
const feature = await this.featureToggleStore.get(name);
if (feature === undefined) {
return;
}
msg = feature.archived
? 'An archived flag with that name already exists'
: 'A flag with that name already exists';
}
catch (_error) {
return;
}
throw new NameExistsError(msg);
}
async hasFeature(name) {
return this.featureToggleStore.exists(name);
}
async updateStale(featureName, isStale, auditUser) {
const feature = await this.featureToggleStore.get(featureName);
if (feature === undefined) {
throw new NotFoundError(`Could not find feature with name: ${featureName}`);
}
const { project } = feature;
feature.stale = isStale;
await this.featureToggleStore.update(project, feature);
await this.eventService.storeEvent(new FeatureStaleEvent({
stale: isStale,
project,
featureName,
auditUser,
}));
return feature;
}
async archiveToggle(featureName, user, auditUser, projectId) {
if (projectId) {
await this.stopWhenChangeRequestsEnabled(projectId, undefined, user);
}
await this.unprotectedArchiveToggle(featureName, auditUser, projectId);
}
async unprotectedArchiveToggle(featureName, auditUser, projectId) {
const feature = await this.featureToggleStore.get(featureName);
if (feature === undefined) {
throw new NotFoundError(`Could not find feature with name ${featureName}`);
}
if (projectId) {
await this.validateFeatureBelongsToProject({
featureName,
projectId,
});
await this.validateNoOrphanParents([featureName]);
}
await this.validateNoChildren(featureName);
await this.featureToggleStore.archive(featureName);
if (projectId) {
await this.dependentFeaturesService.unprotectedDeleteFeaturesDependencies([featureName], projectId, auditUser);
}
await this.eventService.storeEvent(new FeatureArchivedEvent({
featureName,
auditUser,
project: feature.project,
}));
}
async archiveToggles(featureNames, user, auditUser, projectId) {
await this.stopWhenChangeRequestsEnabled(projectId, undefined, user);
await this.unprotectedArchiveToggles(featureNames, projectId, auditUser);
}
async validateArchiveToggles(featureNames) {
const hasDeletedDependencies = await this.dependentFeaturesReadModel.haveDependencies(featureNames);
const parentsWithChildFeatures = await this.dependentFeaturesReadModel.getOrphanParents(featureNames);
return {
hasDeletedDependencies,
parentsWithChildFeatures,
};
}
async unprotectedArchiveToggles(featureNames, projectId, auditUser) {
await Promise.all([
this.validateFeaturesContext(featureNames, projectId),
this.validateNoOrphanParents(featureNames),
]);
const features = await this.featureToggleStore.getAllByNames(featureNames);
await this.featureToggleStore.batchArchive(featureNames);
await this.dependentFeaturesService.unprotectedDeleteFeaturesDependencies(featureNames, projectId, auditUser);
await this.eventService.storeEvents(features.map((feature) => new FeatureArchivedEvent({
featureName: feature.name,
project: feature.project,
auditUser,
})));
}
async setToggleStaleness(featureNames, stale, projectId, auditUser) {
await this.validateFeaturesContext(featureNames, projectId);
const features = await this.featureToggleStore.getAllByNames(featureNames);
const relevantFeatures = features.filter((feature) => feature.stale !== stale);
const relevantFeatureNames = relevantFeatures.map((feature) => feature.name);
await this.featureToggleStore.batchStale(relevantFeatureNames, stale);
await this.eventService.storeEvents(relevantFeatures.map((feature) => new FeatureStaleEvent({
stale: stale,
project: projectId,
featureName: feature.name,
auditUser,
})));
}
async bulkUpdateEnabled(project, featureNames, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) {
await allSettledWithRejection(featureNames.map((featureName) => this.updateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies)));
}
async updateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) {
await this.stopWhenChangeRequestsEnabled(project, environment, user);
if (enabled) {
await this.stopWhenCannotCreateStrategies(project, environment, featureName, user);
}
return this.unprotectedUpdateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies);
}
async unprotectedUpdateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) {
await this.validateFeatureBelongsToProject({
featureName,
projectId: project,
});
const hasEnvironment = await this.featureEnvironmentStore.featureHasEnvironment(environment, featureName);
if (!hasEnvironment) {
throw new NotFoundError(`Could not find environment ${environment} for feature: ${featureName}`);
}
await this.validateFeatureIsNotArchived(featureName, project);
if (enabled) {
const strategies = await this.getStrategiesForEnvironment(project, featureName, environment);
const hasDisabledStrategies = strategies.some((strategy) => strategy.disabled);
if (hasDisabledStrategies && shouldActivateDisabledStrategies) {
await Promise.all(strategies.map((strategy) => this.updateStrategy(strategy.id, { ...strategy, disabled: false }, {
environment,
projectId: project,
featureName,
}, auditUser, user)));
}
const hasOnlyDisabledStrategies = strategies.every((strategy) => strategy.disabled);
const shouldCreate = hasOnlyDisabledStrategies && !shouldActivateDisabledStrategies;
if (strategies.length === 0 || shouldCreate) {
const projectEnvironmentDefaultStrategy = await this.projectStore.getDefaultStrategy(project, environment);
const strategy = projectEnvironmentDefaultStrategy != null
? getProjectDefaultStrategy(projectEnvironmentDefaultStrategy, featureName)
: getDefaultStrategy(featureName);
await this.unprotectedCreateStrategy(strategy, {
environment,
projectId: project,
featureName,
}, auditUser);
}
}
const updatedEnvironmentStatus = await this.featureEnvironmentStore.setEnvironmentEnabledStatus(environment, featureName, enabled);
const feature = await this.featureToggleStore.get(featureName);
if (updatedEnvironmentStatus > 0) {
await this.eventService.storeEvent(new FeatureEnvironmentEvent({
enabled,
project,
featureName,
environment,
auditUser,
}));
}
return feature; // If we get here we know the toggle exists
}
async changeProject(featureName, newProject, auditUser) {
const changeRequestEnabled = await this.changeRequestAccessReadModel.isChangeRequestsEnabledForProject(newProject);
if (changeRequestEnabled) {
throw new ForbiddenError(`Changing project not allowed. Project ${newProject} has change requests enabled.`);
}
if (await this.dependentFeaturesReadModel.haveDependencies([
featureName,
])) {
throw new ForbiddenError('Changing project not allowed. Feature has dependencies.');
}
const feature = await this.featureToggleStore.get(featureName);
if (feature === undefined) {
throw new NotFoundError(`Could not find feature with name ${featureName}`);
}
const oldProject = feature.project;
feature.project = newProject;
await this.featureToggleStore.update(newProject, feature);
await this.eventService.storeEvent(new FeatureChangeProjectEvent({
auditUser,
oldProject,
newProject,
featureName,
}));
}
// TODO: add project id.
async deleteFeature(featureName, auditUser) {
await this.validateNoChildren(featureName);
const toggle = await this.featureToggleStore.get(featureName);
if (toggle === undefined) {
return; /// Do nothing, toggle is already deleted
}
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.featureToggleStore.delete(featureName);
await this.eventService.storeEvent(new FeatureDeletedEvent({
featureName,
project: toggle.project,
auditUser,
preData: toggle,
tags,
}));
}
async deleteFeatures(featureNames, projectId, auditUser) {
await this.validateFeaturesContext(featureNames, projectId);
await this.validateNoOrphanParents(featureNames);
const features = await this.featureToggleStore.getAllByNames(featureNames);
const eligibleFeatures = features.filter((toggle) => toggle.archivedAt !== null);
const eligibleFeatureNames = eligibleFeatures.map((toggle) => toggle.name);
if (eligibleFeatures.length === 0) {
return;
}
const tags = await this.tagStore.getAllByFeatures(eligibleFeatureNames);
await this.featureToggleStore.batchDelete(eligibleFeatureNames);
await this.eventService.storeEvents(eligibleFeatures.map((feature) => new FeatureDeletedEvent({
featureName: feature.name,
auditUser,
project: feature.project,
preData: feature,
tags: tags
.filter((tag) => tag.featureName === feature.name)
.map((tag) => ({
value: tag.tagValue,
type: tag.tagType,