1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-10-18 20:09:08 +02:00
unleash.unleash/src/lib/services/feature-toggle-service.ts
Thomas Heartman e55ad1a21e
feat(#1873/playground): Return detailed information on feature toggle evaluation (#1839)
* Feat: return reasons why a feature evaluated to true or false

Note: this is very rough and just straight ripped from the nodejs
client. It will need a lot of work, but is a good place to start

* Feat: add suggested shape for new payload

* Chore: minor cleanup

* Wip: make server compile again

* Remove unused schema ref

* Export new schemas

* Chore: fix some tests to use sub property

* Fix: fix some tests

* Refactor: rename some variables, uncomment some stuff

* Add segments type to bootstrap options

* Add segments capability to offline feature evaluator

* Fix function calls after turning params into an option abject

* Feat: test strategy order, etc

* Feat: add test to check that all strats are returned correctly

* Feat: allow you to include strategy ids in clients

* Wip: hook up segments in the offline client.

Note: compared to regular clients, they still fail

* Feat: add segments validation

* Fix: fix test case invariant.

* Chore: revert to returning only `boolean` from strategies.

This _should_ make it work with custom strategies too 🤞

* Feat: make more properties of the returned feature required

* Wip: add some comments and unfinished tests for edge cases

* Feat: add `isEnabledInCurrentEnvironment` prop

* Feat: consider more strategy failure cases

* Feat: test that isenabledinenvironment matches expectations

* Feat: add unknown strategies

* Fix: fix property access typo

* Feat: add unknown strategy for fallback purposes

* Feat: test edge case: all unknown strategies

* Feat: add custom strategy to arbitrary

* Feat: test that features can be true, even if not enabled in env

* Chore: add some comments

* Wip: fix sdk tests

* Remove comments, improve test logging

* Feat: add descriptions and examples to playground feature schema

* Switch `examples` for `example`

* Update schemas with descriptions and examples

* Fix: update snapshot

* Fix: openapi example

* Fix: merge issues

* Fix: fix issue where feature evaluation state was wrong

* Chore: update openapi spec

* Fix: fix broken offline client tests

* Refactor: move schemas into separate files

* Refactor: remove "reason" for incomplete evaluation.

The only instances where evaluation is incomplete is when we don't
know what the strategy is.

* Refactor: move unleash node client into test and dev dependencies

* Wip: further removal of stuff

* Chore: remove a bunch of code that we don't use

* Chore: remove comment

* Chore: remove unused code

* Fix: fix some prettier errors

* Type parameters in strategies to avoid `any`

* Fix: remove commented out code

* Feat: make `id` required on playground strategies

* Chore: remove redundant type

* Fix: remove redundant if and fix fallback evaluation

* Refactor: reduce nesting and remove duplication

* Fix: remove unused helper function

* Refactor: type `parameters` as `unknown`

* Chore: remove redundant comment

* Refactor: move constraint code into a separate file

* Refactor: rename `unleash` -> `feature-evaluator`

* Rename class `Unleash` -> `FeatureEvaluator`

* Refactor: remove this.ready and sync logic from feature evaluator

* Refactor: remove unused code, rename config type

* Refactor: remove event emission from the Unleash client

* Remove unlistened-for events in feature evaluator

* Refactor: make offline client synchronous; remove code

* Fix: update openapi snapshot after adding required strategy ids

* Feat: change `strategies` format.

This commit changes the format of a playground feature's `strategies`
properties from a list of strategies to an object with properties
`result` and `data`. It looks a bit like this:

```ts
type Strategies = {
  result: boolean | "unknown",
  data: Strategy[]
}
```

The reason is that this allows us to avoid the breaking change that
was previously suggested in the PR:

`feature.isEnabled` used to be a straight boolean. Then, when we found
out we couldn't necessarily evaluate all strategies (custom strats are
hard!) we changed it to `boolean | 'unevaluated'`. However, this is
confusing on a few levels as the playground results are no longer the
same as the SDK would be, nor are they strictly boolean anymore.

