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

feat: add segments (#1426)

* refactor: fix missing tsconfig path in .eslintrc

* refactor: require contextName and operator

* refactor: fix crash on missing feature strategies

* feat: add segments schema

* feat: add segments client API

* feat: add segments permissions

* refactor: fail migration if things exist

* refactor: remove strategy IDs from responses

* refactor: allow empty description

* refactor: add segment import/export

* refactor: add perf scripts

* refactor: add get segment fn

* refactor: move constraint validation endpoint

* refactor: use a separate id for segment updates

* refactor: use PERF_AUTH_KEY for artillery

* refactor: adjust segment seed size

* refactor: add missing event data await

* refactor: improve method order

* refactor: remove request body limit override
This commit is contained in:
olav 2022-03-29 14:59:14 +02:00 committed by GitHub
parent f469b41eb9
commit 66d9d7a6d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1408 additions and 98 deletions

View File

@ -43,6 +43,8 @@
"test:watch": "yarn test --watch", "test:watch": "yarn test --watch",
"test:coverage": "NODE_ENV=test PORT=4243 jest --coverage --forceExit --testTimeout=10000", "test:coverage": "NODE_ENV=test PORT=4243 jest --coverage --forceExit --testTimeout=10000",
"test:coverage:jest": "NODE_ENV=test PORT=4243 jest --silent --ci --json --coverage --testLocationInResults --outputFile=\"report.json\" --forceExit --testTimeout=10000", "test:coverage:jest": "NODE_ENV=test PORT=4243 jest --silent --ci --json --coverage --testLocationInResults --outputFile=\"report.json\" --forceExit --testTimeout=10000",
"seed:setup": "ts-node src/test/e2e/seed/segment.seed.ts",
"seed:serve": "UNLEASH_DATABASE_NAME=unleash_test UNLEASH_DATABASE_SCHEMA=seed yarn run start:dev",
"clean": "del-cli --force dist" "clean": "del-cli --force dist"
}, },
"jest": { "jest": {

32
perf/README.md Normal file
View File

@ -0,0 +1,32 @@
# /perf
Testing performance testing! Files of note:
```shell
# Configure the app URL and auth token to use in performance testing.
./env.sh
# Export all the data from the app at the configured URL.
./seed/export.sh
# Import previously exported data to the app instance.
./seed/import.sh
# Measure the GZIP response size for interesting endpoints.
./test/gzip.sh
# Run a few load test scenarios against the app.
./test/artillery.sh
```
See also the following scripts in `package.json`:
```shell
# Fill the unleash_testing/seed schema with seed data.
$ yarn seed:setup
# Serve the unleash_testing/seed schema data, for exports.
$ yarn seed:serve
```
Edit files in `/test/e2e/seed` to change the amount data.

4
perf/env.sh Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
export PERF_AUTH_KEY="*:*.964a287e1b728cb5f4f3e0120df92cb5"
export PERF_APP_URL="http://localhost:4242"

1
perf/seed/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/export.json

12
perf/seed/export.sh Executable file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -feu
cd "$(dirname "$0")"
. ../env.sh
# Export data. Delete environments since they can't be imported.
curl -H "Authorization: $PERF_AUTH_KEY" "$PERF_APP_URL/api/admin/state/export" \
| jq 'del(.environments)' \
> export.json

13
perf/seed/import.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -feu
cd "$(dirname "$0")"
. ../env.sh
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: $PERF_AUTH_KEY" \
-d @export.json \
"$PERF_APP_URL/api/admin/state/import?drop=true&keep=false"

2
perf/test/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/artillery.json
/artillery.json.html

13
perf/test/artillery.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -feu
cd "$(dirname "$0")"
. ../env.sh
artillery run ./artillery.yaml --output artillery.json
artillery report artillery.json
echo "See artillery.json.html for results"

12
perf/test/artillery.yaml Normal file
View File

@ -0,0 +1,12 @@
config:
target: "http://localhost:4242"
defaults:
headers:
authorization: "{{ $processEnvironment.PERF_AUTH_KEY }}"
phases:
- duration: 60
arrivalRate: 10
scenarios:
- flow:
- get:
url: "/api/client/features"

25
perf/test/gzip.sh Executable file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -feu
cd "$(dirname "$0")"
. ../env.sh
print_response_size () {
local URL
local RES
URL="$1"
RES="$(curl -s -H "Authorization: $PERF_AUTH_KEY" "$URL")"
echo
echo "$URL"
echo
echo "* Byte size: $(echo "$RES" | wc -c) bytes"
echo "* GZIP size: $(echo "$RES" | gzip -6 | wc -c) bytes"
}
print_response_size "$PERF_APP_URL/api/admin/projects"
print_response_size "$PERF_APP_URL/api/admin/features"
print_response_size "$PERF_APP_URL/api/client/features"

View File

@ -14,6 +14,7 @@ import {
IStrategyConfig, IStrategyConfig,
} from '../types/model'; } from '../types/model';
import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store'; import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
import { PartialSome } from '../types/partial';
const COLUMNS = [ const COLUMNS = [
'id', 'id',
@ -36,6 +37,7 @@ const mapperToColumnNames = {
const T = { const T = {
features: 'features', features: 'features',
featureStrategies: 'feature_strategies', featureStrategies: 'feature_strategies',
featureStrategySegment: 'feature_strategy_segment',
featureEnvs: 'feature_environments', featureEnvs: 'feature_environments',
}; };
@ -128,7 +130,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
async exists(key: string): Promise<boolean> { async exists(key: string): Promise<boolean> {
const result = await this.db.raw( const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.featureStrategies} WHERE id = ?) AS present`, `SELECT EXISTS(SELECT 1 FROM ${T.featureStrategies} WHERE id = ?) AS present`,
[key], [key],
); );
const { present } = result.rows[0]; const { present } = result.rows[0];
@ -148,9 +150,9 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
} }
async createStrategyFeatureEnv( async createStrategyFeatureEnv(
strategyConfig: Omit<IFeatureStrategy, 'id' | 'createdAt'>, strategyConfig: PartialSome<IFeatureStrategy, 'id' | 'createdAt'>,
): Promise<IFeatureStrategy> { ): Promise<IFeatureStrategy> {
const strategyRow = mapInput({ ...strategyConfig, id: uuidv4() }); const strategyRow = mapInput({ id: uuidv4(), ...strategyConfig });
const rows = await this.db<IFeatureStrategiesTable>(T.featureStrategies) const rows = await this.db<IFeatureStrategiesTable>(T.featureStrategies)
.insert(strategyRow) .insert(strategyRow)
.returning('*'); .returning('*');
@ -422,6 +424,27 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
.where({ feature_name: featureName }) .where({ feature_name: featureName })
.update({ project_name: newProjectId }); .update({ project_name: newProjectId });
} }
async getStrategiesBySegment(
segmentId: number,
): Promise<IFeatureStrategy[]> {
const stopTimer = this.timer('getStrategiesBySegment');
const rows = await this.db
.select(this.prefixColumns())
.from<IFeatureStrategiesTable>(T.featureStrategies)
.join(
T.featureStrategySegment,
`${T.featureStrategySegment}.feature_strategy_id`,
`${T.featureStrategies}.id`,
)
.where(`${T.featureStrategySegment}.segment_id`, '=', segmentId);
stopTimer();
return rows.map(mapRow);
}
prefixColumns(): string[] {
return COLUMNS.map((c) => `${T.featureStrategies}.${c}`);
}
} }
module.exports = FeatureStrategiesStore; module.exports = FeatureStrategiesStore;

View File

@ -1,5 +1,4 @@
import { Knex } from 'knex'; import { Knex } from 'knex';
import EventEmitter from 'events';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
@ -10,6 +9,9 @@ import {
} from '../types/model'; } from '../types/model';
import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store'; import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store';
import { DEFAULT_ENV } from '../util/constants'; import { DEFAULT_ENV } from '../util/constants';
import { PartialDeep } from '../types/partial';
import { EventEmitter } from 'stream';
import { IExperimentalOptions } from '../experimental';
export interface FeaturesTable { export interface FeaturesTable {
name: string; name: string;
@ -29,11 +31,19 @@ export default class FeatureToggleClientStore
private logger: Logger; private logger: Logger;
private experimental: IExperimentalOptions;
private timer: Function; private timer: Function;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(
db: Knex,
eventBus: EventEmitter,
getLogger: LogProvider,
experimental: IExperimentalOptions,
) {
this.db = db; this.db = db;
this.logger = getLogger('feature-toggle-client-store.ts'); this.logger = getLogger('feature-toggle-client-store.ts');
this.experimental = experimental;
this.timer = (action) => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-toggle', store: 'feature-toggle',
@ -41,25 +51,6 @@ export default class FeatureToggleClientStore
}); });
} }
private getAdminStrategy(
r: any,
includeId: boolean = true,
): IStrategyConfig {
if (includeId) {
return {
name: r.strategy_name,
constraints: r.constraints || [],
parameters: r.parameters,
id: r.strategy_id,
};
}
return {
name: r.strategy_name,
constraints: r.constraints || [],
parameters: r.parameters,
};
}
private async getAll( private async getAll(
featureQuery?: IFeatureToggleQuery, featureQuery?: IFeatureToggleQuery,
archived: boolean = false, archived: boolean = false,
@ -67,24 +58,38 @@ export default class FeatureToggleClientStore
): Promise<IFeatureToggleClient[]> { ): Promise<IFeatureToggleClient[]> {
const environment = featureQuery?.environment || DEFAULT_ENV; const environment = featureQuery?.environment || DEFAULT_ENV;
const stopTimer = this.timer('getFeatureAdmin'); const stopTimer = this.timer('getFeatureAdmin');
const { inlineSegmentConstraints = false } =
this.experimental?.segments ?? {};
let selectColumns = [
'features.name as name',
'features.description as description',
'features.type as type',
'features.project as project',
'features.stale as stale',
'features.impression_data as impression_data',
'features.variants as variants',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'fe.enabled as enabled',
'fe.environment as environment',
'fs.id as strategy_id',
'fs.strategy_name as strategy_name',
'fs.parameters as parameters',
'fs.constraints as constraints',
];
if (inlineSegmentConstraints) {
selectColumns = [
...selectColumns,
'segments.id as segment_id',
'segments.constraints as segment_constraints',
];
}
let query = this.db('features') let query = this.db('features')
.select( .select(selectColumns)
'features.name as name',
'features.description as description',
'features.type as type',
'features.project as project',
'features.stale as stale',
'features.impression_data as impression_data',
'features.variants as variants',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'fe.enabled as enabled',
'fe.environment as environment',
'fs.id as strategy_id',
'fs.strategy_name as strategy_name',
'fs.parameters as parameters',
'fs.constraints as constraints',
)
.fullOuterJoin( .fullOuterJoin(
this.db('feature_strategies') this.db('feature_strategies')
.select('*') .select('*')
@ -100,8 +105,21 @@ export default class FeatureToggleClientStore
.as('fe'), .as('fe'),
'fe.feature_name', 'fe.feature_name',
'features.name', 'features.name',
) );
.where({ archived });
if (inlineSegmentConstraints) {
query = query
.fullOuterJoin(
'feature_strategy_segment as fss',
`fss.feature_strategy_id`,
`fs.id`,
)
.fullOuterJoin('segments', `segments.id`, `fss.segment_id`);
}
query = query.where({
archived,
});
if (featureQuery) { if (featureQuery) {
if (featureQuery.tag) { if (featureQuery.tag) {
@ -109,14 +127,14 @@ export default class FeatureToggleClientStore
.from('feature_tag') .from('feature_tag')
.select('feature_name') .select('feature_name')
.whereIn(['tag_type', 'tag_value'], featureQuery.tag); .whereIn(['tag_type', 'tag_value'], featureQuery.tag);
query = query.whereIn('name', tagQuery); query = query.whereIn('features.name', tagQuery);
} }
if (featureQuery.project) { if (featureQuery.project) {
query = query.whereIn('project', featureQuery.project); query = query.whereIn('project', featureQuery.project);
} }
if (featureQuery.namePrefix) { if (featureQuery.namePrefix) {
query = query.where( query = query.where(
'name', 'features.name',
'like', 'like',
`${featureQuery.namePrefix}%`, `${featureQuery.namePrefix}%`,
); );
@ -125,18 +143,16 @@ export default class FeatureToggleClientStore
const rows = await query; const rows = await query;
stopTimer(); stopTimer();
const featureToggles = rows.reduce((acc, r) => { const featureToggles = rows.reduce((acc, r) => {
let feature; let feature: PartialDeep<IFeatureToggleClient> = acc[r.name] ?? {
if (acc[r.name]) { strategies: [],
feature = acc[r.name]; };
} else { if (this.isUnseenStrategyRow(feature, r)) {
feature = {}; feature.strategies.push(this.rowToStrategy(r));
} }
if (!feature.strategies) { if (inlineSegmentConstraints && r.segment_id) {
feature.strategies = []; this.addSegmentToStrategy(feature, r);
}
if (r.strategy_name) {
feature.strategies.push(this.getAdminStrategy(r, isAdmin));
} }
feature.impressionData = r.impression_data; feature.impressionData = r.impression_data;
feature.enabled = !!r.enabled; feature.enabled = !!r.enabled;
@ -154,7 +170,52 @@ export default class FeatureToggleClientStore
acc[r.name] = feature; acc[r.name] = feature;
return acc; return acc;
}, {}); }, {});
return Object.values(featureToggles);
const features: IFeatureToggleClient[] = Object.values(featureToggles);
if (!isAdmin) {
// We should not send strategy IDs from the client API,
// as this breaks old versions of the Go SDK (at least).
this.removeIdsFromStrategies(features);
}
return features;
}
private rowToStrategy(row: Record<string, any>): IStrategyConfig {
return {
id: row.strategy_id,
name: row.strategy_name,
constraints: row.constraints || [],
parameters: row.parameters,
};
}
private removeIdsFromStrategies(features: IFeatureToggleClient[]) {
features.forEach((feature) => {
feature.strategies.forEach((strategy) => {
delete strategy.id;
});
});
}
private isUnseenStrategyRow(
feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>,
): boolean {
return (
row.strategy_id &&
!feature.strategies.find((s) => s.id === row.strategy_id)
);
}
private addSegmentToStrategy(
feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>,
) {
feature.strategies
.find((s) => s.id === row.strategy_id)
?.constraints.push(...row.segment_constraints);
} }
async getClient( async getClient(

View File

@ -28,6 +28,7 @@ import { FeatureEnvironmentStore } from './feature-environment-store';
import { ClientMetricsStoreV2 } from './client-metrics-store-v2'; import { ClientMetricsStoreV2 } from './client-metrics-store-v2';
import UserSplashStore from './user-splash-store'; import UserSplashStore from './user-splash-store';
import RoleStore from './role-store'; import RoleStore from './role-store';
import SegmentStore from './segment-store';
export const createStores = ( export const createStores = (
config: IUnleashConfig, config: IUnleashConfig,
@ -69,6 +70,7 @@ export const createStores = (
db, db,
eventBus, eventBus,
getLogger, getLogger,
config.experimental,
), ),
environmentStore: new EnvironmentStore(db, eventBus, getLogger), environmentStore: new EnvironmentStore(db, eventBus, getLogger),
featureTagStore: new FeatureTagStore(db, eventBus, getLogger), featureTagStore: new FeatureTagStore(db, eventBus, getLogger),
@ -79,6 +81,7 @@ export const createStores = (
), ),
userSplashStore: new UserSplashStore(db, eventBus, getLogger), userSplashStore: new UserSplashStore(db, eventBus, getLogger),
roleStore: new RoleStore(db, eventBus, getLogger), roleStore: new RoleStore(db, eventBus, getLogger),
segmentStore: new SegmentStore(db, eventBus, getLogger),
}; };
}; };

193
src/lib/db/segment-store.ts Normal file
View File

@ -0,0 +1,193 @@
import { ISegmentStore } from '../types/stores/segment-store';
import { IConstraint, IFeatureStrategySegment, ISegment } from '../types/model';
import { Logger, LogProvider } from '../logger';
import { Knex } from 'knex';
import EventEmitter from 'events';
import NotFoundError from '../error/notfound-error';
import User from '../types/user';
import { PartialSome } from '../types/partial';
const T = {
segments: 'segments',
featureStrategies: 'feature_strategies',
featureStrategySegment: 'feature_strategy_segment',
};
const COLUMNS = [
'id',
'name',
'description',
'created_by',
'created_at',
'constraints',
];
interface ISegmentRow {
id: number;
name: string;
description?: string;
created_by?: string;
created_at?: Date;
constraints: IConstraint[];
}
interface IFeatureStrategySegmentRow {
feature_strategy_id: string;
segment_id: number;
created_at?: Date;
}
export default class SegmentStore implements ISegmentStore {
private logger: Logger;
private eventBus: EventEmitter;
private db: Knex;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.eventBus = eventBus;
this.logger = getLogger('lib/db/segment-store.ts');
}
async create(
segment: PartialSome<ISegment, 'id'>,
user: Partial<Pick<User, 'username' | 'email'>>,
): Promise<ISegment> {
const rows = await this.db(T.segments)
.insert({
id: segment.id,
name: segment.name,
description: segment.description,
constraints: JSON.stringify(segment.constraints),
created_by: user.username || user.email,
})
.returning(COLUMNS);
return this.mapRow(rows[0]);
}
async update(id: number, segment: Omit<ISegment, 'id'>): Promise<ISegment> {
const rows = await this.db(T.segments)
.where({ id })
.update({
name: segment.name,
description: segment.description,
constraints: JSON.stringify(segment.constraints),
})
.returning(COLUMNS);
return this.mapRow(rows[0]);
}
delete(id: number): Promise<void> {
return this.db(T.segments).where({ id }).del();
}
async getAll(): Promise<ISegment[]> {
const rows: ISegmentRow[] = await this.db
.select(this.prefixColumns())
.from(T.segments)
.orderBy('name', 'asc');
return rows.map(this.mapRow);
}
async getActive(): Promise<ISegment[]> {
const rows: ISegmentRow[] = await this.db
.distinct(this.prefixColumns())
.from(T.segments)
.orderBy('name', 'asc')
.join(
T.featureStrategySegment,
`${T.featureStrategySegment}.segment_id`,
`${T.segments}.id`,
);
return rows.map(this.mapRow);
}
async getByStrategy(strategyId: string): Promise<ISegment[]> {
const rows = await this.db
.select(this.prefixColumns())
.from<ISegmentRow>(T.segments)
.join(
T.featureStrategySegment,
`${T.featureStrategySegment}.segment_id`,
`${T.segments}.id`,
)
.where(
`${T.featureStrategySegment}.feature_strategy_id`,
'=',
strategyId,
);
return rows.map(this.mapRow);
}
deleteAll(): Promise<void> {
return this.db(T.segments).del();
}
async exists(id: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS(SELECT 1 FROM ${T.segments} WHERE id = ?) AS present`,
[id],
);
return result.rows[0].present;
}
async get(id: number): Promise<ISegment> {
const rows: ISegmentRow[] = await this.db
.select(this.prefixColumns())
.from(T.segments)
.where({ id });
return this.mapRow(rows[0]);
}
async addToStrategy(id: number, strategyId: string): Promise<void> {
await this.db(T.featureStrategySegment).insert({
segment_id: id,
feature_strategy_id: strategyId,
});
}
async removeFromStrategy(id: number, strategyId: string): Promise<void> {
await this.db(T.featureStrategySegment)
.where({ segment_id: id, feature_strategy_id: strategyId })
.del();
}
async getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]> {
const rows: IFeatureStrategySegmentRow[] = await this.db
.select(['segment_id', 'feature_strategy_id'])
.from(T.featureStrategySegment);
return rows.map((row) => ({
featureStrategyId: row.feature_strategy_id,
segmentId: row.segment_id,
}));
}
prefixColumns(): string[] {
return COLUMNS.map((c) => `${T.segments}.${c}`);
}
mapRow(row?: ISegmentRow): ISegment {
if (!row) {
throw new NotFoundError('No row');
}
return {
id: row.id,
name: row.name,
description: row.description,
constraints: row.constraints,
createdBy: row.created_by,
createdAt: row.created_at,
};
}
destroy(): void {}
}

23
src/lib/experimental.ts Normal file
View File

@ -0,0 +1,23 @@
export interface IExperimentalOptions {
metricsV2?: IExperimentalToggle;
clientFeatureMemoize?: IExperimentalToggle;
segments?: IExperimentalSegments;
}
export interface IExperimentalToggle {
enabled: boolean;
}
export interface IExperimentalSegments {
enableSegmentsClientApi: boolean;
enableSegmentsAdminApi: boolean;
inlineSegmentConstraints: boolean;
}
export const experimentalSegmentsConfig = (): IExperimentalSegments => {
return {
enableSegmentsAdminApi: true,
enableSegmentsClientApi: true,
inlineSegmentConstraints: true,
};
};

View File

@ -0,0 +1,35 @@
import { Request, Response } from 'express';
import FeatureToggleService from '../../services/feature-toggle-service';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types';
import { IConstraint } from '../../types/model';
import { NONE } from '../../types/permissions';
import Controller from '../controller';
import { Logger } from '../../logger';
export default class ConstraintController extends Controller {
private featureService: FeatureToggleService;
private readonly logger: Logger;
constructor(
config: IUnleashConfig,
{
featureToggleServiceV2,
}: Pick<IUnleashServices, 'featureToggleServiceV2'>,
) {
super(config);
this.featureService = featureToggleServiceV2;
this.logger = config.getLogger('/admin-api/validation.ts');
this.post('/validate', this.validateConstraint, NONE);
}
async validateConstraint(
req: Request<{}, undefined, IConstraint>,
res: Response,
): Promise<void> {
await this.featureService.validateConstraint(req.body);
res.status(204).send();
}
}

View File

@ -157,7 +157,7 @@ class FeatureController extends Controller {
true, true,
); );
const strategies = await Promise.all( const strategies = await Promise.all(
toggle.strategies.map(async (s) => (toggle.strategies ?? []).map(async (s) =>
this.service.createStrategy( this.service.createStrategy(
s, s,
{ {

View File

@ -24,6 +24,7 @@ import UserFeedbackController from './user-feedback-controller';
import UserSplashController from './user-splash-controller'; import UserSplashController from './user-splash-controller';
import ProjectApi from './project'; import ProjectApi from './project';
import { EnvironmentsController } from './environments-controller'; import { EnvironmentsController } from './environments-controller';
import ConstraintsController from './constraints';
class AdminApi extends Controller { class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) { constructor(config: IUnleashConfig, services: IUnleashServices) {
@ -99,6 +100,10 @@ class AdminApi extends Controller {
'/splash', '/splash',
new UserSplashController(config, services).router, new UserSplashController(config, services).router,
); );
this.app.use(
'/constraints',
new ConstraintsController(config, services).router,
);
} }
index(req, res) { index(req, res) {

View File

@ -10,7 +10,6 @@ import {
CREATE_FEATURE_STRATEGY, CREATE_FEATURE_STRATEGY,
DELETE_FEATURE, DELETE_FEATURE,
DELETE_FEATURE_STRATEGY, DELETE_FEATURE_STRATEGY,
NONE,
UPDATE_FEATURE, UPDATE_FEATURE,
UPDATE_FEATURE_ENVIRONMENT, UPDATE_FEATURE_ENVIRONMENT,
UPDATE_FEATURE_STRATEGY, UPDATE_FEATURE_STRATEGY,
@ -73,7 +72,6 @@ export default class ProjectFeaturesController extends Controller {
this.featureService = featureToggleServiceV2; this.featureService = featureToggleServiceV2;
this.logger = config.getLogger('/admin-api/project/features.ts'); this.logger = config.getLogger('/admin-api/project/features.ts');
// Environments
this.get(`${PATH_ENV}`, this.getEnvironment); this.get(`${PATH_ENV}`, this.getEnvironment);
this.post( this.post(
`${PATH_ENV}/on`, `${PATH_ENV}/on`,
@ -85,14 +83,13 @@ export default class ProjectFeaturesController extends Controller {
this.toggleEnvironmentOff, this.toggleEnvironmentOff,
UPDATE_FEATURE_ENVIRONMENT, UPDATE_FEATURE_ENVIRONMENT,
); );
// activation strategies
this.get(`${PATH_STRATEGIES}`, this.getStrategies); this.get(`${PATH_STRATEGIES}`, this.getStrategies);
this.post( this.post(
`${PATH_STRATEGIES}`, `${PATH_STRATEGIES}`,
this.addStrategy, this.addStrategy,
CREATE_FEATURE_STRATEGY, CREATE_FEATURE_STRATEGY,
); );
this.get(`${PATH_STRATEGY}`, this.getStrategy); this.get(`${PATH_STRATEGY}`, this.getStrategy);
this.put( this.put(
`${PATH_STRATEGY}`, `${PATH_STRATEGY}`,
@ -109,18 +106,10 @@ export default class ProjectFeaturesController extends Controller {
this.deleteStrategy, this.deleteStrategy,
DELETE_FEATURE_STRATEGY, DELETE_FEATURE_STRATEGY,
); );
this.post(
`${PATH_FEATURE}/constraint/validate`,
this.validateConstraint,
NONE,
);
// feature toggles
this.get(PATH, this.getFeatures); this.get(PATH, this.getFeatures);
this.post(PATH, this.createFeature, CREATE_FEATURE); this.post(PATH, this.createFeature, CREATE_FEATURE);
this.post(PATH_FEATURE_CLONE, this.cloneFeature, CREATE_FEATURE); this.post(PATH_FEATURE_CLONE, this.cloneFeature, CREATE_FEATURE);
this.get(PATH_FEATURE, this.getFeature); this.get(PATH_FEATURE, this.getFeature);
this.put(PATH_FEATURE, this.updateFeature, UPDATE_FEATURE); this.put(PATH_FEATURE, this.updateFeature, UPDATE_FEATURE);
this.patch(PATH_FEATURE, this.patchFeature, UPDATE_FEATURE); this.patch(PATH_FEATURE, this.patchFeature, UPDATE_FEATURE);
@ -343,13 +332,6 @@ export default class ProjectFeaturesController extends Controller {
res.status(200).json(updatedStrategy); res.status(200).json(updatedStrategy);
} }
async validateConstraint(req: Request, res: Response): Promise<void> {
const constraint: IConstraint = { ...req.body };
await this.featureService.validateConstraint(constraint);
res.status(204).send();
}
async getStrategy( async getStrategy(
req: IAuthRequest<StrategyIdParams, any, any, any>, req: IAuthRequest<StrategyIdParams, any, any, any>,
res: Response, res: Response,

View File

@ -5,16 +5,25 @@ import MetricsController from './metrics';
import RegisterController from './register'; import RegisterController from './register';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types'; import { IUnleashServices } from '../../types';
import { SegmentsController } from './segments';
const apiDef = require('./api-def.json'); const apiDef = require('./api-def.json');
export default class ClientApi extends Controller { export default class ClientApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) { constructor(config: IUnleashConfig, services: IUnleashServices) {
super(config); super(config);
this.get('/', this.index); this.get('/', this.index);
this.use('/features', new FeatureController(services, config).router); this.use('/features', new FeatureController(services, config).router);
this.use('/metrics', new MetricsController(services, config).router); this.use('/metrics', new MetricsController(services, config).router);
this.use('/register', new RegisterController(services, config).router); this.use('/register', new RegisterController(services, config).router);
if (config.experimental?.segments?.enableSegmentsClientApi) {
this.use(
'/segments',
new SegmentsController(services, config).router,
);
}
} }
index(req: Request, res: Response): void { index(req: Request, res: Response): void {

View File

@ -0,0 +1,29 @@
import { Response } from 'express';
import Controller from '../controller';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types';
import { Logger } from '../../logger';
import { SegmentService } from '../../services/segment-service';
import { IAuthRequest } from '../unleash-types';
export class SegmentsController extends Controller {
private logger: Logger;
private segmentService: SegmentService;
constructor(
{ segmentService }: Pick<IUnleashServices, 'segmentService'>,
config: IUnleashConfig,
) {
super(config);
this.logger = config.getLogger('/client-api/segments.ts');
this.segmentService = segmentService;
this.get('/active', this.getActive);
}
async getActive(req: IAuthRequest, res: Response): Promise<void> {
const segments = await this.segmentService.getActive();
res.json({ segments });
}
}

View File

@ -8,8 +8,11 @@ export const nameSchema = joi
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false }); .options({ stripUnknown: true, allowUnknown: false, abortEarly: false });
export const constraintSchema = joi.object().keys({ export const constraintSchema = joi.object().keys({
contextName: joi.string(), contextName: joi.string().required(),
operator: joi.string().valid(...ALL_OPERATORS), operator: joi
.string()
.valid(...ALL_OPERATORS)
.required(),
// Constraints must have a values array to support legacy SDKs. // Constraints must have a values array to support legacy SDKs.
values: joi.array().items(joi.string().min(1).max(100)).default([]), values: joi.array().items(joi.string().min(1).max(100)).default([]),
value: joi.optional(), value: joi.optional(),

View File

@ -28,6 +28,7 @@ import EnvironmentService from './environment-service';
import FeatureTagService from './feature-tag-service'; import FeatureTagService from './feature-tag-service';
import ProjectHealthService from './project-health-service'; import ProjectHealthService from './project-health-service';
import UserSplashService from './user-splash-service'; import UserSplashService from './user-splash-service';
import { SegmentService } from './segment-service';
export const createServices = ( export const createServices = (
stores: IUnleashStores, stores: IUnleashStores,
@ -74,6 +75,7 @@ export const createServices = (
featureToggleServiceV2, featureToggleServiceV2,
); );
const userSplashService = new UserSplashService(stores, config); const userSplashService = new UserSplashService(stores, config);
const segmentService = new SegmentService(stores, config);
return { return {
accessService, accessService,
@ -103,6 +105,7 @@ export const createServices = (
featureTagService, featureTagService,
projectHealthService, projectHealthService,
userSplashService, userSplashService,
segmentService,
}; };
}; };

View File

@ -0,0 +1,19 @@
import joi from 'joi';
import { constraintSchema } from '../schema/feature-schema';
export const segmentSchema = joi
.object()
.keys({
name: joi.string().required(),
description: joi.string().allow(null).allow('').optional(),
constraints: joi.array().items(constraintSchema).required(),
})
.options({ allowUnknown: true });
export const featureStrategySegmentSchema = joi
.object()
.keys({
segmentId: joi.number().required(),
featureStrategyId: joi.string().required(),
})
.options({ allowUnknown: true });

View File

@ -0,0 +1,107 @@
import { IUnleashConfig } from '../types/option';
import { IEventStore } from '../types/stores/event-store';
import { IUnleashStores } from '../types';
import { Logger } from '../logger';
import { ISegmentStore } from '../types/stores/segment-store';
import { IFeatureStrategy, ISegment } from '../types/model';
import { segmentSchema } from './segment-schema';
import {
SEGMENT_CREATED,
SEGMENT_DELETED,
SEGMENT_UPDATED,
} from '../types/events';
import User from '../types/user';
import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
export class SegmentService {
private logger: Logger;
private segmentStore: ISegmentStore;
private featureStrategiesStore: IFeatureStrategiesStore;
private eventStore: IEventStore;
constructor(
{
segmentStore,
featureStrategiesStore,
eventStore,
}: Pick<
IUnleashStores,
'segmentStore' | 'featureStrategiesStore' | 'eventStore'
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
) {
this.segmentStore = segmentStore;
this.featureStrategiesStore = featureStrategiesStore;
this.eventStore = eventStore;
this.logger = getLogger('services/segment-service.ts');
}
async get(id: number): Promise<ISegment> {
return this.segmentStore.get(id);
}
async getAll(): Promise<ISegment[]> {
return this.segmentStore.getAll();
}
async getActive(): Promise<ISegment[]> {
return this.segmentStore.getActive();
}
// Used by unleash-enterprise.
async getByStrategy(strategyId: string): Promise<ISegment[]> {
return this.segmentStore.getByStrategy(strategyId);
}
// Used by unleash-enterprise.
async getStrategies(id: number): Promise<IFeatureStrategy[]> {
return this.featureStrategiesStore.getStrategiesBySegment(id);
}
async create(data: unknown, user: User): Promise<void> {
const input = await segmentSchema.validateAsync(data);
const segment = await this.segmentStore.create(input, user);
await this.eventStore.store({
type: SEGMENT_CREATED,
createdBy: user.email || user.username,
data: segment,
});
}
async update(id: number, data: unknown, user: User): Promise<void> {
const input = await segmentSchema.validateAsync(data);
const preData = await this.segmentStore.get(id);
const segment = await this.segmentStore.update(id, input);
await this.eventStore.store({
type: SEGMENT_UPDATED,
createdBy: user.email || user.username,
data: segment,
preData,
});
}
async delete(id: number, user: User): Promise<void> {
const segment = this.segmentStore.get(id);
await this.segmentStore.delete(id);
await this.eventStore.store({
type: SEGMENT_DELETED,
createdBy: user.email || user.username,
data: segment,
});
}
// Used by unleash-enterprise.
async addToStrategy(id: number, strategyId: string): Promise<void> {
await this.segmentStore.addToStrategy(id, strategyId);
}
// Used by unleash-enterprise.
async removeFromStrategy(id: number, strategyId: string): Promise<void> {
await this.segmentStore.removeFromStrategy(id, strategyId);
}
}

View File

@ -5,6 +5,7 @@ import { tagSchema } from './tag-schema';
import { tagTypeSchema } from './tag-type-schema'; import { tagTypeSchema } from './tag-type-schema';
import { projectSchema } from './project-schema'; import { projectSchema } from './project-schema';
import { nameType } from '../routes/util'; import { nameType } from '../routes/util';
import { featureStrategySegmentSchema, segmentSchema } from './segment-schema';
export const featureStrategySchema = joi export const featureStrategySchema = joi
.object() .object()
@ -56,4 +57,9 @@ export const stateSchema = joi.object().keys({
.optional() .optional()
.items(featureEnvironmentsSchema), .items(featureEnvironmentsSchema),
environments: joi.array().optional().items(environmentSchema), environments: joi.array().optional().items(environmentSchema),
segments: joi.array().optional().items(segmentSchema),
featureStrategySegments: joi
.array()
.optional()
.items(featureStrategySegmentSchema),
}); });

View File

@ -22,13 +22,14 @@ import { IUnleashConfig } from '../types/option';
import { import {
FeatureToggle, FeatureToggle,
IEnvironment, IEnvironment,
IImportFile,
IFeatureEnvironment, IFeatureEnvironment,
IFeatureStrategy, IFeatureStrategy,
ITag,
IImportData, IImportData,
IImportFile,
IProject, IProject,
ISegment,
IStrategyConfig, IStrategyConfig,
ITag,
} from '../types/model'; } from '../types/model';
import { Logger } from '../logger'; import { Logger } from '../logger';
import { import {
@ -47,6 +48,8 @@ import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-st
import { IUnleashStores } from '../types/stores'; import { IUnleashStores } from '../types/stores';
import { DEFAULT_ENV } from '../util/constants'; import { DEFAULT_ENV } from '../util/constants';
import { GLOBAL_ENV } from '../types/environment'; import { GLOBAL_ENV } from '../types/environment';
import { ISegmentStore } from '../types/stores/segment-store';
import { PartialSome } from '../types/partial';
export interface IBackupOption { export interface IBackupOption {
includeFeatureToggles: boolean; includeFeatureToggles: boolean;
@ -61,6 +64,7 @@ interface IExportIncludeOptions {
includeProjects?: boolean; includeProjects?: boolean;
includeTags?: boolean; includeTags?: boolean;
includeEnvironments?: boolean; includeEnvironments?: boolean;
includeSegments?: boolean;
} }
export default class StateService { export default class StateService {
@ -86,6 +90,8 @@ export default class StateService {
private environmentStore: IEnvironmentStore; private environmentStore: IEnvironmentStore;
private segmentStore: ISegmentStore;
constructor( constructor(
stores: IUnleashStores, stores: IUnleashStores,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>, { getLogger }: Pick<IUnleashConfig, 'getLogger'>,
@ -100,6 +106,7 @@ export default class StateService {
this.projectStore = stores.projectStore; this.projectStore = stores.projectStore;
this.featureTagStore = stores.featureTagStore; this.featureTagStore = stores.featureTagStore;
this.environmentStore = stores.environmentStore; this.environmentStore = stores.environmentStore;
this.segmentStore = stores.segmentStore;
this.logger = getLogger('services/state-service.js'); this.logger = getLogger('services/state-service.js');
} }
@ -227,6 +234,20 @@ export default class StateService {
keepExisting, keepExisting,
}); });
} }
if (importData.segments) {
await this.importSegments(
data.segments,
userName,
dropBeforeImport,
);
}
if (importData.featureStrategySegments) {
await this.importFeatureStrategySegments(
data.featureStrategySegments,
);
}
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@ -596,6 +617,35 @@ export default class StateService {
} }
} }
async importSegments(
segments: PartialSome<ISegment, 'id'>[],
userName: string,
dropBeforeImport: boolean,
): Promise<void> {
if (dropBeforeImport) {
await this.segmentStore.deleteAll();
}
await Promise.all(
segments.map((segment) =>
this.segmentStore.create(segment, { username: userName }),
),
);
}
async importFeatureStrategySegments(
featureStrategySegments: {
featureStrategyId: string;
segmentId: number;
}[],
): Promise<void> {
await Promise.all(
featureStrategySegments.map(({ featureStrategyId, segmentId }) =>
this.segmentStore.addToStrategy(segmentId, featureStrategyId),
),
);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async export({ async export({
includeFeatureToggles = true, includeFeatureToggles = true,
@ -603,6 +653,7 @@ export default class StateService {
includeProjects = true, includeProjects = true,
includeTags = true, includeTags = true,
includeEnvironments = true, includeEnvironments = true,
includeSegments = true,
}: IExportIncludeOptions): Promise<{ }: IExportIncludeOptions): Promise<{
features: FeatureToggle[]; features: FeatureToggle[];
strategies: IStrategy[]; strategies: IStrategy[];
@ -639,6 +690,10 @@ export default class StateService {
includeFeatureToggles includeFeatureToggles
? this.featureEnvironmentStore.getAll() ? this.featureEnvironmentStore.getAll()
: Promise.resolve([]), : Promise.resolve([]),
includeSegments ? this.segmentStore.getAll() : Promise.resolve([]),
includeSegments
? this.segmentStore.getAllFeatureStrategySegments()
: Promise.resolve([]),
]).then( ]).then(
([ ([
features, features,
@ -650,6 +705,8 @@ export default class StateService {
featureStrategies, featureStrategies,
environments, environments,
featureEnvironments, featureEnvironments,
segments,
featureStrategySegments,
]) => ({ ]) => ({
version: 3, version: 3,
features, features,
@ -665,6 +722,8 @@ export default class StateService {
featureEnvironments: featureEnvironments.filter((fE) => featureEnvironments: featureEnvironments.filter((fE) =>
features.some((f) => fE.featureName === f.name), features.some((f) => fE.featureName === f.name),
), ),
segments,
featureStrategySegments,
}), }),
); );
} }

View File

@ -61,6 +61,9 @@ export const USER_UPDATED = 'user-updated';
export const USER_DELETED = 'user-deleted'; export const USER_DELETED = 'user-deleted';
export const DROP_ENVIRONMENTS = 'drop-environments'; export const DROP_ENVIRONMENTS = 'drop-environments';
export const ENVIRONMENT_IMPORT = 'environment-import'; export const ENVIRONMENT_IMPORT = 'environment-import';
export const SEGMENT_CREATED = 'segment-created';
export const SEGMENT_UPDATED = 'segment-updated';
export const SEGMENT_DELETED = 'segment-deleted';
export const CLIENT_METRICS = 'client-metrics'; export const CLIENT_METRICS = 'client-metrics';

View File

@ -60,6 +60,9 @@ export interface IFeatureToggleClient {
variants: IVariant[]; variants: IVariant[];
enabled: boolean; enabled: boolean;
strategies: IStrategyConfig[]; strategies: IStrategyConfig[];
impressionData?: boolean;
lastSeenAt?: Date;
createdAt?: Date;
} }
export interface IFeatureEnvironmentInfo { export interface IFeatureEnvironmentInfo {
@ -341,3 +344,17 @@ export interface IProjectWithCount extends IProject {
featureCount: number; featureCount: number;
memberCount: number; memberCount: number;
} }
export interface ISegment {
id: number;
name: string;
description?: string;
constraints: IConstraint[];
createdBy?: string;
createdAt: Date;
}
export interface IFeatureStrategySegment {
featureStrategyId: string;
segmentId: number;
}

View File

@ -1,6 +1,7 @@
import EventEmitter from 'events'; import EventEmitter from 'events';
import { LogLevel, LogProvider } from '../logger'; import { LogLevel, LogProvider } from '../logger';
import { IApiTokenCreate } from './models/api-token'; import { IApiTokenCreate } from './models/api-token';
import { IExperimentalOptions } from '../experimental';
export type EventHook = (eventName: string, data: object) => void; export type EventHook = (eventName: string, data: object) => void;
@ -91,9 +92,7 @@ export interface IUnleashOptions {
authentication?: Partial<IAuthOption>; authentication?: Partial<IAuthOption>;
ui?: object; ui?: object;
import?: Partial<IImportOption>; import?: Partial<IImportOption>;
experimental?: { experimental?: IExperimentalOptions;
[key: string]: object;
};
email?: Partial<IEmailOption>; email?: Partial<IEmailOption>;
secureHeaders?: boolean; secureHeaders?: boolean;
enableOAS?: boolean; enableOAS?: boolean;
@ -146,9 +145,7 @@ export interface IUnleashConfig {
authentication: IAuthOption; authentication: IAuthOption;
ui: IUIConfig; ui: IUIConfig;
import: IImportOption; import: IImportOption;
experimental: { experimental?: IExperimentalOptions;
[key: string]: any;
};
email: IEmailOption; email: IEmailOption;
secureHeaders: boolean; secureHeaders: boolean;
enableOAS: boolean; enableOAS: boolean;

10
src/lib/types/partial.ts Normal file
View File

@ -0,0 +1,10 @@
// Recursively mark all properties as optional.
export type PartialDeep<T> = T extends object
? {
[P in keyof T]?: PartialDeep<T[P]>;
}
: T;
// Mark one or more properties as optional.
export type PartialSome<T, K extends keyof T> = Pick<Partial<T>, K> &
Omit<T, K>;

View File

@ -32,3 +32,6 @@ export const UPDATE_TAG_TYPE = 'UPDATE_TAG_TYPE';
export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE'; export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE';
export const UPDATE_FEATURE_VARIANTS = 'UPDATE_FEATURE_VARIANTS'; export const UPDATE_FEATURE_VARIANTS = 'UPDATE_FEATURE_VARIANTS';
export const MOVE_FEATURE_TOGGLE = 'MOVE_FEATURE_TOGGLE'; export const MOVE_FEATURE_TOGGLE = 'MOVE_FEATURE_TOGGLE';
export const CREATE_SEGMENT = 'CREATE_SEGMENT';
export const UPDATE_SEGMENT = 'UPDATE_SEGMENT';
export const DELETE_SEGMENT = 'DELETE_SEGMENT';

View File

@ -24,6 +24,7 @@ import FeatureTagService from '../services/feature-tag-service';
import ProjectHealthService from '../services/project-health-service'; import ProjectHealthService from '../services/project-health-service';
import ClientMetricsServiceV2 from '../services/client-metrics/metrics-service-v2'; import ClientMetricsServiceV2 from '../services/client-metrics/metrics-service-v2';
import UserSplashService from '../services/user-splash-service'; import UserSplashService from '../services/user-splash-service';
import { SegmentService } from '../services/segment-service';
export interface IUnleashServices { export interface IUnleashServices {
accessService: AccessService; accessService: AccessService;
@ -53,4 +54,5 @@ export interface IUnleashServices {
userService: UserService; userService: UserService;
versionService: VersionService; versionService: VersionService;
userSplashService: UserSplashService; userSplashService: UserSplashService;
segmentService: SegmentService;
} }

View File

@ -24,6 +24,7 @@ import { IFeatureToggleClientStore } from './stores/feature-toggle-client-store'
import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2'; import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2';
import { IUserSplashStore } from './stores/user-splash-store'; import { IUserSplashStore } from './stores/user-splash-store';
import { IRoleStore } from './stores/role-store'; import { IRoleStore } from './stores/role-store';
import { ISegmentStore } from './stores/segment-store';
export interface IUnleashStores { export interface IUnleashStores {
accessStore: IAccessStore; accessStore: IAccessStore;
@ -52,4 +53,5 @@ export interface IUnleashStores {
userStore: IUserStore; userStore: IUserStore;
userSplashStore: IUserSplashStore; userSplashStore: IUserSplashStore;
roleStore: IRoleStore; roleStore: IRoleStore;
segmentStore: ISegmentStore;
} }

View File

@ -46,9 +46,9 @@ export interface IFeatureStrategiesStore
projectId: String, projectId: String,
environment: String, environment: String,
): Promise<void>; ): Promise<void>;
setProjectForStrategiesBelongingToFeature( setProjectForStrategiesBelongingToFeature(
featureName: string, featureName: string,
newProjectId: string, newProjectId: string,
): Promise<void>; ): Promise<void>;
getStrategiesBySegment(segmentId: number): Promise<IFeatureStrategy[]>;
} }

View File

@ -0,0 +1,26 @@
import { IFeatureStrategySegment, ISegment } from '../model';
import { Store } from './store';
import User from '../user';
export interface ISegmentStore extends Store<ISegment, number> {
getAll(): Promise<ISegment[]>;
getActive(): Promise<ISegment[]>;
getByStrategy(strategyId: string): Promise<ISegment[]>;
create(
segment: Omit<ISegment, 'id'>,
user: Partial<Pick<User, 'username' | 'email'>>,
): Promise<ISegment>;
update(id: number, segment: Omit<ISegment, 'id'>): Promise<ISegment>;
delete(id: number): Promise<void>;
addToStrategy(id: number, strategyId: string): Promise<void>;
removeFromStrategy(id: number, strategyId: string): Promise<void>;
getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]>;
}

View File

@ -0,0 +1,3 @@
export const collectIds = <T>(items: { id?: T }[]): T[] => {
return items.map((item) => item.id);
};

View File

@ -0,0 +1,5 @@
import { v4 as uuidv4 } from 'uuid';
export const randomId = (): string => {
return uuidv4();
};

View File

@ -4,13 +4,16 @@
"parser": "", "parser": "",
"plugins": [], "plugins": [],
"rules": {}, "rules": {},
"settings": {},
"parserOptions": {
"project": "../../tsconfig.json"
},
"overrides": [ "overrides": [
{ {
"files": "*.js", "files": "*.js",
"rules": { "rules": {
"@typescript-eslint/indent": "off" "@typescript-eslint/indent": "off"
}
} }
} ]
],
"settings": {}
} }

View File

@ -0,0 +1,73 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`
create table segments
(
id serial primary key,
name text not null,
description text,
created_by text,
created_at timestamp with time zone not null default now(),
constraints jsonb not null default '[]'::jsonb
);
create table feature_strategy_segment
(
feature_strategy_id text not null references feature_strategies (id) on update cascade on delete cascade not null,
segment_id integer not null references segments (id) on update cascade on delete cascade not null,
created_at timestamp with time zone not null default now(),
primary key (feature_strategy_id, segment_id)
);
create index feature_strategy_segment_segment_id_index
on feature_strategy_segment (segment_id);
insert into permissions (permission, display_name, type) values
('CREATE_SEGMENT', 'Create segments', 'root'),
('UPDATE_SEGMENT', 'Edit segments', 'root'),
('DELETE_SEGMENT', 'Delete segments', 'root');
insert into role_permission (role_id, permission_id)
select
r.id as role_id,
p.id as permission_id
from roles r
cross join permissions p
where r.name in (
'Admin',
'Editor'
)
and p.permission in (
'CREATE_SEGMENT',
'UPDATE_SEGMENT',
'DELETE_SEGMENT'
);
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
delete from role_permission where permission_id in (
select id from permissions where permission in (
'DELETE_SEGMENT',
'UPDATE_SEGMENT',
'CREATE_SEGMENT'
)
);
delete from permissions where permission = 'DELETE_SEGMENT';
delete from permissions where permission = 'UPDATE_SEGMENT';
delete from permissions where permission = 'CREATE_SEGMENT';
drop index feature_strategy_segment_segment_id_index;
drop table feature_strategy_segment;
drop table segments;
`,
cb,
);
};

View File

@ -2,6 +2,7 @@ import { start } from './lib/server-impl';
import { createConfig } from './lib/create-config'; import { createConfig } from './lib/create-config';
import { LogLevel } from './lib/logger'; import { LogLevel } from './lib/logger';
import { ApiTokenType } from './lib/types/models/api-token'; import { ApiTokenType } from './lib/types/models/api-token';
import { experimentalSegmentsConfig } from './lib/experimental';
process.nextTick(async () => { process.nextTick(async () => {
try { try {
@ -12,7 +13,8 @@ process.nextTick(async () => {
password: 'passord', password: 'passord',
host: 'localhost', host: 'localhost',
port: 5432, port: 5432,
database: 'unleash', database: process.env.UNLEASH_DATABASE_NAME || 'unleash',
schema: process.env.UNLEASH_DATABASE_SCHEMA,
ssl: false, ssl: false,
}, },
server: { server: {
@ -29,9 +31,8 @@ process.nextTick(async () => {
enable: false, enable: false,
}, },
experimental: { experimental: {
metricsV2: { metricsV2: { enabled: true },
enabled: true, segments: experimentalSegmentsConfig(),
},
}, },
authentication: { authentication: {
initApiTokens: [ initApiTokens: [

View File

@ -7,6 +7,7 @@ import {
import getLogger from '../fixtures/no-logger'; import getLogger from '../fixtures/no-logger';
import { createConfig } from '../../lib/create-config'; import { createConfig } from '../../lib/create-config';
import { experimentalSegmentsConfig } from '../../lib/experimental';
function mergeAll<T>(objects: Partial<T>[]): T { function mergeAll<T>(objects: Partial<T>[]): T {
return merge.all<T>(objects.filter((i) => i)); return merge.all<T>(objects.filter((i) => i));
@ -20,6 +21,9 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
session: { session: {
db: false, db: false,
}, },
experimental: {
segments: experimentalSegmentsConfig(),
},
versionCheck: { enable: false }, versionCheck: { enable: false },
}; };
const options = mergeAll<IUnleashOptions>([testConfig, config]); const options = mergeAll<IUnleashOptions>([testConfig, config]);

View File

@ -0,0 +1,36 @@
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { setupApp } from '../../helpers/test-helper';
let app;
let db;
const PATH = '/api/admin/constraints/validate';
beforeAll(async () => {
db = await dbInit('constraints', getLogger);
app = await setupApp(db.stores);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('should reject invalid constraints', async () => {
await app.request.post(PATH).send({}).expect(400);
await app.request.post(PATH).send({ a: 1 }).expect(400);
await app.request.post(PATH).send({ operator: 'IN' }).expect(400);
await app.request.post(PATH).send({ contextName: 'a' }).expect(400);
});
test('should accept valid constraints', async () => {
await app.request
.post(PATH)
.send({ contextName: 'environment', operator: 'NUM_EQ', value: 1 })
.expect(204);
await app.request
.post(PATH)
.send({ contextName: 'environment', operator: 'IN', values: ['a'] })
.expect(204);
});

View File

@ -2,6 +2,7 @@ import dbInit, { ITestDb } from '../../helpers/database-init';
import { IUnleashTest, setupApp } from '../../helpers/test-helper'; import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger'; import getLogger from '../../../fixtures/no-logger';
import { DEFAULT_ENV } from '../../../../lib/util/constants'; import { DEFAULT_ENV } from '../../../../lib/util/constants';
import { collectIds } from '../../../../lib/util/collect-ids';
const importData = require('../../../examples/import.json'); const importData = require('../../../examples/import.json');
@ -321,3 +322,18 @@ test(`Importing version 2 replaces :global: environment with 'default'`, async (
expect(feature.environments).toHaveLength(1); expect(feature.environments).toHaveLength(1);
expect(feature.environments[0].name).toBe(DEFAULT_ENV); expect(feature.environments[0].name).toBe(DEFAULT_ENV);
}); });
test(`should import segments and connect them to feature strategies`, async () => {
await app.request
.post('/api/admin/state/import')
.attach('file', 'src/test/examples/exported-segments.json')
.expect(202);
const allSegments = await app.services.segmentService.getAll();
const activeSegments = await app.services.segmentService.getActive();
expect(allSegments.length).toEqual(2);
expect(collectIds(allSegments)).toEqual([1, 2]);
expect(activeSegments.length).toEqual(1);
expect(collectIds(activeSegments)).toEqual([1]);
});

View File

@ -0,0 +1,147 @@
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import { collectIds } from '../../../../lib/util/collect-ids';
import {
IConstraint,
IFeatureToggleClient,
ISegment,
} from '../../../../lib/types/model';
import { randomId } from '../../../../lib/util/random-id';
import User from '../../../../lib/types/user';
let db: ITestDb;
let app: IUnleashTest;
const FEATURES_ADMIN_BASE_PATH = '/api/admin/features';
const FEATURES_CLIENT_BASE_PATH = '/api/client/features';
const fetchSegments = (): Promise<ISegment[]> => {
return app.services.segmentService.getAll();
};
const fetchFeatures = (): Promise<IFeatureToggleClient[]> => {
return app.request
.get(FEATURES_ADMIN_BASE_PATH)
.expect(200)
.then((res) => res.body.features);
};
const fetchClientFeatures = (): Promise<IFeatureToggleClient[]> => {
return app.request
.get(FEATURES_CLIENT_BASE_PATH)
.expect(200)
.then((res) => res.body.features);
};
const fetchClientSegmentsActive = (): Promise<ISegment[]> => {
return app.request
.get('/api/client/segments/active')
.expect(200)
.then((res) => res.body.segments);
};
const createSegment = (postData: object): Promise<unknown> => {
const user = { email: 'test@example.com' } as User;
return app.services.segmentService.create(postData, user);
};
const createFeatureToggle = (
postData: object,
expectStatusCode = 201,
): Promise<unknown> => {
return app.request
.post(FEATURES_ADMIN_BASE_PATH)
.send(postData)
.expect(expectStatusCode);
};
const addSegmentToStrategy = (
segmentId: number,
strategyId: string,
): Promise<unknown> => {
return app.services.segmentService.addToStrategy(segmentId, strategyId);
};
const mockFeatureToggle = (): object => {
return {
name: randomId(),
strategies: [{ name: randomId(), constraints: [], parameters: {} }],
};
};
const mockConstraints = (): IConstraint[] => {
return Array.from({ length: 5 }).map(() => ({
values: ['x', 'y', 'z'],
operator: 'IN',
contextName: 'a',
}));
};
beforeAll(async () => {
db = await dbInit('segments', getLogger);
app = await setupApp(db.stores);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
afterEach(async () => {
await db.stores.segmentStore.deleteAll();
await db.stores.featureToggleStore.deleteAll();
});
test('should add segments to features as constraints', async () => {
const constraints = mockConstraints();
await createSegment({ name: 'S1', constraints });
await createSegment({ name: 'S2', constraints });
await createSegment({ name: 'S3', constraints });
await createFeatureToggle(mockFeatureToggle());
await createFeatureToggle(mockFeatureToggle());
await createFeatureToggle(mockFeatureToggle());
const [feature1, feature2, feature3] = await fetchFeatures();
const [segment1, segment2, segment3] = await fetchSegments();
await addSegmentToStrategy(segment1.id, feature1.strategies[0].id);
await addSegmentToStrategy(segment2.id, feature1.strategies[0].id);
await addSegmentToStrategy(segment2.id, feature2.strategies[0].id);
await addSegmentToStrategy(segment3.id, feature1.strategies[0].id);
await addSegmentToStrategy(segment3.id, feature2.strategies[0].id);
await addSegmentToStrategy(segment3.id, feature3.strategies[0].id);
const clientFeatures = await fetchClientFeatures();
const clientStrategies = clientFeatures.flatMap((f) => f.strategies);
const clientConstraints = clientStrategies.flatMap((s) => s.constraints);
const clientValues = clientConstraints.flatMap((c) => c.values);
const uniqueValues = [...new Set(clientValues)];
expect(clientFeatures.length).toEqual(3);
expect(clientStrategies.length).toEqual(3);
expect(clientConstraints.length).toEqual(5 * 6);
expect(clientValues.length).toEqual(5 * 6 * 3);
expect(uniqueValues.length).toEqual(3);
});
test('should list active segments', async () => {
const constraints = mockConstraints();
await createSegment({ name: 'S1', constraints });
await createSegment({ name: 'S2', constraints });
await createSegment({ name: 'S3', constraints });
await createFeatureToggle(mockFeatureToggle());
await createFeatureToggle(mockFeatureToggle());
await createFeatureToggle(mockFeatureToggle());
const [feature1, feature2] = await fetchFeatures();
const [segment1, segment2] = await fetchSegments();
await addSegmentToStrategy(segment1.id, feature1.strategies[0].id);
await addSegmentToStrategy(segment2.id, feature1.strategies[0].id);
await addSegmentToStrategy(segment2.id, feature2.strategies[0].id);
const clientSegments = await fetchClientSegmentsActive();
expect(collectIds(clientSegments)).toEqual(
collectIds([segment1, segment2]),
);
});

View File

@ -0,0 +1,149 @@
import dbInit from '../helpers/database-init';
import getLogger from '../../fixtures/no-logger';
import assert from 'assert';
import User from '../../../lib/types/user';
import { randomId } from '../../../lib/util/random-id';
import {
IConstraint,
IFeatureToggleClient,
ISegment,
} from '../../../lib/types/model';
import { IUnleashTest, setupApp } from '../helpers/test-helper';
interface ISeedSegmentSpec {
featuresCount: number;
segmentsPerFeature: number;
constraintsPerSegment: number;
valuesPerConstraint: number;
}
// The number of items to insert.
const seedSegmentSpec: ISeedSegmentSpec = {
featuresCount: 100,
segmentsPerFeature: 5,
constraintsPerSegment: 1,
valuesPerConstraint: 100,
};
// The database schema to populate.
const seedSchema = 'seed';
const fetchSegments = (app: IUnleashTest): Promise<ISegment[]> => {
return app.services.segmentService.getAll();
};
const fetchFeatures = (app: IUnleashTest): Promise<IFeatureToggleClient[]> => {
return app.request
.get('/api/admin/features')
.expect(200)
.then((res) => res.body.features);
};
const createSegment = (
app: IUnleashTest,
postData: object,
): Promise<unknown> => {
const user = { email: 'test@example.com' } as User;
return app.services.segmentService.create(postData, user);
};
const createFeatureToggle = (
app: IUnleashTest,
postData: object,
expectStatusCode = 201,
): Promise<unknown> => {
return app.request
.post('/api/admin/features')
.send(postData)
.expect(expectStatusCode);
};
const addSegmentToStrategy = (
app: IUnleashTest,
segmentId: number,
strategyId: string,
): Promise<unknown> => {
return app.services.segmentService.addToStrategy(segmentId, strategyId);
};
const mockFeatureToggle = (
overrides?: Partial<IFeatureToggleClient>,
): Partial<IFeatureToggleClient> => {
return {
name: randomId(),
strategies: [{ name: randomId(), constraints: [], parameters: {} }],
...overrides,
};
};
const seedConstraints = (spec: ISeedSegmentSpec): IConstraint[] => {
return Array.from({ length: spec.constraintsPerSegment }).map(() => ({
values: Array.from({ length: spec.valuesPerConstraint }).map(() =>
randomId().substring(0, 16),
),
operator: 'IN',
contextName: 'x',
}));
};
const seedSegments = (spec: ISeedSegmentSpec): Partial<ISegment>[] => {
return Array.from({ length: spec.segmentsPerFeature }).map((v, i) => {
return {
name: `${seedSchema}_segment_${i}`,
constraints: seedConstraints(spec),
};
});
};
const seedFeatures = (
spec: ISeedSegmentSpec,
): Partial<IFeatureToggleClient>[] => {
return Array.from({ length: spec.featuresCount }).map((v, i) => {
return mockFeatureToggle({
name: `${seedSchema}_feature_${i}`,
});
});
};
const seedSegmentsDatabase = async (
app: IUnleashTest,
spec: ISeedSegmentSpec,
): Promise<void> => {
await Promise.all(
seedSegments(spec).map((seed) => {
return createSegment(app, seed);
}),
);
await Promise.all(
seedFeatures(spec).map((seed) => {
return createFeatureToggle(app, seed);
}),
);
const features = await fetchFeatures(app);
const segments = await fetchSegments(app);
assert(features.length === spec.featuresCount);
assert(segments.length === spec.segmentsPerFeature);
const addSegment = (feature: IFeatureToggleClient, segment: ISegment) => {
return addSegmentToStrategy(app, segment.id, feature.strategies[0].id);
};
for (const feature of features) {
await Promise.all(
segments.map((segment) => addSegment(feature, segment)),
);
}
};
const main = async (): Promise<void> => {
const db = await dbInit(seedSchema, getLogger);
const app = await setupApp(db.stores);
await seedSegmentsDatabase(app, seedSegmentSpec);
await app.destroy();
await db.destroy();
};
main().catch(console.error);

View File

@ -0,0 +1,77 @@
{
"version": 3,
"features": [
{
"name": "seed_feature_1",
"description": null,
"type": "release",
"project": "default",
"stale": false,
"variants": [],
"createdAt": "2022-03-17T20:18:55.643Z",
"lastSeenAt": null,
"impressionData": false
}
],
"featureStrategies": [
{
"id": "854adc48-7d4e-4433-9c36-5a4cd15491dc",
"featureName": "seed_feature_1",
"projectId": "default",
"environment": "default",
"strategyName": "bc555d20-cfef-490f-a30d-d922e5675b0e",
"parameters": {},
"constraints": [],
"createdAt": "2022-03-17T20:18:55.667Z"
}
],
"featureEnvironments": [
{
"enabled": true,
"featureName": "seed_feature_1",
"environment": "default"
}
],
"segments": [
{
"id": 1,
"name": "seed_segment_1",
"description": null,
"constraints": [
{
"values": [
"878a9f99-96a0-46",
"894fc669-f53d-43"
],
"operator": "IN",
"contextName": "x"
}
],
"createdBy": "some-user",
"createdAt": "2022-03-17T20:18:55.696Z"
},
{
"id": 2,
"name": "seed_segment_2",
"description": null,
"constraints": [
{
"values": [
"894fc669-f53d-43",
"15fecdbf-d3b6-48"
],
"operator": "IN",
"contextName": "x"
}
],
"createdBy": "some-user",
"createdAt": "2022-03-17T20:18:55.696Z"
}
],
"featureStrategySegments": [
{
"featureStrategyId": "854adc48-7d4e-4433-9c36-5a4cd15491dc",
"segmentId": 1
}
]
}

View File

@ -270,6 +270,10 @@ export default class FakeFeatureStrategiesStore
): Promise<boolean> { ): Promise<boolean> {
return Promise.resolve(enabled); return Promise.resolve(enabled);
} }
getStrategiesBySegment(): Promise<IFeatureStrategy[]> {
throw new Error('Method not implemented.');
}
} }
module.exports = FakeFeatureStrategiesStore; module.exports = FakeFeatureStrategiesStore;

54
src/test/fixtures/fake-segment-store.ts vendored Normal file
View File

@ -0,0 +1,54 @@
import { ISegmentStore } from '../../lib/types/stores/segment-store';
import { IFeatureStrategySegment, ISegment } from '../../lib/types/model';
export default class FakeSegmentStore implements ISegmentStore {
create(): Promise<ISegment> {
throw new Error('Method not implemented.');
}
async delete(): Promise<void> {
return;
}
async deleteAll(): Promise<void> {
return;
}
async exists(): Promise<boolean> {
return false;
}
get(): Promise<ISegment> {
throw new Error('Method not implemented.');
}
async getAll(): Promise<ISegment[]> {
return [];
}
async getActive(): Promise<ISegment[]> {
return [];
}
async getByStrategy(): Promise<ISegment[]> {
return [];
}
update(): Promise<ISegment> {
throw new Error('Method not implemented.');
}
addToStrategy(): Promise<void> {
throw new Error('Method not implemented.');
}
removeFromStrategy(): Promise<void> {
throw new Error('Method not implemented.');
}
async getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]> {
return [];
}
destroy(): void {}
}

View File

@ -25,6 +25,7 @@ import FakeFeatureToggleClientStore from './fake-feature-toggle-client-store';
import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2'; import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2';
import FakeUserSplashStore from './fake-user-splash-store'; import FakeUserSplashStore from './fake-user-splash-store';
import FakeRoleStore from './fake-role-store'; import FakeRoleStore from './fake-role-store';
import FakeSegmentStore from './fake-segment-store';
const createStores: () => IUnleashStores = () => { const createStores: () => IUnleashStores = () => {
const db = { const db = {
@ -61,6 +62,7 @@ const createStores: () => IUnleashStores = () => {
sessionStore: new FakeSessionStore(), sessionStore: new FakeSessionStore(),
userSplashStore: new FakeUserSplashStore(), userSplashStore: new FakeUserSplashStore(),
roleStore: new FakeRoleStore(), roleStore: new FakeRoleStore(),
segmentStore: new FakeSegmentStore(),
}; };
}; };