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:
parent
eb259a3783
commit
06ea70ef00
@ -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);
|
||||||
|
};
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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[]>;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
@ -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([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user