From 59f2ae435e02cf1d7927042327de4cb126fdf4cf Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 19 Sep 2023 10:04:05 +0200 Subject: [PATCH] feat: stub for create dependent features (#4769) --- .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/openapi/index.ts | 4 ++ .../spec/create-dependent-feature-schema.ts | 35 +++++++++++ .../openapi/spec/dependent-feature-schema.ts | 14 +++++ src/lib/openapi/spec/index.ts | 2 + .../admin-api/project/project-features.ts | 60 ++++++++++++++++--- src/lib/services/feature-toggle-service.ts | 31 ++++++++++ src/lib/types/experimental.ts | 7 ++- .../project/features.dependencies.e2e.test.ts | 56 +++++++++++++++++ 9 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 src/lib/openapi/spec/create-dependent-feature-schema.ts create mode 100644 src/lib/openapi/spec/dependent-feature-schema.ts create mode 100644 src/test/e2e/api/admin/project/features.dependencies.e2e.test.ts diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index b8889fc763..6aff82af2d 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -76,6 +76,7 @@ exports[`should create default config 1`] = ` "caseInsensitiveInOperators": false, "customRootRolesKillSwitch": false, "demo": false, + "dependentFeatures": false, "disableBulkToggle": false, "disableNotifications": false, "doraMetrics": false, @@ -114,6 +115,7 @@ exports[`should create default config 1`] = ` "caseInsensitiveInOperators": false, "customRootRolesKillSwitch": false, "demo": false, + "dependentFeatures": false, "disableBulkToggle": false, "disableNotifications": false, "doraMetrics": false, diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 80651b1bf9..9e709fdf5f 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -158,6 +158,8 @@ import { createGroupSchema, doraFeaturesSchema, projectDoraMetricsSchema, + dependentFeatureSchema, + createDependentFeatureSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -375,6 +377,8 @@ export const schemas: UnleashSchemas = { createFeatureNamingPatternSchema, doraFeaturesSchema, projectDoraMetricsSchema, + dependentFeatureSchema, + createDependentFeatureSchema, }; // Remove JSONSchema keys that would result in an invalid OpenAPI spec. diff --git a/src/lib/openapi/spec/create-dependent-feature-schema.ts b/src/lib/openapi/spec/create-dependent-feature-schema.ts new file mode 100644 index 0000000000..a6e8c72b69 --- /dev/null +++ b/src/lib/openapi/spec/create-dependent-feature-schema.ts @@ -0,0 +1,35 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const createDependentFeatureSchema = { + $id: '#/components/schemas/createDependentFeatureSchema', + type: 'object', + description: 'Feature dependency on a parent feature in write model', + required: ['feature'], + properties: { + feature: { + type: 'string', + description: 'The name of the feature we depend on.', + example: 'parent_feature', + }, + enabled: { + type: 'boolean', + description: + 'Whether the parent feature should be enabled. When `false` variants are ignored. `true` by default.', + example: false, + }, + variants: { + type: 'array', + description: + 'The list of variants the parent feature should resolve to. Leave empty when you only want to check the `enabled` status.', + items: { + type: 'string', + }, + example: ['variantA', 'variantB'], + }, + }, + components: {}, +} as const; + +export type CreateDependentFeatureSchema = FromSchema< + typeof createDependentFeatureSchema +>; diff --git a/src/lib/openapi/spec/dependent-feature-schema.ts b/src/lib/openapi/spec/dependent-feature-schema.ts new file mode 100644 index 0000000000..d32b5cc049 --- /dev/null +++ b/src/lib/openapi/spec/dependent-feature-schema.ts @@ -0,0 +1,14 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { createDependentFeatureSchema } from './create-dependent-feature-schema'; + +export const dependentFeatureSchema = { + $id: '#/components/schemas/dependentFeatureSchema', + type: 'object', + description: 'Feature dependency on a parent feature in read model', + required: ['feature'], + additionalProperties: false, + properties: createDependentFeatureSchema.properties, + components: {}, +} as const; + +export type DependentFeatureSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 87fb0dd8d4..84792ba324 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -157,3 +157,5 @@ export * from './create-group-schema'; export * from './application-usage-schema'; export * from './dora-features-schema'; export * from './project-dora-metrics-schema'; +export * from './dependent-feature-schema'; +export * from './create-dependent-feature-schema'; diff --git a/src/lib/routes/admin-api/project/project-features.ts b/src/lib/routes/admin-api/project/project-features.ts index 7a8aa97a7e..5b27f6e455 100644 --- a/src/lib/routes/admin-api/project/project-features.ts +++ b/src/lib/routes/admin-api/project/project-features.ts @@ -2,18 +2,18 @@ import { Request, Response } from 'express'; import { applyPatch, Operation } from 'fast-json-patch'; import Controller from '../../controller'; import { - IUnleashConfig, - IUnleashServices, - serializeDates, CREATE_FEATURE, CREATE_FEATURE_STRATEGY, DELETE_FEATURE, DELETE_FEATURE_STRATEGY, + IFlagResolver, + IUnleashConfig, + IUnleashServices, NONE, + serializeDates, UPDATE_FEATURE, UPDATE_FEATURE_ENVIRONMENT, UPDATE_FEATURE_STRATEGY, - IFlagResolver, } from '../../../types'; import { Logger } from '../../../logger'; import { extractUsername } from '../../../util'; @@ -42,9 +42,9 @@ import { UpdateFeatureStrategySchema, } from '../../../openapi'; import { - OpenApiService, - FeatureToggleService, FeatureTagService, + FeatureToggleService, + OpenApiService, } from '../../../services'; import { querySchema } from '../../../schema/feature-schema'; import { BatchStaleSchema } from '../../../openapi/spec/batch-stale-schema'; @@ -52,7 +52,8 @@ import { TransactionCreator, UnleashTransaction, } from '../../../db/transaction'; -import { BadDataError } from '../../../error'; +import { BadDataError, InvalidOperationError } from '../../../error'; +import { CreateDependentFeatureSchema } from '../../../openapi/spec/create-dependent-feature-schema'; interface FeatureStrategyParams { projectId: string; @@ -99,6 +100,7 @@ const PATH_ENV = `${PATH_FEATURE}/environments/:environment`; const BULK_PATH_ENV = `/:projectId/bulk_features/environments/:environment`; const PATH_STRATEGIES = `${PATH_ENV}/strategies`; const PATH_STRATEGY = `${PATH_STRATEGIES}/:strategyId`; +const PATH_DEPENDENCIES = `${PATH_FEATURE}/dependencies`; type ProjectFeaturesServices = Pick< IUnleashServices, @@ -297,6 +299,29 @@ export default class ProjectFeaturesController extends Controller { ], }); + this.route({ + method: 'post', + path: PATH_DEPENDENCIES, + handler: this.addFeatureDependency, + permission: CREATE_FEATURE, + middleware: [ + openApiService.validPath({ + tags: ['Features'], + summary: 'Add a feature dependency.', + description: + 'Add a dependency to a parent feature. Each environment will resolve corresponding dependency independently.', + operationId: 'addFeatureDependency', + requestBody: createRequestSchema( + 'createDependentFeatureSchema', + ), + responses: { + 200: emptyResponse, + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + this.route({ method: 'get', path: PATH_STRATEGY, @@ -972,6 +997,27 @@ export default class ProjectFeaturesController extends Controller { res.status(200).json(updatedStrategy); } + async addFeatureDependency( + req: IAuthRequest, + res: Response, + ): Promise { + const { featureName } = req.params; + const { variants, enabled, feature } = req.body; + + if (this.config.flagResolver.isEnabled('dependentFeatures')) { + await this.featureService.upsertFeatureDependency(featureName, { + variants, + enabled, + feature, + }); + res.status(200).end(); + } else { + throw new InvalidOperationError( + 'Dependent features are not enabled', + ); + } + } + async getFeatureStrategies( req: Request, res: Response, diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index b5d37c344f..d830162d45 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -96,6 +96,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 { CreateDependentFeatureSchema } from '../openapi'; interface IFeatureContext { featureName: string; @@ -122,6 +123,15 @@ export type FeatureNameCheckResultWithFeaturePattern = featureNaming: IFeatureNaming; }; +export type FeatureDependency = + | { + parent: string; + child: string; + enabled: true; + variants?: string[]; + } + | { parent: string; child: string; enabled: false }; + const oneOf = (values: string[], match: string) => { return values.some((value) => value === match); }; @@ -2201,6 +2211,27 @@ class FeatureToggleService { ); } } + + async upsertFeatureDependency( + parentFeature: string, + dependentFeature: CreateDependentFeatureSchema, + ): Promise { + const { enabled, feature, variants } = dependentFeature; + const featureDependency: FeatureDependency = + enabled === false + ? { + parent: parentFeature, + child: feature, + enabled, + } + : { + parent: parentFeature, + child: feature, + enabled: true, + variants, + }; + console.log(featureDependency); + } } export default FeatureToggleService; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 82d85088fe..d123c57c56 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -30,7 +30,8 @@ export type IFlagKey = | 'doraMetrics' | 'variantTypeNumber' | 'accessOverview' - | 'privateProjects'; + | 'privateProjects' + | 'dependentFeatures'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -130,6 +131,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_DORA_METRICS, false, ), + dependentFeatures: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_DEPENDENT_FEATURES, + false, + ), variantTypeNumber: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_VARIANT_TYPE_NUMBER, false, diff --git a/src/test/e2e/api/admin/project/features.dependencies.e2e.test.ts b/src/test/e2e/api/admin/project/features.dependencies.e2e.test.ts new file mode 100644 index 0000000000..e41762516d --- /dev/null +++ b/src/test/e2e/api/admin/project/features.dependencies.e2e.test.ts @@ -0,0 +1,56 @@ +import { v4 as uuidv4 } from 'uuid'; +import { CreateDependentFeatureSchema } from '../../../../../lib/openapi'; +import { + IUnleashTest, + setupAppWithCustomConfig, +} from '../../../helpers/test-helper'; +import dbInit, { ITestDb } from '../../../helpers/database-init'; +import getLogger from '../../../../fixtures/no-logger'; + +let app: IUnleashTest; +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('feature_dependencies', getLogger); + app = await setupAppWithCustomConfig( + db.stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + dependentFeatures: true, + }, + }, + }, + db.rawDatabase, + ); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +const addFeatureDependency = async ( + parentFeature: string, + payload: CreateDependentFeatureSchema, + expectedCode = 200, +) => { + return app.request + .post( + `/api/admin/projects/default/features/${parentFeature}/dependencies`, + ) + .send(payload) + .expect(expectedCode); +}; + +test('should add feature dependency', async () => { + const parent = uuidv4(); + const child = uuidv4(); + await app.createFeature(parent); + await app.createFeature(child); + + await addFeatureDependency(parent, { + feature: child, + }); +});