1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-22 01:16:07 +02:00

feat: enforce no transitive parents (#4818)

This commit is contained in:
Mateusz Kwasniewski 2023-09-25 10:12:32 +02:00 committed by GitHub
parent eb259a3783
commit 06ea70ef00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 95 additions and 15 deletions

View File

@ -0,0 +1,10 @@
import { Db } from '../../db/db';
import { DependentFeaturesService } from './dependent-features-service';
import { DependentFeaturesStore } from './dependent-features-store';
export const createDependentFeaturesService = (
db: Db,
): DependentFeaturesService => {
const dependentFeaturesStore = new DependentFeaturesStore(db);
return new DependentFeaturesService(dependentFeaturesStore);
};

View File

@ -17,6 +17,7 @@ import {
import { IAuthRequest } from '../../routes/unleash-types'; import { IAuthRequest } from '../../routes/unleash-types';
import { InvalidOperationError } from '../../error'; import { InvalidOperationError } from '../../error';
import { DependentFeaturesService } from './dependent-features-service'; import { DependentFeaturesService } from './dependent-features-service';
import { TransactionCreator, UnleashTransaction } from '../../db/transaction';
interface FeatureParams { interface FeatureParams {
featureName: string; featureName: string;
@ -28,11 +29,15 @@ const PATH_DEPENDENCIES = `${PATH_FEATURE}/dependencies`;
type DependentFeaturesServices = Pick< type DependentFeaturesServices = Pick<
IUnleashServices, IUnleashServices,
'dependentFeaturesService' | 'openApiService' 'transactionalDependentFeaturesService' | 'openApiService'
>; >;
export default class DependentFeaturesController extends Controller { export default class DependentFeaturesController extends Controller {
private dependentFeaturesService: DependentFeaturesService; private transactionalDependentFeaturesService: (
db: UnleashTransaction,
) => DependentFeaturesService;
private readonly startTransaction: TransactionCreator<UnleashTransaction>;
private openApiService: OpenApiService; private openApiService: OpenApiService;
@ -42,12 +47,18 @@ export default class DependentFeaturesController extends Controller {
constructor( constructor(
config: IUnleashConfig, config: IUnleashConfig,
{ dependentFeaturesService, openApiService }: DependentFeaturesServices, {
transactionalDependentFeaturesService,
openApiService,
}: DependentFeaturesServices,
startTransaction: TransactionCreator<UnleashTransaction>,
) { ) {
super(config); super(config);
this.dependentFeaturesService = dependentFeaturesService; this.transactionalDependentFeaturesService =
transactionalDependentFeaturesService;
this.openApiService = openApiService; this.openApiService = openApiService;
this.flagResolver = config.flagResolver; this.flagResolver = config.flagResolver;
this.startTransaction = startTransaction;
this.logger = config.getLogger( this.logger = config.getLogger(
'/dependent-features/dependent-feature-service.ts', '/dependent-features/dependent-feature-service.ts',
); );
@ -84,13 +95,14 @@ export default class DependentFeaturesController extends Controller {
const { variants, enabled, feature } = req.body; const { variants, enabled, feature } = req.body;
if (this.config.flagResolver.isEnabled('dependentFeatures')) { if (this.config.flagResolver.isEnabled('dependentFeatures')) {
await this.dependentFeaturesService.upsertFeatureDependency( await this.startTransaction(async (tx) =>
featureName, this.transactionalDependentFeaturesService(
{ tx,
).upsertFeatureDependency(featureName, {
variants, variants,
enabled, enabled,
feature, feature,
}, }),
); );
res.status(200).end(); res.status(200).end();
} else { } else {

View File

@ -1,3 +1,4 @@
import { InvalidOperationError } from '../../error';
import { CreateDependentFeatureSchema } from '../../openapi'; import { CreateDependentFeatureSchema } from '../../openapi';
import { IDependentFeaturesStore } from './dependent-features-store-type'; import { IDependentFeaturesStore } from './dependent-features-store-type';
@ -17,20 +18,28 @@ export class DependentFeaturesService {
} }
async upsertFeatureDependency( async upsertFeatureDependency(
childFeature: string, child: string,
dependentFeature: CreateDependentFeatureSchema, dependentFeature: CreateDependentFeatureSchema,
): Promise<void> { ): Promise<void> {
const { enabled, feature, variants } = dependentFeature; const { enabled, feature: parent, variants } = dependentFeature;
const children = await this.dependentFeaturesStore.getChildren(child);
if (children.length > 0) {
throw new InvalidOperationError(
'Transitive dependency detected. Cannot add a dependency to the feature that other features depend on.',
);
}
const featureDependency: FeatureDependency = const featureDependency: FeatureDependency =
enabled === false enabled === false
? { ? {
parent: feature, parent,
child: childFeature, child,
enabled, enabled,
} }
: { : {
parent: feature, parent,
child: childFeature, child,
enabled: true, enabled: true,
variants, variants,
}; };

View File

@ -2,4 +2,5 @@ import { FeatureDependency } from './dependent-features-service';
export interface IDependentFeaturesStore { export interface IDependentFeaturesStore {
upsert(featureDependency: FeatureDependency): Promise<void>; upsert(featureDependency: FeatureDependency): Promise<void>;
getChildren(parent: string): Promise<string[]>;
} }

View File

@ -5,6 +5,7 @@ import { IDependentFeaturesStore } from './dependent-features-store-type';
type SerializableFeatureDependency = Omit<FeatureDependency, 'variants'> & { type SerializableFeatureDependency = Omit<FeatureDependency, 'variants'> & {
variants?: string; variants?: string;
}; };
export class DependentFeaturesStore implements IDependentFeaturesStore { export class DependentFeaturesStore implements IDependentFeaturesStore {
private db: Db; private db: Db;
@ -28,4 +29,13 @@ export class DependentFeaturesStore implements IDependentFeaturesStore {
.onConflict(['parent', 'child']) .onConflict(['parent', 'child'])
.merge(); .merge();
} }
async getChildren(parent: string): Promise<string[]> {
const rows = await this.db('dependent_features').where(
'parent',
parent,
);
return rows.map((row) => row.child);
}
} }

View File

@ -61,3 +61,23 @@ test('should add feature dependency', async () => {
variants: ['variantB'], variants: ['variantB'],
}); });
}); });
test('should not allow to add a parent dependency to a feature that already has children', async () => {
const grandparent = uuidv4();
const parent = uuidv4();
const child = uuidv4();
await app.createFeature(grandparent);
await app.createFeature(parent);
await app.createFeature(child);
await addFeatureDependency(child, {
feature: parent,
});
await addFeatureDependency(
parent,
{
feature: grandparent,
},
403,
);
});

View File

@ -4,4 +4,8 @@ export class FakeDependentFeaturesStore implements IDependentFeaturesStore {
async upsert(): Promise<void> { async upsert(): Promise<void> {
return Promise.resolve(); return Promise.resolve();
} }
getChildren(): Promise<string[]> {
return Promise.resolve([]);
}
} }

View File

@ -113,7 +113,14 @@ export default class ProjectApi extends Controller {
createKnexTransactionStarter(db), createKnexTransactionStarter(db),
).router, ).router,
); );
this.use('/', new DependentFeaturesController(config, services).router); this.use(
'/',
new DependentFeaturesController(
config,
services,
createKnexTransactionStarter(db),
).router,
);
this.use('/', new EnvironmentsController(config, services).router); this.use('/', new EnvironmentsController(config, services).router);
this.use('/', new ProjectHealthReport(config, services).router); this.use('/', new ProjectHealthReport(config, services).router);
this.use('/', new VariantsController(config, services).router); this.use('/', new VariantsController(config, services).router);

View File

@ -69,6 +69,7 @@ import {
createGetActiveUsers, createGetActiveUsers,
} from '../features/instance-stats/getActiveUsers'; } from '../features/instance-stats/getActiveUsers';
import { DependentFeaturesService } from '../features/dependent-features/dependent-features-service'; import { DependentFeaturesService } from '../features/dependent-features/dependent-features-service';
import { createDependentFeaturesService } from '../features/dependent-features/createDependentFeaturesService';
// TODO: will be moved to scheduler feature directory // TODO: will be moved to scheduler feature directory
export const scheduleServices = async ( export const scheduleServices = async (
@ -299,6 +300,8 @@ export const createServices = (
const dependentFeaturesService = new DependentFeaturesService( const dependentFeaturesService = new DependentFeaturesService(
stores.dependentFeaturesStore, stores.dependentFeaturesStore,
); );
const transactionalDependentFeaturesService = (txDb: Knex.Transaction) =>
createDependentFeaturesService(txDb);
return { return {
accessService, accessService,
@ -351,6 +354,7 @@ export const createServices = (
transactionalGroupService, transactionalGroupService,
privateProjectChecker, privateProjectChecker,
dependentFeaturesService, dependentFeaturesService,
transactionalDependentFeaturesService,
}; };
}; };

View File

@ -101,4 +101,7 @@ export interface IUnleashServices {
transactionalGroupService: (db: Knex.Transaction) => GroupService; transactionalGroupService: (db: Knex.Transaction) => GroupService;
privateProjectChecker: IPrivateProjectChecker; privateProjectChecker: IPrivateProjectChecker;
dependentFeaturesService: DependentFeaturesService; dependentFeaturesService: DependentFeaturesService;
transactionalDependentFeaturesService: (
db: Knex.Transaction,
) => DependentFeaturesService;
} }