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

feat: feature admin API returns dependencies and children (#4848)

This commit is contained in:
Mateusz Kwasniewski 2023-09-27 15:07:20 +02:00 committed by GitHub
parent fd8775f13d
commit 87a81120d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 190 additions and 17 deletions

View File

@ -1,5 +1,7 @@
import { IDependency } from '../../types';
export interface IDependentFeaturesReadModel {
getChildren(parent: string): Promise<string[]>;
getParents(child: string): Promise<string[]>;
getParents(child: string): Promise<IDependency[]>;
getParentOptions(child: string): Promise<string[]>;
}

View File

@ -1,5 +1,6 @@
import { Db } from '../../db/db';
import { IDependentFeaturesReadModel } from './dependent-features-read-model-type';
import { IDependency } from '../../types';
export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
private db: Db;
@ -17,10 +18,14 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
return rows.map((row) => row.child);
}
async getParents(child: string): Promise<string[]> {
async getParents(child: string): Promise<IDependency[]> {
const rows = await this.db('dependent_features').where('child', child);
return rows.map((row) => row.parent);
return rows.map((row) => ({
feature: row.parent,
enabled: row.enabled,
variants: row.variants,
}));
}
async getParentOptions(child: string): Promise<string[]> {

View File

@ -1,4 +1,5 @@
import { IDependentFeaturesReadModel } from './dependent-features-read-model-type';
import { IDependency } from '../../types';
export class FakeDependentFeaturesReadModel
implements IDependentFeaturesReadModel
@ -7,7 +8,7 @@ export class FakeDependentFeaturesReadModel
return Promise.resolve([]);
}
getParents(): Promise<string[]> {
getParents(): Promise<IDependency[]> {
return Promise.resolve([]);
}

View File

@ -45,6 +45,8 @@ import {
createFakePrivateProjectChecker,
createPrivateProjectChecker,
} from '../private-project/createPrivateProjectChecker';
import { DependentFeaturesReadModel } from '../dependent-features/dependent-features-read-model';
import { FakeDependentFeaturesReadModel } from '../dependent-features/fake-dependent-features-read-model';
export const createFeatureToggleService = (
db: Db,
@ -105,6 +107,8 @@ export const createFeatureToggleService = (
const privateProjectChecker = createPrivateProjectChecker(db, config);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(db);
const featureToggleService = new FeatureToggleService(
{
featureStrategiesStore,
@ -122,6 +126,7 @@ export const createFeatureToggleService = (
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
return featureToggleService;
};
@ -155,7 +160,8 @@ export const createFakeFeatureToggleService = (
);
const segmentService = createFakeSegmentService(config);
const changeRequestAccessReadModel = createFakeChangeRequestAccessService();
const fakeprivateProjectChecker = createFakePrivateProjectChecker();
const fakePrivateProjectChecker = createFakePrivateProjectChecker();
const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel();
const featureToggleService = new FeatureToggleService(
{
featureStrategiesStore,
@ -172,7 +178,8 @@ export const createFakeFeatureToggleService = (
segmentService,
accessService,
changeRequestAccessReadModel,
fakeprivateProjectChecker,
fakePrivateProjectChecker,
dependentFeaturesReadModel,
);
return featureToggleService;
};

View File

@ -121,6 +121,47 @@ export const featureSchema = {
nullable: true,
description: 'The list of feature tags',
},
children: {
type: 'array',
description:
'The list of child feature names. This is an experimental field and may change.',
items: {
type: 'string',
example: 'some-feature',
},
},
dependencies: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['feature'],
properties: {
feature: {
description: 'The name of the parent feature',
type: 'string',
example: 'some-feature',
},
enabled: {
description:
'Whether the parent feature is enabled or not',
type: 'boolean',
example: true,
},
variants: {
description:
'The list of variants the parent feature should resolve to. Only valid when feature is enabled.',
type: 'array',
items: {
example: 'some-feature-blue-variant',
type: 'string',
},
},
},
},
description:
'The list of parent dependencies. This is an experimental field and may change.',
},
},
components: {
schemas: {

View File

@ -10,6 +10,7 @@ import { AccessService } from './access-service';
import { IChangeRequestAccessReadModel } from 'lib/features/change-request-access-service/change-request-access-read-model';
import { ISegmentService } from 'lib/segments/segment-service-interface';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
import { IDependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model-type';
test('Should only store events for potentially stale on', async () => {
expect.assertions(2);
@ -51,6 +52,7 @@ test('Should only store events for potentially stale on', async () => {
{} as AccessService,
{} as IChangeRequestAccessReadModel,
{} as IPrivateProjectChecker,
{} as IDependentFeaturesReadModel,
);
await featureToggleService.updatePotentiallyStaleFeatures();

View File

@ -16,9 +16,11 @@ import {
FeatureToggle,
FeatureToggleDTO,
FeatureToggleLegacy,
FeatureToggleWithDependencies,
FeatureToggleWithEnvironment,
FeatureVariantEvent,
IConstraint,
IDependency,
IEventStore,
IFeatureEnvironmentInfo,
IFeatureEnvironmentStore,
@ -96,6 +98,7 @@ import { ISegmentService } from 'lib/segments/segment-service-interface';
import { IChangeRequestAccessReadModel } from '../features/change-request-access-service/change-request-access-read-model';
import { checkFeatureFlagNamesAgainstPattern } from '../features/feature-naming-pattern/feature-naming-validation';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
import { IDependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model-type';
interface IFeatureContext {
featureName: string;
@ -157,6 +160,8 @@ class FeatureToggleService {
private privateProjectChecker: IPrivateProjectChecker;
private dependentFeaturesReadModel: IDependentFeaturesReadModel;
constructor(
{
featureStrategiesStore,
@ -188,6 +193,7 @@ class FeatureToggleService {
accessService: AccessService,
changeRequestAccessReadModel: IChangeRequestAccessReadModel,
privateProjectChecker: IPrivateProjectChecker,
dependentFeaturesReadModel: IDependentFeaturesReadModel,
) {
this.logger = getLogger('services/feature-toggle-service.ts');
this.featureStrategiesStore = featureStrategiesStore;
@ -204,6 +210,7 @@ class FeatureToggleService {
this.flagResolver = flagResolver;
this.changeRequestAccessReadModel = changeRequestAccessReadModel;
this.privateProjectChecker = privateProjectChecker;
this.dependentFeaturesReadModel = dependentFeaturesReadModel;
}
async validateFeaturesContext(
@ -921,7 +928,7 @@ class FeatureToggleService {
projectId,
environmentVariants,
userId,
}: IGetFeatureParams): Promise<FeatureToggleWithEnvironment> {
}: IGetFeatureParams): Promise<FeatureToggleWithDependencies> {
if (projectId) {
await this.validateFeatureBelongsToProject({
featureName,
@ -929,18 +936,31 @@ class FeatureToggleService {
});
}
let dependencies: IDependency[] = [];
let children: string[] = [];
if (this.flagResolver.isEnabled('dependentFeatures')) {
[dependencies, children] = await Promise.all([
this.dependentFeaturesReadModel.getParents(featureName),
this.dependentFeaturesReadModel.getChildren(featureName),
]);
}
if (environmentVariants) {
return this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(
featureName,
userId,
archived,
);
const result =
await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(
featureName,
userId,
archived,
);
return { ...result, dependencies, children };
} else {
return this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
userId,
archived,
);
const result =
await this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
userId,
archived,
);
return { ...result, dependencies, children };
}
}

View File

@ -73,6 +73,8 @@ import {
createDependentFeaturesService,
createFakeDependentFeaturesService,
} from '../features/dependent-features/createDependentFeaturesService';
import { DependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model';
import { FakeDependentFeaturesReadModel } from '../features/dependent-features/fake-dependent-features-read-model';
// TODO: will be moved to scheduler feature directory
export const scheduleServices = async (
@ -175,6 +177,9 @@ export const createServices = (
const privateProjectChecker = db
? createPrivateProjectChecker(db, config)
: createFakePrivateProjectChecker();
const dependentFeaturesReadModel = db
? new DependentFeaturesReadModel(db)
: new FakeDependentFeaturesReadModel();
const contextService = new ContextService(
stores,
@ -227,6 +232,7 @@ export const createServices = (
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
const environmentService = new EnvironmentService(stores, config);
const featureTagService = new FeatureTagService(stores, config);

View File

@ -96,6 +96,12 @@ export interface FeatureToggleWithEnvironment extends FeatureToggle {
environments: IEnvironmentDetail[];
}
export interface FeatureToggleWithDependencies
extends FeatureToggleWithEnvironment {
dependencies: IDependency[];
children: string[];
}
// @deprecated
export interface FeatureToggleLegacy extends FeatureToggle {
strategies: IStrategyConfig[];

View File

@ -91,6 +91,7 @@ beforeAll(async () => {
experimental: {
flags: {
strictSchemaValidation: true,
dependentFeatures: true,
},
},
},
@ -214,6 +215,32 @@ test('Can get project overview', async () => {
});
});
test('should list dependencies and children', async () => {
const parent = uuidv4();
const child = uuidv4();
await app.createFeature(parent, 'default');
await app.createFeature(child, 'default');
await app.addDependency(child, parent);
const { body: childFeature } = await app.getProjectFeatures(
'default',
child,
);
const { body: parentFeature } = await app.getProjectFeatures(
'default',
parent,
);
expect(childFeature).toMatchObject({
children: [],
dependencies: [{ feature: parent, enabled: true, variants: [] }],
});
expect(parentFeature).toMatchObject({
children: [child],
dependencies: [],
});
});
test('Can get features for project', async () => {
await app.request
.post('/api/admin/projects/default/features')

View File

@ -20,6 +20,7 @@ beforeAll(async () => {
flags: {
strictSchemaValidation: true,
featureNamingPattern: true,
dependentFeatures: true,
},
},
},

View File

@ -63,6 +63,8 @@ export interface IUnleashHttpAPI {
importPayload: ImportTogglesSchema,
expectedResponseCode?: number,
): supertest.Test;
addDependency(child: string, parent: string): supertest.Test;
}
function httpApis(
@ -161,6 +163,21 @@ function httpApis(
.set('Content-Type', 'application/json')
.expect(expectedResponseCode);
},
addDependency(
child: string,
parent: string,
project = DEFAULT_PROJECT,
expectedResponseCode: number = 200,
): supertest.Test {
return request
.post(
`/api/admin/projects/${project}/features/${child}/dependencies`,
)
.send({ feature: parent })
.set('Content-Type', 'application/json')
.expect(expectedResponseCode);
},
};
}

View File

@ -23,6 +23,7 @@ import { GroupService } from '../../../lib/services/group-service';
import { FavoritesService } from '../../../lib/services';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model';
let db: ITestDb;
let stores: IUnleashStores;
@ -250,6 +251,9 @@ beforeAll(async () => {
db.rawDatabase,
config,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
featureToggleService = new FeatureToggleService(
stores,
config,
@ -262,6 +266,7 @@ beforeAll(async () => {
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
favoritesService = new FavoritesService(stores, config);
projectService = new ProjectService(

View File

@ -13,6 +13,7 @@ import { GroupService } from '../../../lib/services/group-service';
import { FavoritesService } from '../../../lib/services';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model';
let db;
let stores;
@ -36,6 +37,9 @@ beforeAll(async () => {
db.rawDatabase,
config,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
const featureToggleService = new FeatureToggleService(
stores,
config,
@ -48,6 +52,7 @@ beforeAll(async () => {
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
const project = {
id: 'test-project',

View File

@ -24,6 +24,7 @@ import {
import { ISegmentService } from '../../../lib/segments/segment-service-interface';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model';
let stores: IUnleashStores;
let db;
@ -63,6 +64,9 @@ beforeAll(async () => {
db.rawDatabase,
config,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
segmentService = new SegmentService(
stores,
changeRequestAccessReadModel,
@ -77,6 +81,7 @@ beforeAll(async () => {
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
});
@ -466,6 +471,9 @@ test('If change requests are enabled, cannot change variants without going via C
db.rawDatabase,
unleashConfig,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
// Force all feature flags on to make sure we have Change requests on
const customFeatureService = new FeatureToggleService(
stores,
@ -479,6 +487,7 @@ test('If change requests are enabled, cannot change variants without going via C
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
const newVariant: IVariant = {
@ -554,6 +563,9 @@ test('If CRs are protected for any environment in the project stops bulk update
db.rawDatabase,
unleashConfig,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
// Force all feature flags on to make sure we have Change requests on
const customFeatureService = new FeatureToggleService(
stores,
@ -567,6 +579,7 @@ test('If CRs are protected for any environment in the project stops bulk update
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
const toggle = await service.createFeatureToggle(

View File

@ -26,6 +26,7 @@ import { AccessService } from '../../../lib/services/access-service';
import { ISegmentService } from '../../../lib/segments/segment-service-interface';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model';
let stores: IUnleashStores;
let db: ITestDb;
@ -47,6 +48,9 @@ beforeAll(async () => {
db.rawDatabase,
config,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
segmentService = new SegmentService(
stores,
changeRequestAccessReadModel,
@ -61,6 +65,7 @@ beforeAll(async () => {
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
service = new PlaygroundService(config, {
featureToggleServiceV2: featureToggleService,

View File

@ -12,6 +12,7 @@ import { GroupService } from '../../../lib/services/group-service';
import { FavoritesService } from '../../../lib/services';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model';
let stores: IUnleashStores;
let db: ITestDb;
@ -41,6 +42,9 @@ beforeAll(async () => {
db.rawDatabase,
config,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
featureToggleService = new FeatureToggleService(
stores,
config,
@ -53,6 +57,7 @@ beforeAll(async () => {
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
favoritesService = new FavoritesService(stores, config);

View File

@ -16,6 +16,7 @@ import { FeatureEnvironmentEvent } from '../../../lib/types/events';
import { addDays, subDays } from 'date-fns';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model';
let stores;
let db: ITestDb;
@ -62,6 +63,9 @@ beforeAll(async () => {
db.rawDatabase,
config,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
featureToggleService = new FeatureToggleService(
stores,
config,
@ -74,6 +78,7 @@ beforeAll(async () => {
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
favoritesService = new FavoritesService(stores, config);