mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-18 01:18:23 +02:00
Merge branch 'master' into docs/add-stickiness
This commit is contained in:
commit
e8478126a5
17
.github/workflows/build_doc_prs.yaml
vendored
Normal file
17
.github/workflows/build_doc_prs.yaml
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
name: PR -> Build Docs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- website/**
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build docs
|
||||
run: |
|
||||
# Build the site
|
||||
cd website && yarn && yarn build
|
@ -137,7 +137,7 @@
|
||||
"del-cli": "4.0.1",
|
||||
"eslint": "8.3.0",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-airbnb-typescript": "15.0.0",
|
||||
"eslint-config-airbnb-typescript": "16.0.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-import": "2.25.3",
|
||||
"eslint-plugin-prettier": "4.0.0",
|
||||
|
@ -167,6 +167,13 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
};
|
||||
}
|
||||
|
||||
rowToVariants(row: FeaturesTable): IVariant[] {
|
||||
if (!row) {
|
||||
throw new NotFoundError('No feature toggle found');
|
||||
}
|
||||
return (row.variants as unknown as IVariant[]) || [];
|
||||
}
|
||||
|
||||
dtoToRow(project: string, data: FeatureToggleDTO): FeaturesTable {
|
||||
const row = {
|
||||
name: data.name,
|
||||
@ -232,6 +239,24 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
.returning(FEATURE_COLUMNS);
|
||||
return this.rowToFeature(row[0]);
|
||||
}
|
||||
|
||||
async getVariants(featureName: string): Promise<IVariant[]> {
|
||||
const row = await this.db(TABLE)
|
||||
.select('variants')
|
||||
.where({ name: featureName });
|
||||
return this.rowToVariants(row[0]);
|
||||
}
|
||||
|
||||
async saveVariants(
|
||||
featureName: string,
|
||||
newVariants: IVariant[],
|
||||
): Promise<FeatureToggle> {
|
||||
const row = await this.db(TABLE)
|
||||
.update({ variants: JSON.stringify(newVariants) })
|
||||
.where({ name: featureName })
|
||||
.returning(FEATURE_COLUMNS);
|
||||
return this.rowToFeature(row[0]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FeatureToggleStore;
|
||||
|
@ -29,6 +29,7 @@ import EnvironmentStore from './environment-store';
|
||||
import FeatureTagStore from './feature-tag-store';
|
||||
import { FeatureEnvironmentStore } from './feature-environment-store';
|
||||
import { ClientMetricsStoreV2 } from './client-metrics-store-v2';
|
||||
import UserSplashStore from './user-splash-store';
|
||||
|
||||
export const createStores = (
|
||||
config: IUnleashConfig,
|
||||
@ -85,6 +86,7 @@ export const createStores = (
|
||||
eventBus,
|
||||
getLogger,
|
||||
),
|
||||
userSplashStore: new UserSplashStore(db, eventBus, getLogger),
|
||||
};
|
||||
};
|
||||
|
||||
|
105
src/lib/db/user-splash-store.ts
Normal file
105
src/lib/db/user-splash-store.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { Knex } from 'knex';
|
||||
import { EventEmitter } from 'events';
|
||||
import { LogProvider, Logger } from '../logger';
|
||||
import {
|
||||
IUserSplash,
|
||||
IUserSplashKey,
|
||||
IUserSplashStore,
|
||||
} from '../types/stores/user-splash-store';
|
||||
|
||||
const COLUMNS = ['user_id', 'splash_id', 'seen'];
|
||||
const TABLE = 'user_splash';
|
||||
|
||||
interface IUserSplashTable {
|
||||
seen?: boolean;
|
||||
splash_id: string;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
const fieldToRow = (fields: IUserSplash): IUserSplashTable => ({
|
||||
seen: fields.seen,
|
||||
splash_id: fields.splashId,
|
||||
user_id: fields.userId,
|
||||
});
|
||||
|
||||
const rowToField = (row: IUserSplashTable): IUserSplash => ({
|
||||
seen: row.seen,
|
||||
splashId: row.splash_id,
|
||||
userId: row.user_id,
|
||||
});
|
||||
|
||||
export default class UserSplashStore implements IUserSplashStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('user-splash-store.ts');
|
||||
}
|
||||
|
||||
async getAllUserSplashs(userId: number): Promise<IUserSplash[]> {
|
||||
const userSplash = await this.db
|
||||
.table<IUserSplashTable>(TABLE)
|
||||
.select()
|
||||
.where({ user_id: userId });
|
||||
|
||||
return userSplash.map(rowToField);
|
||||
}
|
||||
|
||||
async getSplash(userId: number, splashId: string): Promise<IUserSplash> {
|
||||
const userSplash = await this.db
|
||||
.table<IUserSplashTable>(TABLE)
|
||||
.select()
|
||||
.where({ user_id: userId, splash_id: splashId })
|
||||
.first();
|
||||
|
||||
return rowToField(userSplash);
|
||||
}
|
||||
|
||||
async updateSplash(splash: IUserSplash): Promise<IUserSplash> {
|
||||
const insertedSplash = await this.db
|
||||
.table<IUserSplashTable>(TABLE)
|
||||
.insert(fieldToRow(splash))
|
||||
.onConflict(['user_id', 'splash_id'])
|
||||
.merge()
|
||||
.returning(COLUMNS);
|
||||
|
||||
return rowToField(insertedSplash[0]);
|
||||
}
|
||||
|
||||
async delete({ userId, splashId }: IUserSplashKey): Promise<void> {
|
||||
await this.db(TABLE)
|
||||
.where({ user_id: userId, splash_id: splashId })
|
||||
.del();
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<void> {
|
||||
await this.db(TABLE).del();
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
|
||||
async exists({ userId, splashId }: IUserSplashKey): Promise<boolean> {
|
||||
const result = await this.db.raw(
|
||||
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE user_id = ? AND splash_id = ?) AS present`,
|
||||
[userId, splashId],
|
||||
);
|
||||
const { present } = result.rows[0];
|
||||
return present;
|
||||
}
|
||||
|
||||
async get({ userId, splashId }: IUserSplashKey): Promise<IUserSplash> {
|
||||
return this.getSplash(userId, splashId);
|
||||
}
|
||||
|
||||
async getAll(): Promise<IUserSplash[]> {
|
||||
const userSplashs = await this.db
|
||||
.table<IUserSplashTable>(TABLE)
|
||||
.select();
|
||||
|
||||
return userSplashs.map(rowToField);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UserSplashStore;
|
@ -21,6 +21,7 @@ import ApiTokenController from './api-token-controller';
|
||||
import UserAdminController from './user-admin';
|
||||
import EmailController from './email';
|
||||
import UserFeedbackController from './user-feedback-controller';
|
||||
import UserSplashController from './user-splash-controller';
|
||||
import ProjectApi from './project';
|
||||
import { EnvironmentsController } from './environments-controller';
|
||||
|
||||
@ -92,6 +93,10 @@ class AdminApi extends Controller {
|
||||
'/environments',
|
||||
new EnvironmentsController(config, services).router,
|
||||
);
|
||||
this.app.use(
|
||||
'/splash',
|
||||
new UserSplashController(config, services).router,
|
||||
);
|
||||
}
|
||||
|
||||
index(req, res) {
|
||||
|
@ -6,6 +6,7 @@ import ProjectFeaturesController from './features';
|
||||
import EnvironmentsController from './environments';
|
||||
import ProjectHealthReport from './health-report';
|
||||
import ProjectService from '../../../services/project-service';
|
||||
import VariantsController from './variants';
|
||||
|
||||
export default class ProjectApi extends Controller {
|
||||
private projectService: ProjectService;
|
||||
@ -17,6 +18,7 @@ export default class ProjectApi extends Controller {
|
||||
this.use('/', new ProjectFeaturesController(config, services).router);
|
||||
this.use('/', new EnvironmentsController(config, services).router);
|
||||
this.use('/', new ProjectHealthReport(config, services).router);
|
||||
this.use('/', new VariantsController(config, services).router);
|
||||
}
|
||||
|
||||
async getProjects(req: Request, res: Response): Promise<void> {
|
||||
|
78
src/lib/routes/admin-api/project/variants.ts
Normal file
78
src/lib/routes/admin-api/project/variants.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import FeatureToggleService from '../../../services/feature-toggle-service';
|
||||
import { Logger } from '../../../logger';
|
||||
import Controller from '../../controller';
|
||||
import { IUnleashConfig } from '../../../types/option';
|
||||
import { IUnleashServices } from '../../../types';
|
||||
import { Request, Response } from 'express';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { UPDATE_FEATURE } from '../../../types/permissions';
|
||||
import { IVariant } from '../../../types/model';
|
||||
|
||||
const PREFIX = '/:projectId/features/:featureName/variants';
|
||||
|
||||
interface FeatureParams extends ProjectParam {
|
||||
featureName: string;
|
||||
}
|
||||
|
||||
interface ProjectParam {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default class VariantsController extends Controller {
|
||||
private logger: Logger;
|
||||
|
||||
private featureService: FeatureToggleService;
|
||||
|
||||
constructor(
|
||||
config: IUnleashConfig,
|
||||
{
|
||||
featureToggleService,
|
||||
}: Pick<IUnleashServices, 'featureToggleService'>,
|
||||
) {
|
||||
super(config);
|
||||
this.logger = config.getLogger('admin-api/project/variants.ts');
|
||||
this.featureService = featureToggleService;
|
||||
this.get(PREFIX, this.getVariants);
|
||||
this.patch(PREFIX, this.patchVariants, UPDATE_FEATURE);
|
||||
this.put(PREFIX, this.overwriteVariants, UPDATE_FEATURE);
|
||||
}
|
||||
|
||||
async getVariants(
|
||||
req: Request<FeatureParams, any, any, any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { featureName } = req.params;
|
||||
const variants = await this.featureService.getVariants(featureName);
|
||||
res.status(200).json({ version: '1', variants });
|
||||
}
|
||||
|
||||
async patchVariants(
|
||||
req: Request<FeatureParams, any, Operation[], any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { featureName } = req.params;
|
||||
const updatedFeature = await this.featureService.updateVariants(
|
||||
featureName,
|
||||
req.body,
|
||||
);
|
||||
res.status(200).json({
|
||||
version: '1',
|
||||
variants: updatedFeature.variants,
|
||||
});
|
||||
}
|
||||
|
||||
async overwriteVariants(
|
||||
req: Request<FeatureParams, any, IVariant[], any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { featureName } = req.params;
|
||||
const updatedFeature = await this.featureService.saveVariants(
|
||||
featureName,
|
||||
req.body,
|
||||
);
|
||||
res.status(200).json({
|
||||
version: '1',
|
||||
variants: updatedFeature.variants,
|
||||
});
|
||||
}
|
||||
}
|
49
src/lib/routes/admin-api/user-splash-controller.ts
Normal file
49
src/lib/routes/admin-api/user-splash-controller.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Response } from 'express';
|
||||
|
||||
import Controller from '../controller';
|
||||
import { Logger } from '../../logger';
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
import { IUnleashServices } from '../../types/services';
|
||||
import UserSplashService from '../../services/user-splash-service';
|
||||
import { IAuthRequest } from '../unleash-types';
|
||||
|
||||
interface ISplashBody {
|
||||
seen: boolean;
|
||||
splashId: string;
|
||||
}
|
||||
|
||||
class UserSplashController extends Controller {
|
||||
private logger: Logger;
|
||||
|
||||
private userSplashService: UserSplashService;
|
||||
|
||||
constructor(
|
||||
config: IUnleashConfig,
|
||||
{ userSplashService }: Pick<IUnleashServices, 'userSplashService'>,
|
||||
) {
|
||||
super(config);
|
||||
this.logger = config.getLogger('splash-controller.ts');
|
||||
this.userSplashService = userSplashService;
|
||||
|
||||
this.post('/:id', this.updateSplashSettings);
|
||||
}
|
||||
|
||||
private async updateSplashSettings(
|
||||
req: IAuthRequest<any, any, ISplashBody, any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { user } = req;
|
||||
const { id } = req.params;
|
||||
|
||||
const splash = {
|
||||
splashId: id,
|
||||
userId: user.id,
|
||||
seen: true,
|
||||
};
|
||||
const updated = await this.userSplashService.updateSplash(splash);
|
||||
res.json(updated);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UserSplashController;
|
||||
export default UserSplashController;
|
@ -7,6 +7,7 @@ import { IUnleashServices } from '../../types/services';
|
||||
import UserService from '../../services/user-service';
|
||||
import SessionService from '../../services/session-service';
|
||||
import UserFeedbackService from '../../services/user-feedback-service';
|
||||
import UserSplashService from '../../services/user-splash-service';
|
||||
|
||||
interface IChangeUserRequest {
|
||||
password: string;
|
||||
@ -22,6 +23,8 @@ class UserController extends Controller {
|
||||
|
||||
private sessionService: SessionService;
|
||||
|
||||
private userSplashService: UserSplashService;
|
||||
|
||||
constructor(
|
||||
config: IUnleashConfig,
|
||||
{
|
||||
@ -29,12 +32,14 @@ class UserController extends Controller {
|
||||
userService,
|
||||
sessionService,
|
||||
userFeedbackService,
|
||||
userSplashService,
|
||||
}: Pick<
|
||||
IUnleashServices,
|
||||
| 'accessService'
|
||||
| 'userService'
|
||||
| 'sessionService'
|
||||
| 'userFeedbackService'
|
||||
| 'userSplashService'
|
||||
>,
|
||||
) {
|
||||
super(config);
|
||||
@ -42,6 +47,7 @@ class UserController extends Controller {
|
||||
this.userService = userService;
|
||||
this.sessionService = sessionService;
|
||||
this.userFeedbackService = userFeedbackService;
|
||||
this.userSplashService = userSplashService;
|
||||
|
||||
this.get('/', this.getUser);
|
||||
this.post('/change-password', this.updateUserPass);
|
||||
@ -57,11 +63,15 @@ class UserController extends Controller {
|
||||
const feedback = await this.userFeedbackService.getAllUserFeedback(
|
||||
user,
|
||||
);
|
||||
const splash = await this.userSplashService.getAllUserSplashs(user);
|
||||
|
||||
// TODO: remove this line after we remove it from db.
|
||||
delete user.permissions;
|
||||
|
||||
return res.status(200).json({ user, permissions, feedback }).end();
|
||||
return res
|
||||
.status(200)
|
||||
.json({ user, permissions, feedback, splash })
|
||||
.end();
|
||||
}
|
||||
|
||||
async updateUserPass(
|
||||
|
@ -42,6 +42,8 @@ export const variantsSchema = joi.object().keys({
|
||||
),
|
||||
});
|
||||
|
||||
export const variantsArraySchema = joi.array().min(0).items(variantsSchema);
|
||||
|
||||
export const featureMetadataSchema = joi
|
||||
.object()
|
||||
.keys({
|
||||
|
@ -5,7 +5,11 @@ 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 { featureMetadataSchema, nameSchema } from '../schema/feature-schema';
|
||||
import {
|
||||
featureMetadataSchema,
|
||||
nameSchema,
|
||||
variantsArraySchema,
|
||||
} from '../schema/feature-schema';
|
||||
import {
|
||||
FeatureArchivedEvent,
|
||||
FeatureChangeProjectEvent,
|
||||
@ -40,6 +44,8 @@ import {
|
||||
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';
|
||||
@ -389,6 +395,15 @@ class FeatureToggleService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@ -882,6 +897,63 @@ class FeatureToggleService {
|
||||
newProjectId,
|
||||
);
|
||||
}
|
||||
|
||||
async updateVariants(
|
||||
featureName: string,
|
||||
newVariants: Operation[],
|
||||
): Promise<FeatureToggle> {
|
||||
const oldVariants = await this.getVariants(featureName);
|
||||
const { newDocument } = await applyPatch(oldVariants, newVariants);
|
||||
return this.saveVariants(featureName, newDocument);
|
||||
}
|
||||
|
||||
async saveVariants(
|
||||
featureName: string,
|
||||
newVariants: IVariant[],
|
||||
): Promise<FeatureToggle> {
|
||||
await variantsArraySchema.validateAsync(newVariants);
|
||||
const fixedVariants = this.fixVariantWeights(newVariants);
|
||||
return this.featureToggleStore.saveVariants(featureName, fixedVariants);
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -27,6 +27,7 @@ import FeatureToggleService from './feature-toggle-service';
|
||||
import EnvironmentService from './environment-service';
|
||||
import FeatureTagService from './feature-tag-service';
|
||||
import ProjectHealthService from './project-health-service';
|
||||
import UserSplashService from './user-splash-service';
|
||||
|
||||
export const createServices = (
|
||||
stores: IUnleashStores,
|
||||
@ -72,6 +73,7 @@ export const createServices = (
|
||||
accessService,
|
||||
featureToggleServiceV2,
|
||||
);
|
||||
const userSplashService = new UserSplashService(stores, config);
|
||||
|
||||
return {
|
||||
accessService,
|
||||
@ -100,6 +102,7 @@ export const createServices = (
|
||||
userFeedbackService,
|
||||
featureTagService,
|
||||
projectHealthService,
|
||||
userSplashService,
|
||||
};
|
||||
};
|
||||
|
||||
|
54
src/lib/services/user-splash-service.ts
Normal file
54
src/lib/services/user-splash-service.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Logger } from '../logger';
|
||||
import { IUnleashStores } from '../types/stores';
|
||||
import { IUnleashConfig } from '../types/option';
|
||||
import User from '../types/user';
|
||||
import {
|
||||
IUserSplash,
|
||||
IUserSplashStore,
|
||||
} from '../types/stores/user-splash-store';
|
||||
|
||||
export default class UserSplashService {
|
||||
private userSplashStore: IUserSplashStore;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
{ userSplashStore }: Pick<IUnleashStores, 'userSplashStore'>,
|
||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
|
||||
) {
|
||||
this.userSplashStore = userSplashStore;
|
||||
this.logger = getLogger('services/user-splash-service.js');
|
||||
}
|
||||
|
||||
async getAllUserSplashs(user: User): Promise<Object> {
|
||||
if (user.isAPI) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const splashs = (
|
||||
await this.userSplashStore.getAllUserSplashs(user.id)
|
||||
).reduce(
|
||||
(splashObject, splash) => ({
|
||||
...splashObject,
|
||||
[splash.splashId]: splash.seen,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
return splashs;
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async getSplash(user_id: number, splash_id: string): Promise<IUserSplash> {
|
||||
return this.userSplashStore.getSplash(user_id, splash_id);
|
||||
}
|
||||
|
||||
async updateSplash(splash: IUserSplash): Promise<IUserSplash> {
|
||||
return this.userSplashStore.updateSplash(splash);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UserSplashService;
|
@ -8,7 +8,10 @@ export interface IConstraint {
|
||||
operator: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export enum WeightType {
|
||||
VARIABLE = 'variable',
|
||||
FIX = 'fix',
|
||||
}
|
||||
export interface IStrategyConfig {
|
||||
id?: string;
|
||||
name: string;
|
||||
|
@ -23,6 +23,7 @@ import EnvironmentService from '../services/environment-service';
|
||||
import FeatureTagService from '../services/feature-tag-service';
|
||||
import ProjectHealthService from '../services/project-health-service';
|
||||
import ClientMetricsServiceV2 from '../services/client-metrics/client-metrics-service-v2';
|
||||
import UserSplashService from '../services/user-splash-service';
|
||||
|
||||
export interface IUnleashServices {
|
||||
accessService: AccessService;
|
||||
@ -51,4 +52,5 @@ export interface IUnleashServices {
|
||||
userFeedbackService: UserFeedbackService;
|
||||
userService: UserService;
|
||||
versionService: VersionService;
|
||||
userSplashService: UserSplashService;
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ import { IFeatureStrategiesStore } from './stores/feature-strategies-store';
|
||||
import { IEnvironmentStore } from './stores/environment-store';
|
||||
import { IFeatureToggleClientStore } from './stores/feature-toggle-client-store';
|
||||
import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2';
|
||||
import { IUserSplashStore } from './stores/user-splash-store';
|
||||
|
||||
export interface IUnleashStores {
|
||||
accessStore: IAccessStore;
|
||||
@ -50,4 +51,5 @@ export interface IUnleashStores {
|
||||
tagTypeStore: ITagTypeStore;
|
||||
userFeedbackStore: IUserFeedbackStore;
|
||||
userStore: IUserStore;
|
||||
userSplashStore: IUserSplashStore;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FeatureToggle, FeatureToggleDTO } from '../model';
|
||||
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../model';
|
||||
import { Store } from './store';
|
||||
|
||||
export interface IFeatureToggleQuery {
|
||||
@ -16,4 +16,9 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||
archive(featureName: string): Promise<FeatureToggle>;
|
||||
revive(featureName: string): Promise<FeatureToggle>;
|
||||
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
||||
getVariants(featureName: string): Promise<IVariant[]>;
|
||||
saveVariants(
|
||||
featureName: string,
|
||||
newVariants: IVariant[],
|
||||
): Promise<FeatureToggle>;
|
||||
}
|
||||
|
18
src/lib/types/stores/user-splash-store.ts
Normal file
18
src/lib/types/stores/user-splash-store.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Store } from './store';
|
||||
|
||||
export interface IUserSplash {
|
||||
seen: boolean;
|
||||
splashId: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface IUserSplashKey {
|
||||
userId: number;
|
||||
splashId: string;
|
||||
}
|
||||
|
||||
export interface IUserSplashStore extends Store<IUserSplash, IUserSplashKey> {
|
||||
getAllUserSplashs(userId: number): Promise<IUserSplash[]>;
|
||||
getSplash(userId: number, splashId: string): Promise<IUserSplash>;
|
||||
updateSplash(splash: IUserSplash): Promise<IUserSplash>;
|
||||
}
|
25
src/migrations/20211108130333-create-user-splash-table.js
Normal file
25
src/migrations/20211108130333-create-user-splash-table.js
Normal file
@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
exports.up = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS user_splash
|
||||
(user_id INTEGER NOT NULL references users (id) ON DELETE CASCADE,
|
||||
splash_id TEXT,
|
||||
seen BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (user_id, splash_id));
|
||||
CREATE INDEX user_splash_user_id_idx ON user_splash (user_id);
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
DROP INDEX user_splash_user_id_idx;
|
||||
DROP TABLE user_splash;
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
10
src/migrations/20211109103930-add-splash-entry-for-users.js
Normal file
10
src/migrations/20211109103930-add-splash-entry-for-users.js
Normal file
@ -0,0 +1,10 @@
|
||||
exports.up = function (db, cb) {
|
||||
db.runSql(
|
||||
`INSERT INTO user_splash(splash_id, user_id, seen) SELECT 'environment', u.id, false FROM users u`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, cb) {
|
||||
db.runSql('DELETE FROM user_splash', cb);
|
||||
};
|
@ -173,7 +173,7 @@ test('should return toggle summary', async () => {
|
||||
|
||||
test('should only include last hour of metrics return toggle summary', async () => {
|
||||
const now = new Date();
|
||||
const dateOneHourAgo = subHours(now, 1);
|
||||
const dateTwoHoursAgo = subHours(now, 2);
|
||||
const metrics: IClientMetricsEnv[] = [
|
||||
{
|
||||
featureName: 'demo',
|
||||
@ -211,7 +211,7 @@ test('should only include last hour of metrics return toggle summary', async ()
|
||||
featureName: 'demo',
|
||||
appName: 'backend-api',
|
||||
environment: 'test',
|
||||
timestamp: dateOneHourAgo,
|
||||
timestamp: dateTwoHoursAgo,
|
||||
yes: 55,
|
||||
no: 55,
|
||||
},
|
||||
|
645
src/test/e2e/api/admin/project/variants.e2e.test.ts
Normal file
645
src/test/e2e/api/admin/project/variants.e2e.test.ts
Normal file
@ -0,0 +1,645 @@
|
||||
import { IUnleashTest, setupApp } from '../../../helpers/test-helper';
|
||||
import dbInit, { ITestDb } from '../../../helpers/database-init';
|
||||
import getLogger from '../../../../fixtures/no-logger';
|
||||
import * as jsonpatch from 'fast-json-patch';
|
||||
import { IVariant, WeightType } from '../../../../../lib/types/model';
|
||||
|
||||
let app: IUnleashTest;
|
||||
let db: ITestDb;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('project_feature_variants_api_serial', getLogger);
|
||||
app = await setupApp(db.stores);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.destroy();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('Can get variants for a feature', async () => {
|
||||
const featureName = 'feature-variants';
|
||||
const variantName = 'fancy-variant';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
variants: [
|
||||
{
|
||||
name: variantName,
|
||||
stickiness: 'default',
|
||||
weight: 1000,
|
||||
weightType: WeightType.VARIABLE,
|
||||
},
|
||||
],
|
||||
});
|
||||
await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.version).toBe('1');
|
||||
expect(res.body.variants).toHaveLength(1);
|
||||
expect(res.body.variants[0].name).toBe(variantName);
|
||||
});
|
||||
});
|
||||
|
||||
test('Trying to do operations on a non-existing feature yields 404', async () => {
|
||||
await app.request
|
||||
.get(
|
||||
'/api/admin/projects/default/features/non-existing-feature/variants',
|
||||
)
|
||||
.expect(404);
|
||||
const variants = [
|
||||
{
|
||||
name: 'variant-put-overwrites',
|
||||
stickiness: 'default',
|
||||
weight: 1000,
|
||||
weightType: WeightType.VARIABLE,
|
||||
},
|
||||
];
|
||||
await app.request
|
||||
.put('/api/admin/projects/default/features/${featureName}/variants')
|
||||
.send(variants)
|
||||
.expect(404);
|
||||
|
||||
const newVariants: IVariant[] = [];
|
||||
const observer = jsonpatch.observe(newVariants);
|
||||
newVariants.push({
|
||||
name: 'variant1',
|
||||
stickiness: 'default',
|
||||
weight: 700,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
let patch = jsonpatch.generate(observer);
|
||||
await app.request
|
||||
.patch('/api/admin/projects/default/features/${featureName}/variants')
|
||||
.send(patch)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
test('Can patch variants for a feature and get a response of new variant', async () => {
|
||||
const featureName = 'feature-variants-patch';
|
||||
const variantName = 'fancy-variant-patch';
|
||||
const expectedVariantName = 'not-so-cool-variant-name';
|
||||
const variants = [
|
||||
{
|
||||
name: variantName,
|
||||
stickiness: 'default',
|
||||
weight: 1000,
|
||||
weightType: WeightType.VARIABLE,
|
||||
},
|
||||
];
|
||||
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
variants,
|
||||
});
|
||||
|
||||
const observer = jsonpatch.observe(variants);
|
||||
variants[0].name = expectedVariantName;
|
||||
const patch = jsonpatch.generate(observer);
|
||||
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.version).toBe('1');
|
||||
expect(res.body.variants).toHaveLength(1);
|
||||
expect(res.body.variants[0].name).toBe(expectedVariantName);
|
||||
});
|
||||
});
|
||||
|
||||
test('Can add variant for a feature', async () => {
|
||||
const featureName = 'feature-variants-patch-add';
|
||||
const variantName = 'fancy-variant-patch';
|
||||
const expectedVariantName = 'not-so-cool-variant-name';
|
||||
const variants = [
|
||||
{
|
||||
name: variantName,
|
||||
stickiness: 'default',
|
||||
weight: 1000,
|
||||
weightType: WeightType.VARIABLE,
|
||||
},
|
||||
];
|
||||
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
variants,
|
||||
});
|
||||
|
||||
const observer = jsonpatch.observe(variants);
|
||||
variants.push({
|
||||
name: expectedVariantName,
|
||||
stickiness: 'default',
|
||||
weight: 1000,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
const patch = jsonpatch.generate(observer);
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200);
|
||||
|
||||
await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.expect((res) => {
|
||||
expect(res.body.version).toBe('1');
|
||||
expect(res.body.variants).toHaveLength(2);
|
||||
expect(
|
||||
res.body.variants.find((x) => x.name === expectedVariantName),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
res.body.variants.find((x) => x.name === variantName),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('Can remove variant for a feature', async () => {
|
||||
const featureName = 'feature-variants-patch-remove';
|
||||
const variantName = 'fancy-variant-patch';
|
||||
const variants = [
|
||||
{
|
||||
name: variantName,
|
||||
stickiness: 'default',
|
||||
weight: 1000,
|
||||
weightType: WeightType.VARIABLE,
|
||||
},
|
||||
];
|
||||
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
variants,
|
||||
});
|
||||
|
||||
const observer = jsonpatch.observe(variants);
|
||||
variants.pop();
|
||||
const patch = jsonpatch.generate(observer);
|
||||
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200);
|
||||
|
||||
await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.expect((res) => {
|
||||
expect(res.body.version).toBe('1');
|
||||
expect(res.body.variants).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT overwrites current variant on feature', async () => {
|
||||
const featureName = 'variant-put-overwrites';
|
||||
const variantName = 'overwriting-for-fun';
|
||||
const variants = [
|
||||
{
|
||||
name: variantName,
|
||||
stickiness: 'default',
|
||||
weight: 1000,
|
||||
weightType: WeightType.VARIABLE,
|
||||
},
|
||||
];
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
variants,
|
||||
});
|
||||
|
||||
const newVariants: IVariant[] = [
|
||||
{
|
||||
name: 'variant1',
|
||||
stickiness: 'default',
|
||||
weight: 250,
|
||||
weightType: WeightType.FIX,
|
||||
},
|
||||
{
|
||||
name: 'variant2',
|
||||
stickiness: 'default',
|
||||
weight: 375,
|
||||
weightType: WeightType.VARIABLE,
|
||||
},
|
||||
{
|
||||
name: 'variant3',
|
||||
stickiness: 'default',
|
||||
weight: 450,
|
||||
weightType: WeightType.VARIABLE,
|
||||
},
|
||||
];
|
||||
await app.request
|
||||
.put(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(newVariants)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.variants).toHaveLength(3);
|
||||
});
|
||||
await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.variants).toHaveLength(3);
|
||||
expect(res.body.variants.reduce((a, v) => a + v.weight, 0)).toEqual(
|
||||
1000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('PUTing an invalid variant throws 400 exception', async () => {
|
||||
const featureName = 'variants-validation-feature';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
});
|
||||
|
||||
const invalidJson = [
|
||||
{
|
||||
name: 'variant',
|
||||
weight: 500,
|
||||
weightType: 'party',
|
||||
},
|
||||
];
|
||||
await app.request
|
||||
.put(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(invalidJson)
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.details).toHaveLength(1);
|
||||
expect(res.body.details[0].message).toMatch(
|
||||
/.*weightType\" must be one of/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Invalid variant in PATCH also throws 400 exception', async () => {
|
||||
const featureName = 'patch-validation-feature';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
});
|
||||
|
||||
const invalidPatch = `[{
|
||||
"op": "add",
|
||||
"path": "/1",
|
||||
"value": {
|
||||
"name": "not-so-cool-variant-name",
|
||||
"stickiness": "default",
|
||||
"weight": 2000,
|
||||
"weightType": "variable"
|
||||
}
|
||||
}]`;
|
||||
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(invalidPatch)
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.details).toHaveLength(1);
|
||||
expect(res.body.details[0].message).toMatch(
|
||||
/.*weight\" must be less than or equal to 1000/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('PATCHING with all variable weightTypes forces weights to sum to no less than 1000 minus the number of variable variants', async () => {
|
||||
const featureName = 'variants-validation-with-all-variable-weights';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
});
|
||||
|
||||
const newVariants: IVariant[] = [];
|
||||
|
||||
const observer = jsonpatch.observe(newVariants);
|
||||
newVariants.push({
|
||||
name: 'variant1',
|
||||
stickiness: 'default',
|
||||
weight: 700,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
let patch = jsonpatch.generate(observer);
|
||||
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.variants).toHaveLength(1);
|
||||
expect(res.body.variants[0].weight).toEqual(1000);
|
||||
});
|
||||
|
||||
newVariants.push({
|
||||
name: 'variant2',
|
||||
stickiness: 'default',
|
||||
weight: 700,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
|
||||
patch = jsonpatch.generate(observer);
|
||||
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.variants).toHaveLength(2);
|
||||
expect(
|
||||
res.body.variants.every((x) => x.weight === 500),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
newVariants.push({
|
||||
name: 'variant3',
|
||||
stickiness: 'default',
|
||||
weight: 700,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
|
||||
patch = jsonpatch.generate(observer);
|
||||
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
res.body.variants.sort((v, other) => other.weight - v.weight);
|
||||
expect(res.body.variants).toHaveLength(3);
|
||||
expect(res.body.variants[0].weight).toBe(334);
|
||||
expect(res.body.variants[1].weight).toBe(333);
|
||||
expect(res.body.variants[2].weight).toBe(333);
|
||||
});
|
||||
|
||||
newVariants.push({
|
||||
name: 'variant4',
|
||||
stickiness: 'default',
|
||||
weight: 700,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
|
||||
patch = jsonpatch.generate(observer);
|
||||
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.variants).toHaveLength(4);
|
||||
expect(
|
||||
res.body.variants.every((x) => x.weight === 250),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('PATCHING with no variable variants fails with 400', async () => {
|
||||
const featureName = 'variants-validation-with-no-variable-weights';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
});
|
||||
|
||||
const newVariants: IVariant[] = [];
|
||||
|
||||
const observer = jsonpatch.observe(newVariants);
|
||||
newVariants.push({
|
||||
name: 'variant1',
|
||||
stickiness: 'default',
|
||||
weight: 900,
|
||||
weightType: WeightType.FIX,
|
||||
});
|
||||
|
||||
const patch = jsonpatch.generate(observer);
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.details).toHaveLength(1);
|
||||
expect(res.body.details[0].message).toEqual(
|
||||
'There must be at least one "variable" variant',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Patching with a fixed variant and variable variants splits remaining weight among variable variants', async () => {
|
||||
const featureName = 'variants-fixed-and-variable';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
});
|
||||
|
||||
const newVariants: IVariant[] = [];
|
||||
const observer = jsonpatch.observe(newVariants);
|
||||
newVariants.push({
|
||||
name: 'variant1',
|
||||
stickiness: 'default',
|
||||
weight: 900,
|
||||
weightType: WeightType.FIX,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant2',
|
||||
stickiness: 'default',
|
||||
weight: 20,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant3',
|
||||
stickiness: 'default',
|
||||
weight: 123,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant4',
|
||||
stickiness: 'default',
|
||||
weight: 123,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant5',
|
||||
stickiness: 'default',
|
||||
weight: 123,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant6',
|
||||
stickiness: 'default',
|
||||
weight: 123,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant7',
|
||||
stickiness: 'default',
|
||||
weight: 123,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
|
||||
const patch = jsonpatch.generate(observer);
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200);
|
||||
|
||||
await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
let body = res.body;
|
||||
expect(body.variants).toHaveLength(7);
|
||||
expect(
|
||||
body.variants.reduce((total, v) => total + v.weight, 0),
|
||||
).toEqual(1000);
|
||||
body.variants.sort((a, b) => b.weight - a.weight);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant1').weight,
|
||||
).toEqual(900);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant2').weight,
|
||||
).toEqual(17);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant3').weight,
|
||||
).toEqual(17);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant4').weight,
|
||||
).toEqual(17);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant5').weight,
|
||||
).toEqual(17);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant6').weight,
|
||||
).toEqual(16);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant7').weight,
|
||||
).toEqual(16);
|
||||
});
|
||||
});
|
||||
|
||||
test('Multiple fixed variants gets added together to decide how much weight variable variants should get', async () => {
|
||||
const featureName = 'variants-multiple-fixed-and-variable';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
});
|
||||
|
||||
const newVariants: IVariant[] = [];
|
||||
|
||||
const observer = jsonpatch.observe(newVariants);
|
||||
newVariants.push({
|
||||
name: 'variant1',
|
||||
stickiness: 'default',
|
||||
weight: 600,
|
||||
weightType: WeightType.FIX,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant2',
|
||||
stickiness: 'default',
|
||||
weight: 350,
|
||||
weightType: WeightType.FIX,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant3',
|
||||
stickiness: 'default',
|
||||
weight: 350,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
|
||||
const patch = jsonpatch.generate(observer);
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200);
|
||||
await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
let body = res.body;
|
||||
expect(body.variants).toHaveLength(3);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant3').weight,
|
||||
).toEqual(50);
|
||||
});
|
||||
});
|
||||
|
||||
test('If sum of fixed variant weight exceed 1000 fails with 400', async () => {
|
||||
const featureName = 'variants-fixed-weight-over-1000';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
});
|
||||
|
||||
const newVariants: IVariant[] = [];
|
||||
|
||||
const observer = jsonpatch.observe(newVariants);
|
||||
newVariants.push({
|
||||
name: 'variant1',
|
||||
stickiness: 'default',
|
||||
weight: 900,
|
||||
weightType: WeightType.FIX,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant2',
|
||||
stickiness: 'default',
|
||||
weight: 900,
|
||||
weightType: WeightType.FIX,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant3',
|
||||
stickiness: 'default',
|
||||
weight: 350,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
|
||||
const patch = jsonpatch.generate(observer);
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.details).toHaveLength(1);
|
||||
expect(res.body.details[0].message).toEqual(
|
||||
'The traffic distribution total must equal 100%',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('If sum of fixed variant weight equals 1000 variable variants gets weight 0', async () => {
|
||||
const featureName = 'variants-fixed-weight-equals-1000-no-variable-weight';
|
||||
await db.stores.featureToggleStore.create('default', {
|
||||
name: featureName,
|
||||
});
|
||||
|
||||
const newVariants: IVariant[] = [];
|
||||
|
||||
const observer = jsonpatch.observe(newVariants);
|
||||
newVariants.push({
|
||||
name: 'variant1',
|
||||
stickiness: 'default',
|
||||
weight: 900,
|
||||
weightType: WeightType.FIX,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant2',
|
||||
stickiness: 'default',
|
||||
weight: 100,
|
||||
weightType: WeightType.FIX,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant3',
|
||||
stickiness: 'default',
|
||||
weight: 350,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
newVariants.push({
|
||||
name: 'variant4',
|
||||
stickiness: 'default',
|
||||
weight: 350,
|
||||
weightType: WeightType.VARIABLE,
|
||||
});
|
||||
|
||||
const patch = jsonpatch.generate(observer);
|
||||
await app.request
|
||||
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.send(patch)
|
||||
.expect(200);
|
||||
await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
let body = res.body;
|
||||
expect(body.variants).toHaveLength(4);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant3').weight,
|
||||
).toEqual(0);
|
||||
expect(
|
||||
body.variants.find((v) => v.name === 'variant4').weight,
|
||||
).toEqual(0);
|
||||
});
|
||||
});
|
68
src/test/e2e/api/admin/splash.e2e.test.ts
Normal file
68
src/test/e2e/api/admin/splash.e2e.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { Application, NextFunction, Request, Response } from 'express';
|
||||
import { setupAppWithCustomAuth } from '../../helpers/test-helper';
|
||||
import dbInit from '../../helpers/database-init';
|
||||
import getLogger from '../../../fixtures/no-logger';
|
||||
import { IUnleashConfig } from '../../../../lib/types/option';
|
||||
import { IUnleashServices } from '../../../../lib/types/services';
|
||||
|
||||
let stores;
|
||||
let db;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('splash_api_serial', getLogger);
|
||||
stores = db.stores;
|
||||
|
||||
const email = 'custom-user@mail.com';
|
||||
|
||||
const preHook = (
|
||||
application: Application,
|
||||
config: IUnleashConfig,
|
||||
{ userService }: IUnleashServices,
|
||||
) => {
|
||||
application.use(
|
||||
'/api/admin/',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
// @ts-ignore
|
||||
req.user = await userService.loginUserWithoutPassword(
|
||||
email,
|
||||
true,
|
||||
);
|
||||
next();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
app = await setupAppWithCustomAuth(stores, preHook);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.destroy();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('it updates splash for user', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
return app.request
|
||||
.post('/api/admin/splash/environment')
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.seen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('it retrieves splash for user', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
return app.request
|
||||
.get('/api/admin/user')
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.splash).toStrictEqual({ environment: true });
|
||||
});
|
||||
});
|
82
src/test/e2e/stores/user-splash-store.e2e.test.ts
Normal file
82
src/test/e2e/stores/user-splash-store.e2e.test.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { IUserSplashStore } from 'lib/types/stores/user-splash-store';
|
||||
import { IUserStore } from 'lib/types/stores/user-store';
|
||||
import dbInit from '../helpers/database-init';
|
||||
import getLogger from '../../fixtures/no-logger';
|
||||
|
||||
let stores;
|
||||
let db;
|
||||
let userSplashStore: IUserSplashStore;
|
||||
let userStore: IUserStore;
|
||||
let currentUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('user_splash_store', getLogger);
|
||||
stores = db.stores;
|
||||
userSplashStore = stores.userSplashStore;
|
||||
userStore = stores.userStore;
|
||||
currentUser = await userStore.upsert({ email: 'me.feedback@mail.com' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await userSplashStore.deleteAll();
|
||||
});
|
||||
|
||||
test('should create userSplash', async () => {
|
||||
await userSplashStore.updateSplash({
|
||||
splashId: 'some-id',
|
||||
userId: currentUser.id,
|
||||
seen: false,
|
||||
});
|
||||
const userSplashs = await userSplashStore.getAllUserSplashs(currentUser.id);
|
||||
expect(userSplashs).toHaveLength(1);
|
||||
expect(userSplashs[0].splashId).toBe('some-id');
|
||||
});
|
||||
|
||||
test('should get userSplash', async () => {
|
||||
await userSplashStore.updateSplash({
|
||||
splashId: 'some-id',
|
||||
userId: currentUser.id,
|
||||
seen: false,
|
||||
});
|
||||
const userSplash = await userSplashStore.getSplash(
|
||||
currentUser.id,
|
||||
'some-id',
|
||||
);
|
||||
expect(userSplash.splashId).toBe('some-id');
|
||||
});
|
||||
|
||||
test('should exists', async () => {
|
||||
await userSplashStore.updateSplash({
|
||||
splashId: 'some-id-3',
|
||||
userId: currentUser.id,
|
||||
seen: false,
|
||||
});
|
||||
const exists = await userSplashStore.exists({
|
||||
userId: currentUser.id,
|
||||
splashId: 'some-id-3',
|
||||
});
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
test('should not exists', async () => {
|
||||
const exists = await userSplashStore.exists({
|
||||
userId: currentUser.id,
|
||||
splashId: 'some-id-not-here',
|
||||
});
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
test('should get all userSplashs', async () => {
|
||||
await userSplashStore.updateSplash({
|
||||
splashId: 'some-id-2',
|
||||
userId: currentUser.id,
|
||||
seen: false,
|
||||
});
|
||||
const userSplashs = await userSplashStore.getAll();
|
||||
expect(userSplashs).toHaveLength(1);
|
||||
expect(userSplashs[0].splashId).toBe('some-id-2');
|
||||
});
|
20
src/test/fixtures/fake-feature-toggle-store.ts
vendored
20
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -2,7 +2,11 @@ import {
|
||||
IFeatureToggleQuery,
|
||||
IFeatureToggleStore,
|
||||
} from '../../lib/types/stores/feature-toggle-store';
|
||||
import { FeatureToggle, FeatureToggleDTO } from '../../lib/types/model';
|
||||
import {
|
||||
FeatureToggle,
|
||||
FeatureToggleDTO,
|
||||
IVariant,
|
||||
} from '../../lib/types/model';
|
||||
import NotFoundError from '../../lib/error/notfound-error';
|
||||
|
||||
export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||
@ -123,4 +127,18 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getVariants(featureName: string): Promise<IVariant[]> {
|
||||
const feature = await this.get(featureName);
|
||||
return feature.variants;
|
||||
}
|
||||
|
||||
async saveVariants(
|
||||
featureName: string,
|
||||
newVariants: IVariant[],
|
||||
): Promise<FeatureToggle> {
|
||||
const feature = await this.get(featureName);
|
||||
feature.variants = newVariants;
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
|
50
src/test/fixtures/fake-user-splash-store.ts
vendored
Normal file
50
src/test/fixtures/fake-user-splash-store.ts
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
import {
|
||||
IUserSplashKey,
|
||||
IUserSplash,
|
||||
IUserSplashStore,
|
||||
} from '../../lib/types/stores/user-splash-store';
|
||||
|
||||
export default class FakeUserSplashStore implements IUserSplashStore {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getAllUserSplashs(userId: number): Promise<IUserSplash[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getSplash(userId: number, splashId: string): Promise<IUserSplash> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
updateSplash(splash: IUserSplash): Promise<IUserSplash> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
exists(key: IUserSplashKey): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
get(key: IUserSplashKey): Promise<IUserSplash> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getAll(): Promise<IUserSplash[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
delete(key: IUserSplashKey): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
deleteAll(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
destroy(): void {}
|
||||
}
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -24,6 +24,7 @@ import FakeFeatureTypeStore from './fake-feature-type-store';
|
||||
import FakeResetTokenStore from './fake-reset-token-store';
|
||||
import FakeFeatureToggleClientStore from './fake-feature-toggle-client-store';
|
||||
import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2';
|
||||
import FakeUserSplashStore from './fake-user-splash-store';
|
||||
|
||||
const createStores: () => IUnleashStores = () => {
|
||||
const db = {
|
||||
@ -59,6 +60,7 @@ const createStores: () => IUnleashStores = () => {
|
||||
featureTypeStore: new FakeFeatureTypeStore(),
|
||||
resetTokenStore: new FakeResetTokenStore(),
|
||||
sessionStore: new FakeSessionStore(),
|
||||
userSplashStore: new FakeUserSplashStore(),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -5,24 +5,38 @@ title: Feature Toggle Types
|
||||
|
||||
> This feature was introduced in _Unleash v3.5.0_.
|
||||
|
||||
Starting with version `3.5.0` Unleash introduces the concept of feature toggle types. The toggle types are heavily inspired by [Pete Hodgson's article on feature toggles](https://martinfowler.com/articles/feature-toggles.html).
|
||||
You can use feature toggles to support different use cases, each with their own specific needs. Heavily inspired by [Pete Hodgson's article on feature toggles](https://martinfowler.com/articles/feature-toggles.html), Unleash introduced the concept of feature toggle types in version `3.5.0`.
|
||||
|
||||
The idea is to make it easier for teams to manage their feature toggles, if they can more clearly classify them. The classification will also help us understand the [expected feature toggle lifetime](https://www.getunleash.io/blog/feature-toggle-life-time-best-practices). Some feature toggles are meant to live for a few weeks, while we work on the new functionality, while others are of a more permanent nature.
|
||||
A feature toggle's type affects only two things:
|
||||
1. It gives the toggle an appropriate icon
|
||||
2. The toggle's expected lifetime changes
|
||||
|
||||
Feature toggle types currently supported by Unleash:
|
||||
Aside from this, there are no differences between the toggle types and you can always change the type of a toggle after you have created it.
|
||||
|
||||
- **Release** - Used to enable trunk-based development for teams practicing Continuous Delivery. _Expected lifetime 40 days_
|
||||
- **Experiment** - Used to perform multivariate or A/B testing. _Expected lifetime 40 days_
|
||||
- **Operational** - Used to control operational aspects of the system's behavior. _Expected lifetime 7 days_
|
||||
- **Kill switch** - Used to gracefully degrade system functionality. _(permanent)_
|
||||
- **Permission** - Used to change the features or product experience that certain users receive. _(permanent)_
|
||||
Classifying feature toggles by their type makes it easier for you manage them: the toggles get different icons in the toggle list and you can sort the toggles by their types.
|
||||
|
||||
### Deprecate a feature toggle {#deprecate-a-feature-toggle}
|
||||

|
||||
|
||||
Feature toggles can now also be marked as `stale`. You can think of this as a way to deprecate a feature toggle, without removing the active configuration for connected applications. This allows us to clearly signal that we should stop using the feature toggle in our applications.
|
||||
A toggle's type also helps Unleash understand the [toggle's expected lifetime](https://www.getunleash.io/blog/feature-toggle-life-time-best-practices): some feature toggles are meant to live for a few weeks as you work on new functionality, while others stay in for much longer. When a feature toggle lives past its expected lifetime, Unleash will mark it as _potentially stale_. See the [technical debt section](../user_guide/technical_debt) for more information on what this means and how to handle it.
|
||||
|
||||
The `stale` property can utilized to help us manage "feature toggle debt" in various ways:
|
||||
## Feature toggle types
|
||||
|
||||
- Inform the developer working locally when we detect usage of a stale feature toggle.
|
||||
- Use it to break the build if the code contains stale feature toggles.
|
||||
- Send automatic PR to remove usage of completed toggles.
|
||||
Here's the list of the feature toggle types that Unleash supports together with their intended use case and expected lifetime:
|
||||
|
||||
- **Release** - Enable trunk-based development for teams practicing Continuous Delivery. _Expected lifetime 40 days_
|
||||
- **Experiment** - Perform multivariate or A/B testing. _Expected lifetime 40 days_
|
||||
- **Operational** - Control operational aspects of the system's behavior. _Expected lifetime 7 days_
|
||||
- **Kill switch** - Gracefully degrade system functionality. _(permanent)_
|
||||
- **Permission** - Change the features or product experience that certain users receive. _(permanent)_
|
||||
|
||||
## Deprecating feature toggles {#deprecate-a-feature-toggle}
|
||||
|
||||
You can mark feature toggles as `stale`. This is a way to deprecate a feature toggle without removing the active configuration for connected applications. Use this to signal that you should stop using the feature in your applications. Stale toggles will show as stale in the ["technical debt dashboard"](../user_guide/technical_debt).
|
||||
|
||||
When you mark a toggle as stale, Unleash will emit an event. You can use [an addon](https://docs.getunleash.io/addons/index) to integrate this with your systems, for instance to post a message in a Slack channel.
|
||||
|
||||
Additionally, with some extra work, you can also use the `stale` property to:
|
||||
|
||||
- Inform developers that a toggle is stale _while_ they're developing.
|
||||
- Break a project build if the code contains stale feature toggles.
|
||||
- Send automatic PRs to remove usage of toggles that have served their purpose.
|
||||
|
@ -9,7 +9,7 @@ title: /api/admin/projects/:projectId
|
||||
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 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:
|
||||
@ -506,3 +506,98 @@ Transfer-Encoding: chunked
|
||||
Possible Errors:
|
||||
|
||||
- _409 Conflict_ - You can not enable the environment before it has strategies.
|
||||
|
||||
## Feature Variants
|
||||
|
||||
### Put variants for Feature Toggle {#update-variants}
|
||||
|
||||
This overwrites the current variants for the feature toggle specified in the :featureName parameter.
|
||||
The backend will validate the input for the following invariants
|
||||
|
||||
* If there are variants, there needs to be at least one variant with `weightType: variable`
|
||||
* The sum of the weights of variants with `weightType: fix` must be below 1000 (< 1000)
|
||||
|
||||
The backend will also distribute remaining weight up to 1000 after adding the variants with `weightType: fix` together amongst the variants of `weightType: variable`
|
||||
|
||||
**Example Query**
|
||||
```bash
|
||||
echo '[
|
||||
{
|
||||
"name": "variant1",
|
||||
"weightType": "fix",
|
||||
"weight": 650
|
||||
},
|
||||
{
|
||||
"name": "variant2",
|
||||
"weightType": "variable",
|
||||
"weight": 123
|
||||
}
|
||||
]' | \
|
||||
http PUT http://localhost:4242/api/admin/projects/default/features/demo/variants Authorization:$KEY
|
||||
```
|
||||
|
||||
**Example response:**
|
||||
|
||||
```sh
|
||||
HTTP/1.1 200 OK
|
||||
Access-Control-Allow-Origin: *
|
||||
Connection: keep-alive
|
||||
Date: Tue, 23 Nov 2021 08:46:32 GMT
|
||||
Keep-Alive: timeout=60
|
||||
Transfer-Encoding: chunked
|
||||
Content-Type: application/json; charset=utf-8
|
||||
|
||||
{
|
||||
"version": "1",
|
||||
"variants": [
|
||||
{
|
||||
"name": "variant2",
|
||||
"weightType": "variable",
|
||||
"weight": 350
|
||||
},
|
||||
{
|
||||
"name": "variant1",
|
||||
"weightType": "fix",
|
||||
"weight": 650
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### PATCH variants for a feature toggle
|
||||
|
||||
**Example Query**
|
||||
|
||||
```sh
|
||||
echo '[{"op": "add", "path": "/1", "value": {
|
||||
"name": "new-variant",
|
||||
"weightType": "fix",
|
||||
"weight": 200
|
||||
}}]' | \
|
||||
http PATCH http://localhost:4242/api/admin/projects/default/features/demo/variants Authorization:$KEY
|
||||
```
|
||||
|
||||
** Example Response **
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"variants": [
|
||||
{
|
||||
"name": "variant2",
|
||||
"weightType": "variable",
|
||||
"weight": 150
|
||||
},
|
||||
{
|
||||
"name": "new-variant",
|
||||
"weightType": "fix",
|
||||
"weight": 200
|
||||
},
|
||||
{
|
||||
"name": "variant1",
|
||||
"weightType": "fix",
|
||||
"weight": 650
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -57,11 +57,13 @@ docker run -p 4242:4242 \
|
||||
-e DATABASE_HOST=postgres -e DATABASE_NAME=unleash \
|
||||
-e DATABASE_USERNAME=unleash_user -e DATABASE_PASSWORD=some_password \
|
||||
-e DATABASE_SSL=false \
|
||||
--network unleash unleashorg/unleash-server
|
||||
--network unleash --pull=always unleashorg/unleash-server
|
||||
```
|
||||
|
||||
### Option 2 - use Docker-compose {#option-two---use-docker-compose}
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Clone the [unleash-docker](https://github.com/Unleash/unleash-docker) repository.
|
||||
2. Run `docker-compose build` in repository root folder.
|
||||
3. Run `docker-compose up` in repository root folder.
|
||||
@ -95,7 +97,7 @@ docker run -p 4242:4242 \
|
||||
port: 4242,
|
||||
},
|
||||
})
|
||||
.then(unleash => {
|
||||
.then((unleash) => {
|
||||
console.log(
|
||||
`Unleash started on http://localhost:${unleash.app.get('port')}`,
|
||||
);
|
||||
|
@ -21,7 +21,7 @@ Unleash comes with a set of built in strategies. [But you can also build your ow
|
||||
|
||||
All our SDKs perform local evaluation of feature toggles, which means that they download the configuration from unleash and cache the configuration in memory in your application. This is done in order to avoid adding network latency to user interactions, making it unnoticable for users that you are using feature flagging, in addition to the added benefit that none of your data leaves your application - enforcing privacy by design.
|
||||
|
||||
[Read more about our unique architecture here](https://www.getunleash.io/blog/our-unique-architecture)
|
||||
[Read more about our unique architecture here.](https://www.getunleash.io/blog/our-unique-architecture)
|
||||
|
||||
## Unleash Context
|
||||
|
||||
@ -31,10 +31,16 @@ Since the SDKs perform local evaluation, some of the parameters needed for evalu
|
||||
|
||||
## API architecture
|
||||
|
||||
The Unleash API is split into two. One API is for the clients connecting unleash and is located under the path /api/client, and provides access to retrieving saved feature toggle configurations, metrics and registering the application.
|
||||
The Unleash API is split into two. One API is for the clients connecting to unleash. It is located under the path /api/client. This provides access to retrieving saved feature toggle configurations, metrics and registering the application.
|
||||
|
||||
The second API is the admin API, which is utilised in order to control any CRUD aspect of unleash resources. The split ensures a second layer of security that ensures that in the case you should loose your client api key, attackers will only have read-only access to your feature toggle configurations.
|
||||
|
||||
This ensures that we can have different data responses for the client API endpoints which will include less metadata, and be cached more heavily - optimising the SDK endpoints for best performance.
|
||||
|
||||
[Read more about unleash API here](../api)
|
||||
[Read more about unleash API here.](../api)
|
||||
|
||||
## Feature toggle types
|
||||
|
||||
Unleash categorizes feature toggles into five distinct types. This categorization makes it easier for you to see what the purpose of a toggle is and helps Unleash with [managing technical debt](/user_guide/technical_debt). A feature toggle's type has no effect on how the toggle behaves or how you can configure it.
|
||||
|
||||
[Read more about feature toggle types here.](../advanced/feature-toggle-types.md)
|
||||
|
@ -198,27 +198,42 @@ unleash.on('synchronized', () => {
|
||||
|
||||
## I want to run Unleash locally
|
||||
|
||||
### Run Unleash with Docker
|
||||
### Run Unleash with Docker {#run-unleash-with-docker}
|
||||
|
||||
The easiest way to run unleash locally is using [docker](https://www.docker.com/).
|
||||
|
||||
:::tip
|
||||
|
||||
Each container that runs in your local Docker instance must have a unique name. If you've run these commands before, you can either start the containers again (`docker start ${CONTAINER_NAME}`) or remove them (`docker rm ${CONTAINER_NAME}`) and run the commands again.
|
||||
|
||||
:::
|
||||
|
||||
1. Create a network by running `docker network create unleash`
|
||||
2. Start a postgres database:
|
||||
|
||||
```sh
|
||||
docker run -e POSTGRES_PASSWORD=some_password \
|
||||
-e POSTGRES_USER=unleash_user -e POSTGRES_DB=unleash \
|
||||
--network unleash --name postgres postgres
|
||||
docker run \
|
||||
-e POSTGRES_USER=unleash_user \
|
||||
-e POSTGRES_PASSWORD=some_password \
|
||||
-e POSTGRES_DB=unleash \
|
||||
--network unleash \
|
||||
--name postgres \
|
||||
postgres
|
||||
```
|
||||
|
||||
3. Start Unleash via docker:
|
||||
|
||||
```sh
|
||||
docker run -p 4242:4242 \
|
||||
-e DATABASE_HOST=postgres -e DATABASE_NAME=unleash \
|
||||
-e DATABASE_USERNAME=unleash_user -e DATABASE_PASSWORD=some_password \
|
||||
docker run \
|
||||
-p 4242:4242 \
|
||||
-e DATABASE_HOST=postgres \
|
||||
-e DATABASE_NAME=unleash \
|
||||
-e DATABASE_USERNAME=unleash_user \
|
||||
-e DATABASE_PASSWORD=some_password \
|
||||
-e DATABASE_SSL=false \
|
||||
--network unleash unleashorg/unleash-server
|
||||
--network unleash \
|
||||
--name unleash \
|
||||
--pull=always unleashorg/unleash-server
|
||||
```
|
||||
|
||||
[Click here to see all options to get started locally.](deploy/getting-started.md)
|
||||
@ -232,6 +247,58 @@ username: admin
|
||||
password: unleash4all
|
||||
```
|
||||
|
||||
### Run Unleash and the Unleash proxy with Docker
|
||||
|
||||
Follow steps outlined in the [Run Unleash with Docker](#run-unleash-with-docker) section to get the Unleash instance up and running. Once you have done that you need to first get an API key from your Unleash instance and then use that API key when starting the Unleash proxy.
|
||||
|
||||
1. Get an API key.
|
||||
|
||||
To get an API key, access your Unleash instance in a web browser. First, navigate to the API access screen.
|
||||
|
||||
[![The Unleash UI showing a dropdown menu under the Configure menu
|
||||
entry. The dropdown menu's API Access option is highlighted and
|
||||
you're told to navigate there.]](/img/api_access_navigation.png 'Navigate to the API access page.')
|
||||
|
||||
Next, create an API key with these details
|
||||
|
||||
- **name:** proxy-key (this can be whatever you want)
|
||||
- **token type:** client
|
||||
- **project:** all
|
||||
- **environment:** select your preferred environment (this option is only available in Unleash 4.3 and later)
|
||||
|
||||
Copy the API key to your clipboard. You'll need it in the next step.
|
||||
|
||||
:::note
|
||||
|
||||
Depending on whether you have the environments feature enabled or not, the API key will look a little different. If you don't have environments enabled, it'll just be a 64 character long hexadecimal string (for instance `be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178`). If you do have environments enabled, the key will be prefixed with the project and the environment that the key is valid for. It'll use the format `<project>:<environment>.<key>`, e.g. `demo-app:production.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178`.
|
||||
|
||||
Regardless of which format your string uses, do not modify it.
|
||||
|
||||
:::
|
||||
|
||||
2. Start the Unleash proxy
|
||||
|
||||
Start a container with the Unleash proxy by running the following command. Replace `${API_KEY}` with the key you created in the following step.
|
||||
|
||||
```sh
|
||||
docker run \
|
||||
-e UNLEASH_PROXY_SECRETS=some-secret \
|
||||
-e UNLEASH_URL='http://unleash:4242/api/' \
|
||||
-e UNLEASH_API_TOKEN='${API_KEY}' \
|
||||
-p 3000:3000 \
|
||||
--network unleash \
|
||||
--name unleash-proxy \
|
||||
--pull=always unleashorg/unleash-proxy
|
||||
```
|
||||
|
||||
3. Test the proxy
|
||||
|
||||
To make sure the proxy is running successfully, you can test it by running the following command:
|
||||
|
||||
```curl
|
||||
curl http://localhost:3000/proxy -H "Authorization: some-secret"
|
||||
```
|
||||
|
||||
### Create your first toggle
|
||||
|
||||
In order to create a toggle through the UI, [you can follow this guide](create-feature-toggle.md). Once you have created your feature toggle, you are ready to connect your application using an SDK.
|
||||
|
BIN
website/static/img/api_access_navigation.png
Normal file
BIN
website/static/img/api_access_navigation.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 82 KiB |
BIN
website/static/img/toggle_type_icons.png
Normal file
BIN
website/static/img/toggle_type_icons.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 209 KiB |
59
yarn.lock
59
yarn.lock
@ -2335,29 +2335,6 @@ errorhandler@^1.5.1:
|
||||
accepts "~1.3.7"
|
||||
escape-html "~1.0.3"
|
||||
|
||||
es-abstract@^1.18.2:
|
||||
version "1.18.5"
|
||||
resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.5.tgz"
|
||||
integrity sha512-DDggyJLoS91CkJjgauM5c0yZMjiD1uK3KcaCeAmffGwZ+ODWzOkPN4QwRbsK5DOFf06fywmyLci3ZD8jLGhVYA==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
es-to-primitive "^1.2.1"
|
||||
function-bind "^1.1.1"
|
||||
get-intrinsic "^1.1.1"
|
||||
has "^1.0.3"
|
||||
has-symbols "^1.0.2"
|
||||
internal-slot "^1.0.3"
|
||||
is-callable "^1.2.3"
|
||||
is-negative-zero "^2.0.1"
|
||||
is-regex "^1.1.3"
|
||||
is-string "^1.0.6"
|
||||
object-inspect "^1.11.0"
|
||||
object-keys "^1.1.1"
|
||||
object.assign "^4.1.2"
|
||||
string.prototype.trimend "^1.0.4"
|
||||
string.prototype.trimstart "^1.0.4"
|
||||
unbox-primitive "^1.0.1"
|
||||
|
||||
es-abstract@^1.19.0, es-abstract@^1.19.1:
|
||||
version "1.19.1"
|
||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3"
|
||||
@ -2466,7 +2443,7 @@ escodegen@^2.0.0:
|
||||
optionalDependencies:
|
||||
source-map "~0.6.1"
|
||||
|
||||
eslint-config-airbnb-base@15.0.0:
|
||||
eslint-config-airbnb-base@15.0.0, eslint-config-airbnb-base@^15.0.0:
|
||||
version "15.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz#6b09add90ac79c2f8d723a2580e07f3925afd236"
|
||||
integrity sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==
|
||||
@ -2476,21 +2453,12 @@ eslint-config-airbnb-base@15.0.0:
|
||||
object.entries "^1.1.5"
|
||||
semver "^6.3.0"
|
||||
|
||||
eslint-config-airbnb-base@^14.2.1:
|
||||
version "14.2.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz#8a2eb38455dc5a312550193b319cdaeef042cd1e"
|
||||
integrity sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA==
|
||||
eslint-config-airbnb-typescript@16.0.0:
|
||||
version "16.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-16.0.0.tgz#75007e27d5a7fb75530721f48197817c1d2ad4d1"
|
||||
integrity sha512-qDOyD0YYZo5Us1YvOnWig2Ly/+IlQKmMZpnqKnJgVtHdK8SkjaSyVBHKbD41dEaQxk8vRVGBC94PuR2ceSwbLQ==
|
||||
dependencies:
|
||||
confusing-browser-globals "^1.0.10"
|
||||
object.assign "^4.1.2"
|
||||
object.entries "^1.1.2"
|
||||
|
||||
eslint-config-airbnb-typescript@15.0.0:
|
||||
version "15.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-15.0.0.tgz#c88007b3cca5dd0f47125420ca5e8f6efac418fd"
|
||||
integrity sha512-DTWGwqytbTnB8kSKtmkrGkRf3xwTs2l15shSH0w/3Img47AQwCCrIA/ON/Uj0XXBxP31LHyEItPXeuH3mqCNLA==
|
||||
dependencies:
|
||||
eslint-config-airbnb-base "^14.2.1"
|
||||
eslint-config-airbnb-base "^15.0.0"
|
||||
|
||||
eslint-config-prettier@8.3.0:
|
||||
version "8.3.0"
|
||||
@ -3635,7 +3603,7 @@ is-buffer@^1.1.5:
|
||||
resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz"
|
||||
integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
|
||||
|
||||
is-callable@^1.1.4, is-callable@^1.2.3, is-callable@^1.2.4:
|
||||
is-callable@^1.1.4, is-callable@^1.2.4:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz"
|
||||
integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
|
||||
@ -3824,7 +3792,7 @@ is-promise@^2.2.2:
|
||||
resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz"
|
||||
integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==
|
||||
|
||||
is-regex@^1.1.3, is-regex@^1.1.4:
|
||||
is-regex@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz"
|
||||
integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==
|
||||
@ -3854,7 +3822,7 @@ is-stream@^2.0.0:
|
||||
resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz"
|
||||
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
|
||||
|
||||
is-string@^1.0.5, is-string@^1.0.6, is-string@^1.0.7:
|
||||
is-string@^1.0.5, is-string@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz"
|
||||
integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==
|
||||
@ -5274,15 +5242,6 @@ object.defaults@^1.1.0:
|
||||
for-own "^1.0.0"
|
||||
isobject "^3.0.0"
|
||||
|
||||
object.entries@^1.1.2:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.4.tgz"
|
||||
integrity sha512-h4LWKWE+wKQGhtMjZEBud7uLGhqyLwj8fpHOarZhD2uY3C9cRtk57VQ89ke3moByLXMedqs3XCHzyb4AmA2DjA==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.18.2"
|
||||
|
||||
object.entries@^1.1.5:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.5.tgz#e1acdd17c4de2cd96d5a08487cfb9db84d881861"
|
||||
|
Loading…
Reference in New Issue
Block a user