diff --git a/src/lib/features/dependent-features/dependent-features-controller.ts b/src/lib/features/dependent-features/dependent-features-controller.ts index 3ec5c7a4050..eacb5bd123b 100644 --- a/src/lib/features/dependent-features/dependent-features-controller.ts +++ b/src/lib/features/dependent-features/dependent-features-controller.ts @@ -15,7 +15,10 @@ import { createResponseSchema, emptyResponse, getStandardResponses, + parentFeatureOptionsSchema, type ParentFeatureOptionsSchema, + type ParentVariantOptionsSchema, + parentVariantOptionsSchema, } from '../../openapi'; import type { IAuthRequest } from '../../routes/unleash-types'; import type { DependentFeaturesService } from './dependent-features-service'; @@ -29,6 +32,10 @@ interface FeatureParams extends ProjectParams { child: string; } +interface ParentVariantsParams extends ProjectParams { + parent: string; +} + interface DeleteDependencyParams extends ProjectParams { child: string; parent: string; @@ -39,6 +46,7 @@ const PATH_FEATURE = `${PATH}/:child`; const PATH_DEPENDENCIES = `${PATH_FEATURE}/dependencies`; const PATH_DEPENDENCIES_CHECK = `/:projectId/dependencies`; const PATH_PARENTS = `${PATH_FEATURE}/parents`; +const PATH_PARENT_VARIANTS = `${PATH}/:parent/parent-variants`; const PATH_DEPENDENCY = `${PATH_FEATURE}/dependencies/:parent`; type DependentFeaturesServices = Pick< @@ -136,7 +144,7 @@ export default class DependentFeaturesController extends Controller { this.route({ method: 'get', path: PATH_PARENTS, - handler: this.getParentOptions, + handler: this.getPossibleParentFeatures, permission: NONE, middleware: [ openApiService.validPath({ @@ -153,6 +161,26 @@ export default class DependentFeaturesController extends Controller { ], }); + this.route({ + method: 'get', + path: PATH_PARENT_VARIANTS, + handler: this.getPossibleParentVariants, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Dependencies'], + summary: 'List parent feature variants.', + description: + 'List available parent variants across all strategy variants and feature environment variants.', + operationId: 'listParentVariantOptions', + responses: { + 200: createResponseSchema('parentVariantOptionsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + this.route({ method: 'get', path: PATH_DEPENDENCIES_CHECK, @@ -227,15 +255,42 @@ export default class DependentFeaturesController extends Controller { res.status(200).end(); } - async getParentOptions( + async getPossibleParentFeatures( req: IAuthRequest, res: Response, ): Promise { const { child } = req.params; - const parentOptions = - await this.dependentFeaturesService.getParentOptions(child); - res.send(parentOptions); + const options = + await this.dependentFeaturesService.getPossibleParentFeatures( + child, + ); + + this.openApiService.respondWithValidation( + 200, + res, + parentFeatureOptionsSchema.$id, + options, + ); + } + + async getPossibleParentVariants( + req: IAuthRequest, + res: Response, + ): Promise { + const { parent } = req.params; + + const options = + await this.dependentFeaturesService.getPossibleParentVariants( + parent, + ); + + this.openApiService.respondWithValidation( + 200, + res, + parentVariantOptionsSchema.$id, + options, + ); } async checkDependenciesExist( diff --git a/src/lib/features/dependent-features/dependent-features-read-model-type.ts b/src/lib/features/dependent-features/dependent-features-read-model-type.ts index 6ceba695cde..6f6635550da 100644 --- a/src/lib/features/dependent-features/dependent-features-read-model-type.ts +++ b/src/lib/features/dependent-features/dependent-features-read-model-type.ts @@ -7,7 +7,8 @@ export interface IDependentFeaturesReadModel { getOrphanParents(parentsAndChildren: string[]): Promise; getParents(child: string): Promise; getDependencies(children: string[]): Promise; - getParentOptions(child: string): Promise; + getPossibleParentFeatures(child: string): Promise; + getPossibleParentVariants(parent: string): Promise; haveDependencies(features: string[]): Promise; hasAnyDependencies(): Promise; } diff --git a/src/lib/features/dependent-features/dependent-features-read-model.ts b/src/lib/features/dependent-features/dependent-features-read-model.ts index dd1a6264ddf..2d61da87246 100644 --- a/src/lib/features/dependent-features/dependent-features-read-model.ts +++ b/src/lib/features/dependent-features/dependent-features-read-model.ts @@ -2,6 +2,10 @@ import type { Db } from '../../db/db'; import type { IDependentFeaturesReadModel } from './dependent-features-read-model-type'; import type { IDependency, IFeatureDependency } from '../../types'; +interface IVariantName { + variant_name: string; +} + export class DependentFeaturesReadModel implements IDependentFeaturesReadModel { private db: Db; @@ -59,7 +63,7 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel { })); } - async getParentOptions(child: string): Promise { + async getPossibleParentFeatures(child: string): Promise { const result = await this.db('features') .where('features.name', child) .select('features.project'); @@ -82,6 +86,35 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel { return rows.map((item) => item.name); } + async getPossibleParentVariants(parent: string): Promise { + const strategyVariantsQuery = this.db('feature_strategies') + .select( + this.db.raw( + "jsonb_array_elements(variants)->>'name' as variant_name", + ), + ) + .where('feature_name', parent); + + const featureEnvironmentVariantsQuery = this.db('feature_environments') + .select( + this.db.raw( + "jsonb_array_elements(variants)->>'name' as variant_name", + ), + ) + .where('feature_name', parent); + + const results = await Promise.all([ + strategyVariantsQuery, + featureEnvironmentVariantsQuery, + ]); + const flatResults = results + .flat() + .map((item) => (item as unknown as IVariantName).variant_name); + const uniqueResults = [...new Set(flatResults)]; + + return uniqueResults.sort(); + } + async haveDependencies(features: string[]): Promise { const parents = await this.db('dependent_features') .whereIn('parent', features) diff --git a/src/lib/features/dependent-features/dependent-features-service.ts b/src/lib/features/dependent-features/dependent-features-service.ts index 4274793c7ec..88e46e9d21b 100644 --- a/src/lib/features/dependent-features/dependent-features-service.ts +++ b/src/lib/features/dependent-features/dependent-features-service.ts @@ -227,8 +227,16 @@ export class DependentFeaturesService { ); } - async getParentOptions(feature: string): Promise { - return this.dependentFeaturesReadModel.getParentOptions(feature); + async getPossibleParentFeatures(feature: string): Promise { + return this.dependentFeaturesReadModel.getPossibleParentFeatures( + feature, + ); + } + + async getPossibleParentVariants(parentFeature: string): Promise { + return this.dependentFeaturesReadModel.getPossibleParentVariants( + parentFeature, + ); } async checkDependenciesExist(): Promise { diff --git a/src/lib/features/dependent-features/dependent.features.e2e.test.ts b/src/lib/features/dependent-features/dependent.features.e2e.test.ts index 0e476dfa9b1..2d6e0850315 100644 --- a/src/lib/features/dependent-features/dependent.features.e2e.test.ts +++ b/src/lib/features/dependent-features/dependent.features.e2e.test.ts @@ -12,6 +12,7 @@ import { FEATURE_DEPENDENCY_REMOVED, type IEventStore, } from '../../types'; +import { DEFAULT_ENV } from '../../util'; let app: IUnleashTest; let db: ITestDb; @@ -55,6 +56,7 @@ afterAll(async () => { beforeEach(async () => { await db.stores.dependentFeaturesStore.deleteAll(); await db.stores.featureToggleStore.deleteAll(); + await db.stores.featureEnvironmentStore.deleteAll(); }); const addFeatureDependency = async ( @@ -93,12 +95,69 @@ const deleteFeatureDependencies = async ( .expect(expectedCode); }; -const getParentOptions = async (childFeature: string, expectedCode = 200) => { +const getPossibleParentFeatures = async ( + childFeature: string, + expectedCode = 200, +) => { return app.request .get(`/api/admin/projects/default/features/${childFeature}/parents`) .expect(expectedCode); }; +const getPossibleParentVariants = async ( + parentFeature: string, + expectedCode = 200, +) => { + return app.request + .get( + `/api/admin/projects/default/features/${parentFeature}/parent-variants`, + ) + .expect(expectedCode); +}; + +const addStrategyVariants = async (parent: string, variants: string[]) => { + await app.addStrategyToFeatureEnv( + { + name: 'flexibleRollout', + constraints: [], + parameters: { rollout: '100', stickiness: 'default' }, + variants: variants.map((name) => ({ + name, + weight: 1000, + weightType: 'variable', + stickiness: 'default', + })), + }, + DEFAULT_ENV, + parent, + ); +}; + +const addFeatureEnvironmentVariant = async ( + parent: string, + variant: string, +) => { + await app.request + .patch( + `/api/admin/projects/default/features/${parent}/environments/${DEFAULT_ENV}/variants`, + ) + .set('Content-Type', 'application/json') + .send([ + { + op: 'add', + path: '/0', + value: { + name: variant, + weightType: 'variable', + weight: 1000, + overrides: [], + stickiness: 'default', + }, + }, + ]) + .expect(200); +}; + const checkDependenciesExist = async (expectedCode = 200) => { return app.request .get(`/api/admin/projects/default/dependencies`) @@ -111,8 +170,8 @@ test('should add and delete feature dependencies', async () => { await app.createFeature(parent); await app.createFeature(child); - const { body: parentOptions } = await getParentOptions(child); - expect(parentOptions).toStrictEqual([parent]); + const { body: options } = await getPossibleParentFeatures(child); + expect(options).toStrictEqual([parent]); // save explicit enabled and variants await addFeatureDependency(child, { @@ -136,7 +195,7 @@ test('should add and delete feature dependencies', async () => { ]); }); -test('should sort parent options alphabetically', async () => { +test('should sort potential parent features alphabetically', async () => { const parent1 = `a${uuidv4()}`; const parent2 = `c${uuidv4()}`; const parent3 = `b${uuidv4()}`; @@ -146,8 +205,20 @@ test('should sort parent options alphabetically', async () => { await app.createFeature(parent3); await app.createFeature(child); - const { body: parentOptions } = await getParentOptions(child); - expect(parentOptions).toStrictEqual([parent1, parent3, parent2]); + const { body: options } = await getPossibleParentFeatures(child); + expect(options).toStrictEqual([parent1, parent3, parent2]); +}); + +test('should sort potential parent variants', async () => { + const parent = uuidv4(); + await app.createFeature(parent); + await addFeatureEnvironmentVariant(parent, 'e'); + await addStrategyVariants(parent, ['c', 'a', 'd']); + await addStrategyVariants(parent, ['b', 'd']); + + const { body: variants } = await getPossibleParentVariants(parent); + + expect(variants).toStrictEqual(['a', 'b', 'c', 'd', 'e']); }); test('should not allow to add grandparent', async () => { diff --git a/src/lib/features/dependent-features/fake-dependent-features-read-model.ts b/src/lib/features/dependent-features/fake-dependent-features-read-model.ts index 925a3d4c29d..d2b79eed4df 100644 --- a/src/lib/features/dependent-features/fake-dependent-features-read-model.ts +++ b/src/lib/features/dependent-features/fake-dependent-features-read-model.ts @@ -15,7 +15,11 @@ export class FakeDependentFeaturesReadModel return Promise.resolve([]); } - getParentOptions(): Promise { + getPossibleParentFeatures(): Promise { + return Promise.resolve([]); + } + + getPossibleParentVariants(): Promise { return Promise.resolve([]); } diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 1c5c251019d..b11e3123944 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -117,6 +117,7 @@ export * from './outdated-sdks-schema'; export * from './override-schema'; export * from './parameters-schema'; export * from './parent-feature-options-schema'; +export * from './parent-variant-options-schema'; export * from './password-schema'; export * from './pat-schema'; export * from './patch-schema'; diff --git a/src/lib/openapi/spec/parent-variant-options-schema.ts b/src/lib/openapi/spec/parent-variant-options-schema.ts new file mode 100644 index 00000000000..cecd037b772 --- /dev/null +++ b/src/lib/openapi/spec/parent-variant-options-schema.ts @@ -0,0 +1,16 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const parentVariantOptionsSchema = { + $id: '#/components/schemas/parentVariantOptionsSchema', + type: 'array', + description: + 'A list of parent variant names available for a given parent feature. This list includes strategy variants and feature environment variants.', + items: { + type: 'string', + }, + components: {}, +} as const; + +export type ParentVariantOptionsSchema = FromSchema< + typeof parentVariantOptionsSchema +>;