From e030b67a19cc199199683b6a743a3536c7023dee Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 26 Sep 2023 14:03:24 +0200 Subject: [PATCH] feat: Api to list available parent options (#4833) --- .../dependent-features-controller.ts | 41 +++++++++++++++++++ .../dependent-features-service.ts | 4 ++ .../dependent-features-store-type.ts | 1 + .../dependent-features-store.ts | 17 ++++++++ .../dependent.features.e2e.test.ts | 9 ++++ .../fake-dependent-features-store.ts | 4 ++ src/lib/openapi/index.ts | 2 + src/lib/openapi/spec/index.ts | 1 + .../spec/parent-feature-options-schema.ts | 16 ++++++++ 9 files changed, 95 insertions(+) create mode 100644 src/lib/openapi/spec/parent-feature-options-schema.ts diff --git a/src/lib/features/dependent-features/dependent-features-controller.ts b/src/lib/features/dependent-features/dependent-features-controller.ts index 3d8dffa2d622..5a297cb17b62 100644 --- a/src/lib/features/dependent-features/dependent-features-controller.ts +++ b/src/lib/features/dependent-features/dependent-features-controller.ts @@ -5,14 +5,17 @@ import { IFlagResolver, IUnleashConfig, IUnleashServices, + NONE, UPDATE_FEATURE, } from '../../types'; import { Logger } from '../../logger'; import { CreateDependentFeatureSchema, createRequestSchema, + createResponseSchema, emptyResponse, getStandardResponses, + ParentFeatureOptionsSchema, } from '../../openapi'; import { IAuthRequest } from '../../routes/unleash-types'; import { InvalidOperationError } from '../../error'; @@ -31,6 +34,7 @@ interface DeleteDependencyParams { const PATH = '/:projectId/features'; const PATH_FEATURE = `${PATH}/:child`; const PATH_DEPENDENCIES = `${PATH_FEATURE}/dependencies`; +const PATH_PARENTS = `${PATH_FEATURE}/parents`; const PATH_DEPENDENCY = `${PATH_FEATURE}/dependencies/:parent`; type DependentFeaturesServices = Pick< @@ -137,6 +141,26 @@ export default class DependentFeaturesController extends Controller { }), ], }); + + this.route({ + method: 'get', + path: PATH_PARENTS, + handler: this.getParentOptions, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Features'], + summary: 'List parent options.', + description: + 'List available parents who have no transitive dependencies.', + operationId: 'listParentOptions', + responses: { + 200: createResponseSchema('parentFeatureOptionsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); } async addFeatureDependency( @@ -200,4 +224,21 @@ export default class DependentFeaturesController extends Controller { ); } } + + async getParentOptions( + req: IAuthRequest, + res: Response, + ): Promise { + const { child } = req.params; + + if (this.config.flagResolver.isEnabled('dependentFeatures')) { + const parentOptions = + await this.dependentFeaturesService.getParentOptions(child); + res.send(parentOptions); + } else { + throw new InvalidOperationError( + 'Dependent features are not enabled', + ); + } + } } diff --git a/src/lib/features/dependent-features/dependent-features-service.ts b/src/lib/features/dependent-features/dependent-features-service.ts index dacfc8f49915..91aece1a358d 100644 --- a/src/lib/features/dependent-features/dependent-features-service.ts +++ b/src/lib/features/dependent-features/dependent-features-service.ts @@ -48,4 +48,8 @@ export class DependentFeaturesService { async deleteFeatureDependencies(feature: string): Promise { await this.dependentFeaturesStore.deleteAll(feature); } + + async getParentOptions(feature: string): Promise { + return this.dependentFeaturesStore.getParentOptions(feature); + } } diff --git a/src/lib/features/dependent-features/dependent-features-store-type.ts b/src/lib/features/dependent-features/dependent-features-store-type.ts index 2eefbf0e6b5f..6f841b662a6e 100644 --- a/src/lib/features/dependent-features/dependent-features-store-type.ts +++ b/src/lib/features/dependent-features/dependent-features-store-type.ts @@ -5,4 +5,5 @@ export interface IDependentFeaturesStore { getChildren(parent: string): Promise; delete(dependency: FeatureDependencyId): Promise; deleteAll(child: string): Promise; + getParentOptions(child: string): Promise; } diff --git a/src/lib/features/dependent-features/dependent-features-store.ts b/src/lib/features/dependent-features/dependent-features-store.ts index ff66744d2eca..f01f11d5d13d 100644 --- a/src/lib/features/dependent-features/dependent-features-store.ts +++ b/src/lib/features/dependent-features/dependent-features-store.ts @@ -38,6 +38,23 @@ export class DependentFeaturesStore implements IDependentFeaturesStore { return rows.map((row) => row.child); } + async getParentOptions(child: string): Promise { + const result = await this.db('features as f') + .where('f.name', child) + .select('f.project'); + if (result.length === 0) { + return []; + } + const rows = await this.db('features as f') + .leftJoin('dependent_features as df', 'f.name', 'df.child') + .where('f.project', result[0].project) + .andWhere('f.name', '!=', child) + .andWhere('df.child', null) + .select('f.name'); + + return rows.map((item) => item.name); + } + async delete(dependency: FeatureDependencyId): Promise { await this.db('dependent_features') .where('parent', dependency.parent) 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 a375800e6d46..37e8c97937f9 100644 --- a/src/lib/features/dependent-features/dependent.features.e2e.test.ts +++ b/src/lib/features/dependent-features/dependent.features.e2e.test.ts @@ -67,12 +67,21 @@ const deleteFeatureDependencies = async ( .expect(expectedCode); }; +const getParentOptions = async (childFeature: string, expectedCode = 200) => { + return app.request + .get(`/api/admin/projects/default/features/${childFeature}/parents`) + .expect(expectedCode); +}; + test('should add and delete feature dependencies', async () => { const parent = uuidv4(); const child = uuidv4(); await app.createFeature(parent); await app.createFeature(child); + const { body: parentOptions } = await getParentOptions(child); + expect(parentOptions).toStrictEqual([parent]); + // save explicit enabled and variants await addFeatureDependency(child, { feature: parent, diff --git a/src/lib/features/dependent-features/fake-dependent-features-store.ts b/src/lib/features/dependent-features/fake-dependent-features-store.ts index 185ac1ebf670..bc6d4955a3e6 100644 --- a/src/lib/features/dependent-features/fake-dependent-features-store.ts +++ b/src/lib/features/dependent-features/fake-dependent-features-store.ts @@ -9,6 +9,10 @@ export class FakeDependentFeaturesStore implements IDependentFeaturesStore { return Promise.resolve([]); } + getParentOptions(): Promise { + return Promise.resolve([]); + } + delete(): Promise { return Promise.resolve(); } diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index f19ac515e2ba..04b4b735bf7f 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -163,6 +163,7 @@ import { updateFeatureStrategySegmentsSchema, dependentFeatureSchema, createDependentFeatureSchema, + parentFeatureOptionsSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -387,6 +388,7 @@ export const schemas: UnleashSchemas = { updateFeatureStrategySegmentsSchema, dependentFeatureSchema, createDependentFeatureSchema, + parentFeatureOptionsSchema, }; // Remove JSONSchema keys that would result in an invalid OpenAPI spec. diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 8a323fbe94a9..12e7d6f4bd00 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -162,3 +162,4 @@ export * from './segments-schema'; export * from './update-feature-strategy-segments-schema'; export * from './dependent-feature-schema'; export * from './create-dependent-feature-schema'; +export * from './parent-feature-options-schema'; diff --git a/src/lib/openapi/spec/parent-feature-options-schema.ts b/src/lib/openapi/spec/parent-feature-options-schema.ts new file mode 100644 index 000000000000..57ca7b56d968 --- /dev/null +++ b/src/lib/openapi/spec/parent-feature-options-schema.ts @@ -0,0 +1,16 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const parentFeatureOptionsSchema = { + $id: '#/components/schemas/parentFeatureOptionsSchema', + type: 'array', + description: + 'A list of parent feature names available for a given child feature. Features that have their own parents are excluded.', + items: { + type: 'string', + }, + components: {}, +} as const; + +export type ParentFeatureOptionsSchema = FromSchema< + typeof parentFeatureOptionsSchema +>;