This change reverts the `isEnabled` functionality to what it was
before (so it's always a mirror of what the SDK would show).
The equivalent of `feature.isEnabled === 'unevaluated'` now becomes
`feature.isEnabled && strategy.result === 'unknown'`.

* Fix: Fold long string descriptions over multiple lines.

* Fix: update snapshot after adding line breaks to descriptions
2022-08-04 15:41:52 +02:00

1111 lines
35 KiB
TypeScript

import { IUnleashConfig } from '../types/option';
import { IUnleashStores } from '../types';
import { Logger } from '../logger';
import BadDataError from '../error/bad-data-error';
import NameExistsError from '../error/name-exists-error';
import InvalidOperationError from '../error/invalid-operation-error';
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
import {
constraintSchema,
featureMetadataSchema,
nameSchema,
variantsArraySchema,
} from '../schema/feature-schema';
import {
FEATURE_UPDATED,
FeatureArchivedEvent,
FeatureChangeProjectEvent,
FeatureCreatedEvent,
FeatureDeletedEvent,
FeatureEnvironmentEvent,
FeatureMetadataUpdateEvent,
FeatureRevivedEvent,
FeatureStaleEvent,
FeatureStrategyAddEvent,
FeatureStrategyRemoveEvent,
FeatureStrategyUpdateEvent,
FeatureVariantEvent,
} from '../types/events';
import NotFoundError from '../error/notfound-error';
import {
FeatureConfigurationClient,
IFeatureStrategiesStore,
} from '../types/stores/feature-strategies-store';
import { IEventStore } from '../types/stores/event-store';
import { IProjectStore } from '../types/stores/project-store';
import { IFeatureTagStore } from '../types/stores/feature-tag-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import {
FeatureToggle,
FeatureToggleDTO,
FeatureToggleLegacy,
FeatureToggleWithEnvironment,
IConstraint,
IFeatureEnvironmentInfo,
IFeatureOverview,
IFeatureStrategy,
IFeatureToggleQuery,
IStrategyConfig,
IVariant,
WeightType,
} from '../types/model';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store';
import {
DATE_OPERATORS,
DEFAULT_ENV,
NUM_OPERATORS,
SEMVER_OPERATORS,
STRING_OPERATORS,
} from '../util/constants';
import { applyPatch, deepClone, Operation } from 'fast-json-patch';
import { OperationDeniedError } from '../error/operation-denied-error';
import {
validateDate,
validateLegalValues,
validateNumber,
validateSemver,
validateString,
} from '../util/validators/constraint-types';
import { IContextFieldStore } from 'lib/types/stores/context-field-store';
import { Saved, Unsaved } from '../types/saved';
import { SegmentService } from './segment-service';
import { SetStrategySortOrderSchema } from 'lib/openapi/spec/set-strategy-sort-order-schema';
interface IFeatureContext {
featureName: string;
projectId: string;
}
interface IFeatureStrategyContext extends IFeatureContext {
environment: string;
}
const oneOf = (values: string[], match: string) => {
return values.some((value) => value === match);
};
class FeatureToggleService {
private logger: Logger;
private featureStrategiesStore: IFeatureStrategiesStore;
private featureToggleStore: IFeatureToggleStore;
private featureToggleClientStore: IFeatureToggleClientStore;
private tagStore: IFeatureTagStore;
private featureEnvironmentStore: IFeatureEnvironmentStore;
private projectStore: IProjectStore;
private eventStore: IEventStore;
private contextFieldStore: IContextFieldStore;
private segmentService: SegmentService;
constructor(
{
featureStrategiesStore,
featureToggleStore,
featureToggleClientStore,
projectStore,
eventStore,
featureTagStore,
featureEnvironmentStore,
contextFieldStore,
}: Pick<
IUnleashStores,
| 'featureStrategiesStore'
| 'featureToggleStore'
| 'featureToggleClientStore'
| 'projectStore'
| 'eventStore'
| 'featureTagStore'
| 'featureEnvironmentStore'
| 'contextFieldStore'
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
segmentService: SegmentService,
) {
this.logger = getLogger('services/feature-toggle-service.ts');
this.featureStrategiesStore = featureStrategiesStore;
this.featureToggleStore = featureToggleStore;
this.featureToggleClientStore = featureToggleClientStore;
this.tagStore = featureTagStore;
this.projectStore = projectStore;
this.eventStore = eventStore;
this.featureEnvironmentStore = featureEnvironmentStore;
this.contextFieldStore = contextFieldStore;
this.segmentService = segmentService;
}
async validateFeatureContext({
featureName,
projectId,
}: IFeatureContext): Promise<void> {
const id = await this.featureToggleStore.getProjectId(featureName);
if (id !== projectId) {
throw new InvalidOperationError(
'Project id does not match the project that the feature belongs to',
);
}
}
validateFeatureStrategyContext(
strategy: IFeatureStrategy,
{ featureName, projectId }: IFeatureStrategyContext,
): void {
if (strategy.projectId !== projectId) {
throw new InvalidOperationError(
'You can not change the projectId for an activation strategy.',
);
}
if (strategy.featureName !== featureName) {
throw new InvalidOperationError(
'You can not change the featureName for an activation strategy.',
);
}
}
async validateConstraints(
constraints: IConstraint[],
): Promise<IConstraint[]> {
const validations = constraints.map((constraint) => {
return this.validateConstraint(constraint);
});
return Promise.all(validations);
}
async validateConstraint(input: IConstraint): Promise<IConstraint> {
const constraint = await constraintSchema.validateAsync(input);
const { operator } = constraint;
const contextDefinition = await this.contextFieldStore.get(
constraint.contextName,
);
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 (
oneOf(
[...DATE_OPERATORS, ...SEMVER_OPERATORS, ...NUM_OPERATORS],
operator,
)
) {
if (contextDefinition?.legalValues?.length > 0) {
validateLegalValues(
contextDefinition.legalValues,
constraint.value,
);
}
} else {
if (contextDefinition?.legalValues?.length > 0) {
validateLegalValues(
contextDefinition.legalValues,
constraint.values,
);
}
}
return constraint;
}
async patchFeature(
project: string,
featureName: string,
createdBy: string,
operations: Operation[],
): Promise<FeatureToggle> {
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,
createdBy,
featureName,
);
if (featureToggle.stale !== newDocument.stale) {
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store(
new FeatureStaleEvent({
stale: newDocument.stale,
project,
featureName,
createdBy,
tags,
}),
);
}
return updated;
}
featureStrategyToPublic(
featureStrategy: IFeatureStrategy,
): Saved<IStrategyConfig> {
return {
id: featureStrategy.id,
name: featureStrategy.strategyName,
constraints: featureStrategy.constraints || [],
parameters: featureStrategy.parameters,
};
}
async updateStrategiesSortOrder(
featureName: string,
sortOrders: SetStrategySortOrderSchema,
): Promise<Saved<any>> {
await Promise.all(
sortOrders.map(async ({ id, sortOrder }) =>
this.featureStrategiesStore.updateSortOrder(id, sortOrder),
),
);
}
async createStrategy(
strategyConfig: Unsaved<IStrategyConfig>,
context: IFeatureStrategyContext,
createdBy: string,
): Promise<Saved<IStrategyConfig>> {
const { featureName, projectId, environment } = context;
await this.validateFeatureContext(context);
if (strategyConfig.constraints?.length > 0) {
strategyConfig.constraints = await this.validateConstraints(
strategyConfig.constraints,
);
}
try {
const newFeatureStrategy =
await this.featureStrategiesStore.createStrategyFeatureEnv({
strategyName: strategyConfig.name,
constraints: strategyConfig.constraints,
parameters: strategyConfig.parameters,
sortOrder: strategyConfig.sortOrder,
projectId,
featureName,
environment,
});
const tags = await this.tagStore.getAllTagsForFeature(featureName);
const strategy = this.featureStrategyToPublic(newFeatureStrategy);
await this.eventStore.store(
new FeatureStrategyAddEvent({
project: projectId,
featureName,
createdBy,
environment,
data: strategy,
tags,
}),
);
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 userName - Human readable id of the user performing the update
*/
async updateStrategy(
id: string,
updates: Partial<IFeatureStrategy>,
context: IFeatureStrategyContext,
userName: string,
): Promise<Saved<IStrategyConfig>> {
const { projectId, environment, featureName } = context;
const existingStrategy = await this.featureStrategiesStore.get(id);
this.validateFeatureStrategyContext(existingStrategy, context);
if (existingStrategy.id === id) {
if (updates.constraints?.length > 0) {
updates.constraints = await this.validateConstraints(
updates.constraints,
);
}
const strategy = await this.featureStrategiesStore.updateStrategy(
id,
updates,
);
// Store event!
const tags = await this.tagStore.getAllTagsForFeature(featureName);
const data = this.featureStrategyToPublic(strategy);
const preData = this.featureStrategyToPublic(existingStrategy);
await this.eventStore.store(
new FeatureStrategyUpdateEvent({
project: projectId,
featureName,
environment,
createdBy: userName,
data,
preData,
tags,
}),
);
return data;
}
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
async updateStrategyParameter(
id: string,
name: string,
value: string | number,
context: IFeatureStrategyContext,
userName: string,
): Promise<Saved<IStrategyConfig>> {
const { projectId, environment, featureName } = context;
const existingStrategy = await this.featureStrategiesStore.get(id);
this.validateFeatureStrategyContext(existingStrategy, context);
if (existingStrategy.id === id) {
existingStrategy.parameters[name] = String(value);
const strategy = await this.featureStrategiesStore.updateStrategy(
id,
existingStrategy,
);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
const data = this.featureStrategyToPublic(strategy);
const preData = this.featureStrategyToPublic(existingStrategy);
await this.eventStore.store(
new FeatureStrategyUpdateEvent({
featureName,
project: projectId,
environment,
createdBy: userName,
data,
preData,
tags,
}),
);
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 createdBy - Which user does this strategy belong to
*/
async deleteStrategy(
id: string,
context: IFeatureStrategyContext,
createdBy: string,
): Promise<void> {
const existingStrategy = await this.featureStrategiesStore.get(id);
const { featureName, projectId, environment } = context;
this.validateFeatureStrategyContext(existingStrategy, context);
await this.featureStrategiesStore.delete(id);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
const preData = this.featureStrategyToPublic(existingStrategy);
await this.eventStore.store(
new FeatureStrategyRemoveEvent({
featureName,
project: projectId,
environment,
createdBy,
preData,
tags,
}),
);
// If there are no strategies left for environment disable it
await this.featureEnvironmentStore.disableEnvironmentIfNoStrategies(
featureName,
environment,
);
}
async getStrategiesForEnvironment(
project: string,
featureName: string,
environment: string = DEFAULT_ENV,
): Promise<Saved<IStrategyConfig>[]> {
const hasEnv = await this.featureEnvironmentStore.featureHasEnvironment(
environment,
featureName,
);
if (hasEnv) {
const featureStrategies =
await this.featureStrategiesStore.getStrategiesForFeatureEnv(
project,
featureName,
environment,
);
return featureStrategies.map((strat) => ({
id: strat.id,
name: strat.strategyName,
constraints: strat.constraints,
parameters: strat.parameters,
sortOrder: strat.sortOrder,
}));
}
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
*/
async getFeature(
featureName: string,
archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> {
return this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
archived,
);
}
/**
* GET /api/admin/projects/:project/features/:featureName/variants
* @param featureName
* @return The list of variants
*/
async getVariants(featureName: string): Promise<IVariant[]> {
return this.featureToggleStore.getVariants(featureName);
}
async getFeatureMetadata(featureName: string): Promise<FeatureToggle> {
return this.featureToggleStore.get(featureName);
}
async getClientFeatures(
query?: IFeatureToggleQuery,
includeIds?: boolean,
): Promise<FeatureConfigurationClient[]> {
return this.featureToggleClientStore.getClient(query, includeIds);
}
/**
*
* 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
* @returns
*/
async getFeatureToggles(
query?: IFeatureToggleQuery,
archived: boolean = false,
): Promise<FeatureToggle[]> {
return this.featureToggleClientStore.getAdmin(query, archived);
}
async getFeatureOverview(
projectId: string,
archived: boolean = false,
): Promise<IFeatureOverview[]> {
return this.featureStrategiesStore.getFeatureOverview(
projectId,
archived,
);
}
async getFeatureToggle(
featureName: string,
): Promise<FeatureToggleWithEnvironment> {
return this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
false,
);
}
async createFeatureToggle(
projectId: string,
value: FeatureToggleDTO,
createdBy: string,
isValidated: boolean = false,
): Promise<FeatureToggle> {
this.logger.info(`${createdBy} creates feature toggle ${value.name}`);
await this.validateName(value.name);
const exists = await this.projectStore.hasProject(projectId);
if (exists) {
let featureData;
if (isValidated) {
featureData = value;
} else {
featureData = await featureMetadataSchema.validateAsync(value);
}
const featureName = featureData.name;
const createdToggle = await this.featureToggleStore.create(
projectId,
featureData,
);
await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(
featureName,
projectId,
);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store(
new FeatureCreatedEvent({
featureName,
createdBy,
project: projectId,
data: createdToggle,
tags,
}),
);
return createdToggle;
}
throw new NotFoundError(`Project with id ${projectId} does not exist`);
}
async cloneFeatureToggle(
featureName: string,
projectId: string,
newFeatureName: string,
replaceGroupId: boolean = true, // eslint-disable-line
userName: string,
): Promise<FeatureToggle> {
this.logger.info(
`${userName} clones feature toggle ${featureName} to ${newFeatureName}`,
);
await this.validateName(newFeatureName);
const cToggle =
await this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
);
const newToggle = { ...cToggle, name: newFeatureName };
const created = await this.createFeatureToggle(
projectId,
newToggle,
userName,
);
const tasks = newToggle.environments.flatMap((e) =>
e.strategies.map((s) => {
if (replaceGroupId && s.parameters.hasOwnProperty('groupId')) {
s.parameters.groupId = newFeatureName;
}
const context = {
projectId,
featureName: newFeatureName,
environment: e.name,
};
return this.createStrategy(s, context, userName).then((s2) =>
this.segmentService.cloneStrategySegments(s.id, s2.id),
);
}),
);
await Promise.allSettled(tasks);
return created;
}
async updateFeatureToggle(
projectId: string,
updatedFeature: FeatureToggleDTO,
userName: string,
featureName: string,
): Promise<FeatureToggle> {
await this.validateFeatureContext({ featureName, projectId });
this.logger.info(`${userName} updates feature toggle ${featureName}`);
const featureData = await featureMetadataSchema.validateAsync(
updatedFeature,
);
const preData = await this.featureToggleStore.get(featureName);
const featureToggle = await this.featureToggleStore.update(projectId, {
...featureData,
name: featureName,
});
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store(
new FeatureMetadataUpdateEvent({
createdBy: userName,
data: featureToggle,
preData,
featureName,
project: projectId,
tags,
}),
);
return featureToggle;
}
async getFeatureCountForProject(projectId: string): Promise<number> {
return this.featureToggleStore.count({
archived: false,
project: projectId,
});
}
async removeAllStrategiesForEnv(
toggleName: string,
environment: string = DEFAULT_ENV,
): Promise<void> {
await this.featureStrategiesStore.removeAllStrategiesForFeatureEnv(
toggleName,
environment,
);
}
async getStrategy(strategyId: string): Promise<Saved<IStrategyConfig>> {
const strategy = await this.featureStrategiesStore.getStrategyById(
strategyId,
);
return {
id: strategy.id,
name: strategy.strategyName,
constraints: strategy.constraints || [],
parameters: strategy.parameters,
};
}
async getEnvironmentInfo(
project: string,
environment: string,
featureName: string,
): Promise<IFeatureEnvironmentInfo> {
const envMetadata =
await this.featureEnvironmentStore.getEnvironmentMetaData(
environment,
featureName,
);
const strategies =
await this.featureStrategiesStore.getStrategiesForFeatureEnv(
project,
featureName,
environment,
);
return {
name: featureName,
environment,
enabled: envMetadata.enabled,
strategies,
};
}
// todo: store events for this change.
async deleteEnvironment(
projectId: string,
environment: string,
): Promise<void> {
await this.featureStrategiesStore.deleteConfigurationsForProjectAndEnvironment(
projectId,
environment,
);
await this.projectStore.deleteEnvironmentForProject(
projectId,
environment,
);
}
/** Validations */
async validateName(name: string): Promise<string> {
await nameSchema.validateAsync({ name });
await this.validateUniqueFeatureName(name);
return name;
}
async validateUniqueFeatureName(name: string): Promise<void> {
let msg: string;
try {
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';
} catch (error) {
return;
}
throw new NameExistsError(msg);
}
async hasFeature(name: string): Promise<boolean> {
return this.featureToggleStore.exists(name);
}
async updateStale(
featureName: string,
isStale: boolean,
createdBy: string,
): Promise<any> {
const feature = await this.featureToggleStore.get(featureName);
const { project } = feature;
feature.stale = isStale;
await this.featureToggleStore.update(project, feature);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store(
new FeatureStaleEvent({
stale: isStale,
project,
featureName,
createdBy,
tags,
}),
);
return feature;
}
// todo: add projectId
async archiveToggle(featureName: string, createdBy: string): Promise<void> {
const feature = await this.featureToggleStore.get(featureName);
await this.featureToggleStore.archive(featureName);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store(
new FeatureArchivedEvent({
featureName,
createdBy,
project: feature.project,
tags,
}),
);
}
async updateEnabled(
project: string,
featureName: string,
environment: string,
enabled: boolean,
createdBy: string,
): Promise<FeatureToggle> {
const hasEnvironment =
await this.featureEnvironmentStore.featureHasEnvironment(
environment,
featureName,
);
if (hasEnvironment) {
if (enabled) {
const strategies = await this.getStrategiesForEnvironment(
project,
featureName,
environment,
);
if (strategies.length === 0) {
throw new InvalidOperationError(
'You can not enable the environment before it has strategies',
);
}
}
const updatedEnvironmentStatus =
await this.featureEnvironmentStore.setEnvironmentEnabledStatus(
environment,
featureName,
enabled,
);
const feature = await this.featureToggleStore.get(featureName);
if (updatedEnvironmentStatus > 0) {
const tags = await this.tagStore.getAllTagsForFeature(
featureName,
);
await this.eventStore.store(
new FeatureEnvironmentEvent({
enabled,
project,
featureName,
environment,
createdBy,
tags,
}),
);
}
return feature;
}
throw new NotFoundError(
`Could not find environment ${environment} for feature: ${featureName}`,
);
}
// @deprecated
async storeFeatureUpdatedEventLegacy(
featureName: string,
createdBy: string,
): Promise<FeatureToggleLegacy> {
const tags = await this.tagStore.getAllTagsForFeature(featureName);
const feature = await this.getFeatureToggleLegacy(featureName);
// Legacy event. Will not be used from v4.3.
// We do not include 'preData' on purpose.
await this.eventStore.store({
type: FEATURE_UPDATED,
createdBy,
featureName,
data: feature,
tags,
project: feature.project,
});
return feature;
}
// @deprecated
async toggle(
projectId: string,
featureName: string,
environment: string,
userName: string,
): Promise<FeatureToggle> {
await this.featureToggleStore.get(featureName);
const isEnabled =
await this.featureEnvironmentStore.isEnvironmentEnabled(
featureName,
environment,
);
return this.updateEnabled(
projectId,
featureName,
environment,
!isEnabled,
userName,
);
}
// @deprecated
async getFeatureToggleLegacy(
featureName: string,
): Promise<FeatureToggleLegacy> {
const feature =
await this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
);
const { environments, ...legacyFeature } = feature;
const defaultEnv = environments.find((e) => e.name === DEFAULT_ENV);
const strategies = defaultEnv?.strategies || [];
const enabled = defaultEnv?.enabled || false;
return { ...legacyFeature, enabled, strategies };
}
async changeProject(
featureName: string,
newProject: string,
createdBy: string,
): Promise<void> {
const feature = await this.featureToggleStore.get(featureName);
const oldProject = feature.project;
feature.project = newProject;
await this.featureToggleStore.update(newProject, feature);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store(
new FeatureChangeProjectEvent({
createdBy,
oldProject,
newProject,
featureName,
tags,
}),
);
}
async getArchivedFeatures(): Promise<FeatureToggle[]> {
return this.getFeatureToggles({}, true);
}
// TODO: add project id.
async deleteFeature(featureName: string, createdBy: string): Promise<void> {
const toggle = await this.featureToggleStore.get(featureName);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.featureToggleStore.delete(featureName);
await this.eventStore.store(
new FeatureDeletedEvent({
featureName,
project: toggle.project,
createdBy,
preData: toggle,
tags,
}),
);
}
// TODO: add project id.
async reviveToggle(featureName: string, createdBy: string): Promise<void> {
const toggle = await this.featureToggleStore.revive(featureName);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store(
new FeatureRevivedEvent({
createdBy,
featureName,
project: toggle.project,
tags,
}),
);
}
async getMetadataForAllFeatures(
archived: boolean,
): Promise<FeatureToggle[]> {
return this.featureToggleStore.getAll({ archived });
}
async getMetadataForAllFeaturesByProjectId(
archived: boolean,
project: string,
): Promise<FeatureToggle[]> {
return this.featureToggleStore.getAll({ archived, project });
}
async getProjectId(name: string): Promise<string> {
return this.featureToggleStore.getProjectId(name);
}
async updateFeatureStrategyProject(
featureName: string,
newProjectId: string,
): Promise<void> {
await this.featureStrategiesStore.setProjectForStrategiesBelongingToFeature(
featureName,
newProjectId,
);
}
async updateVariants(
featureName: string,
project: string,
newVariants: Operation[],
createdBy: string,
): Promise<FeatureToggle> {
const oldVariants = await this.getVariants(featureName);
const { newDocument } = await applyPatch(oldVariants, newVariants);
return this.saveVariants(featureName, project, newDocument, createdBy);
}
async saveVariants(
featureName: string,
project: string,
newVariants: IVariant[],
createdBy: string,
): Promise<FeatureToggle> {
await variantsArraySchema.validateAsync(newVariants);
const fixedVariants = this.fixVariantWeights(newVariants);
const oldVariants = await this.featureToggleStore.getVariants(
featureName,
);
const featureToggle = await this.featureToggleStore.saveVariants(
project,
featureName,
fixedVariants,
);
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store(
new FeatureVariantEvent({
project,
featureName,
createdBy,
tags,
oldVariants,
newVariants: featureToggle.variants as IVariant[],
}),
);
return featureToggle;
}
fixVariantWeights(variants: IVariant[]): IVariant[] {
let variableVariants = variants.filter((x) => {
return x.weightType === WeightType.VARIABLE;
});
if (variants.length > 0 && variableVariants.length === 0) {
throw new BadDataError(
'There must be at least one "variable" variant',
);
}
let fixedVariants = variants.filter((x) => {
return x.weightType === WeightType.FIX;
});
let fixedWeights = fixedVariants.reduce((a, v) => a + v.weight, 0);
if (fixedWeights > 1000) {
throw new BadDataError(
'The traffic distribution total must equal 100%',
);
}
let averageWeight = Math.floor(
(1000 - fixedWeights) / variableVariants.length,
);
let remainder = (1000 - fixedWeights) % variableVariants.length;
variableVariants = variableVariants.map((x) => {
x.weight = averageWeight;
if (remainder > 0) {
x.weight += 1;
remainder--;
}
return x;
});
return variableVariants.concat(fixedVariants);
}
}
export default FeatureToggleService;