Skip to content

Commit

Permalink
feat: feature admin API returns dependencies and children (#4848)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Sep 27, 2023
1 parent fd8775f commit 87a8112
Show file tree
Hide file tree
Showing 18 changed files with 190 additions and 17 deletions.
@@ -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[]>;
}
@@ -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;
Expand All @@ -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[]> {
Expand Down
@@ -1,4 +1,5 @@
import { IDependentFeaturesReadModel } from './dependent-features-read-model-type';
import { IDependency } from '../../types';

export class FakeDependentFeaturesReadModel
implements IDependentFeaturesReadModel
Expand All @@ -7,7 +8,7 @@ export class FakeDependentFeaturesReadModel
return Promise.resolve([]);
}

getParents(): Promise<string[]> {
getParents(): Promise<IDependency[]> {
return Promise.resolve([]);
}

Expand Down
11 changes: 9 additions & 2 deletions src/lib/features/feature-toggle/createFeatureToggleService.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -105,6 +107,8 @@ export const createFeatureToggleService = (

const privateProjectChecker = createPrivateProjectChecker(db, config);

const dependentFeaturesReadModel = new DependentFeaturesReadModel(db);

const featureToggleService = new FeatureToggleService(
{
featureStrategiesStore,
Expand All @@ -122,6 +126,7 @@ export const createFeatureToggleService = (
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
return featureToggleService;
};
Expand Down Expand Up @@ -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,
Expand All @@ -172,7 +178,8 @@ export const createFakeFeatureToggleService = (
segmentService,
accessService,
changeRequestAccessReadModel,
fakeprivateProjectChecker,
fakePrivateProjectChecker,
dependentFeaturesReadModel,
);
return featureToggleService;
};
41 changes: 41 additions & 0 deletions src/lib/openapi/spec/feature-schema.ts
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/services/feature-service-potentially-stale.test.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
42 changes: 31 additions & 11 deletions src/lib/services/feature-toggle-service.ts
Expand Up @@ -16,9 +16,11 @@ import {
FeatureToggle,
FeatureToggleDTO,
FeatureToggleLegacy,
FeatureToggleWithDependencies,
FeatureToggleWithEnvironment,
FeatureVariantEvent,
IConstraint,
IDependency,
IEventStore,
IFeatureEnvironmentInfo,
IFeatureEnvironmentStore,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -157,6 +160,8 @@ class FeatureToggleService {

private privateProjectChecker: IPrivateProjectChecker;

private dependentFeaturesReadModel: IDependentFeaturesReadModel;

constructor(
{
featureStrategiesStore,
Expand Down Expand Up @@ -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;
Expand All @@ -204,6 +210,7 @@ class FeatureToggleService {
this.flagResolver = flagResolver;
this.changeRequestAccessReadModel = changeRequestAccessReadModel;
this.privateProjectChecker = privateProjectChecker;
this.dependentFeaturesReadModel = dependentFeaturesReadModel;
}

async validateFeaturesContext(
Expand Down Expand Up @@ -921,26 +928,39 @@ class FeatureToggleService {
projectId,
environmentVariants,
userId,
}: IGetFeatureParams): Promise<FeatureToggleWithEnvironment> {
}: IGetFeatureParams): Promise<FeatureToggleWithDependencies> {
if (projectId) {
await this.validateFeatureBelongsToProject({
featureName,
projectId,
});
}

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 };
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/lib/services/index.ts
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -227,6 +232,7 @@ export const createServices = (
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
const environmentService = new EnvironmentService(stores, config);
const featureTagService = new FeatureTagService(stores, config);
Expand Down
6 changes: 6 additions & 0 deletions src/lib/types/model.ts
Expand Up @@ -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[];
Expand Down
27 changes: 27 additions & 0 deletions src/test/e2e/api/admin/project/features.e2e.test.ts
Expand Up @@ -91,6 +91,7 @@ beforeAll(async () => {
experimental: {
flags: {
strictSchemaValidation: true,
dependentFeatures: true,
},
},
},
Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions src/test/e2e/api/client/feature.e2e.test.ts
Expand Up @@ -20,6 +20,7 @@ beforeAll(async () => {
flags: {
strictSchemaValidation: true,
featureNamingPattern: true,
dependentFeatures: true,
},
},
},
Expand Down
17 changes: 17 additions & 0 deletions src/test/e2e/helpers/test-helper.ts
Expand Up @@ -63,6 +63,8 @@ export interface IUnleashHttpAPI {
importPayload: ImportTogglesSchema,
expectedResponseCode?: number,
): supertest.Test;

addDependency(child: string, parent: string): supertest.Test;
}

function httpApis(
Expand Down Expand Up @@ -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);
},
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/test/e2e/services/access-service.e2e.test.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -250,6 +251,9 @@ beforeAll(async () => {
db.rawDatabase,
config,
);
const dependentFeaturesReadModel = new DependentFeaturesReadModel(
db.rawDatabase,
);
featureToggleService = new FeatureToggleService(
stores,
config,
Expand All @@ -262,6 +266,7 @@ beforeAll(async () => {
accessService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
);
favoritesService = new FavoritesService(stores, config);
projectService = new ProjectService(
Expand Down

0 comments on commit 87a8112

Please sign in to comment.