UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

645 lines • 30 kB
import fastJsonPatch from 'fast-json-patch'; const { applyPatch } = fastJsonPatch; import Controller from '../../routes/controller.js'; import { CREATE_FEATURE, CREATE_FEATURE_STRATEGY, DELETE_FEATURE, DELETE_FEATURE_STRATEGY, NONE, serializeDates, UPDATE_FEATURE, UPDATE_FEATURE_ENVIRONMENT, UPDATE_FEATURE_STRATEGY, } from '../../types/index.js'; import { createRequestSchema, createResponseSchema, emptyResponse, featureEnvironmentSchema, featureSchema, featureStrategySchema, getStandardResponses, projectFeaturesSchema, } from '../../openapi/index.js'; import { querySchema } from '../../schema/feature-schema.js'; import { BadDataError } from '../../error/index.js'; import { anonymise } from '../../util/index.js'; import { throwOnInvalidSchema } from '../../openapi/validate.js'; const PATH = '/:projectId/features'; const PATH_STALE = '/:projectId/stale'; const PATH_TAGS = `/:projectId/tags`; const PATH_FEATURE = `${PATH}/:featureName`; const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`; 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`; export default class ProjectFeaturesController extends Controller { constructor(config, { featureToggleService, openApiService, transactionalFeatureToggleService, featureTagService, }) { super(config); this.featureService = featureToggleService; this.transactionalFeatureToggleService = transactionalFeatureToggleService; this.openApiService = openApiService; this.featureTagService = featureTagService; this.flagResolver = config.flagResolver; this.logger = config.getLogger('/admin-api/project/features.ts'); this.route({ method: 'get', path: PATH_ENV, permission: NONE, handler: this.getFeatureEnvironment, middleware: [ openApiService.validPath({ summary: 'Get a feature environment', description: 'Information about the enablement status and strategies for a feature flag in specified environment.', tags: ['Features'], operationId: 'getFeatureEnvironment', responses: { 200: createResponseSchema('featureEnvironmentSchema'), ...getStandardResponses(401, 403, 404), }, }), ], }); this.route({ method: 'post', path: `${PATH_ENV}/off`, acceptAnyContentType: true, handler: this.toggleFeatureEnvironmentOff, permission: UPDATE_FEATURE_ENVIRONMENT, middleware: [ openApiService.validPath({ summary: 'Disable a feature flag', description: 'Disable a feature flag in the specified environment.', tags: ['Features'], operationId: 'toggleFeatureEnvironmentOff', responses: { 200: createResponseSchema('featureSchema'), ...getStandardResponses(400, 401, 403, 404), }, }), ], }); this.route({ method: 'post', path: `${PATH_ENV}/on`, acceptAnyContentType: true, handler: this.toggleFeatureEnvironmentOn, permission: UPDATE_FEATURE_ENVIRONMENT, middleware: [ openApiService.validPath({ summary: 'Enable a feature flag', description: 'Enable a feature flag in the specified environment.', tags: ['Features'], operationId: 'toggleFeatureEnvironmentOn', responses: { 200: createResponseSchema('featureSchema'), ...getStandardResponses(400, 401, 403, 404), }, }), ], }); this.route({ method: 'post', path: `${BULK_PATH_ENV}/on`, handler: this.bulkToggleFeaturesEnvironmentOn, permission: UPDATE_FEATURE_ENVIRONMENT, middleware: [ openApiService.validPath({ tags: ['Features'], summary: 'Bulk enable a list of features', description: 'This endpoint enables multiple feature flags.', operationId: 'bulkToggleFeaturesEnvironmentOn', requestBody: createRequestSchema('bulkToggleFeaturesSchema'), responses: { 200: emptyResponse, ...getStandardResponses(400, 401, 403, 404, 413, 415), }, }), ], }); this.route({ method: 'post', path: `${BULK_PATH_ENV}/off`, handler: this.bulkToggleFeaturesEnvironmentOff, permission: UPDATE_FEATURE_ENVIRONMENT, middleware: [ openApiService.validPath({ tags: ['Features'], summary: 'Bulk disable a list of features', description: 'This endpoint disables multiple feature flags.', operationId: 'bulkToggleFeaturesEnvironmentOff', requestBody: createRequestSchema('bulkToggleFeaturesSchema'), responses: { 200: emptyResponse, ...getStandardResponses(400, 401, 403, 404, 413, 415), }, }), ], }); this.route({ method: 'get', path: PATH_STRATEGIES, handler: this.getFeatureStrategies, permission: NONE, middleware: [ openApiService.validPath({ tags: ['Features'], summary: 'Get feature flag strategies', operationId: 'getFeatureStrategies', description: 'Get strategies defined for a feature flag in the specified environment.', responses: { 200: createResponseSchema('featureStrategySchema'), ...getStandardResponses(401, 403, 404), }, }), ], }); this.route({ method: 'post', path: PATH_STRATEGIES, handler: this.addFeatureStrategy, permission: CREATE_FEATURE_STRATEGY, middleware: [ openApiService.validPath({ tags: ['Features'], summary: 'Add a strategy to a feature flag', description: 'Add a strategy to a feature flag in the specified environment.', operationId: 'addFeatureStrategy', requestBody: createRequestSchema('createFeatureStrategySchema'), responses: { 200: createResponseSchema('featureStrategySchema'), ...getStandardResponses(401, 403, 404), }, }), ], }); this.route({ method: 'get', path: PATH_STRATEGY, handler: this.getFeatureStrategy, permission: NONE, middleware: [ openApiService.validPath({ tags: ['Features'], summary: 'Get a strategy configuration', description: 'Get a strategy configuration for an environment in a feature flag.', operationId: 'getFeatureStrategy', responses: { 200: createResponseSchema('featureStrategySchema'), ...getStandardResponses(401, 403, 404), }, }), ], }); this.route({ method: 'post', path: `${PATH_STRATEGIES}/set-sort-order`, handler: this.setStrategiesSortOrder, permission: UPDATE_FEATURE_STRATEGY, middleware: [ openApiService.validPath({ tags: ['Features'], operationId: 'setStrategySortOrder', summary: 'Set strategy sort order', description: 'Set the sort order of the provided list of strategies.', requestBody: createRequestSchema('setStrategySortOrderSchema'), responses: { 200: emptyResponse, ...getStandardResponses(400, 401, 403), }, }), ], }); this.route({ method: 'put', path: PATH_STRATEGY, handler: this.updateFeatureStrategy, permission: UPDATE_FEATURE_STRATEGY, middleware: [ openApiService.validPath({ tags: ['Features'], summary: 'Update a strategy', description: 'Replace strategy configuration for a feature flag in the specified environment.', operationId: 'updateFeatureStrategy', requestBody: createRequestSchema('updateFeatureStrategySchema'), responses: { 200: createResponseSchema('featureStrategySchema'), ...getStandardResponses(400, 401, 403, 404, 415), }, }), ], }); this.route({ method: 'patch', path: PATH_STRATEGY, handler: this.patchFeatureStrategy, permission: UPDATE_FEATURE_STRATEGY, middleware: [ openApiService.validPath({ tags: ['Features'], summary: 'Change specific properties of a strategy', description: 'Change specific properties of a strategy configuration in a feature flag.', operationId: 'patchFeatureStrategy', requestBody: createRequestSchema('patchesSchema'), responses: { 200: createResponseSchema('featureStrategySchema'), ...getStandardResponses(400, 401, 403, 404, 415), }, }), ], }); this.route({ method: 'delete', path: PATH_STRATEGY, acceptAnyContentType: true, handler: this.deleteFeatureStrategy, permission: DELETE_FEATURE_STRATEGY, middleware: [ openApiService.validPath({ tags: ['Features'], summary: 'Delete a strategy from a feature flag', description: 'Delete a strategy configuration from a feature flag in the specified environment.', operationId: 'deleteFeatureStrategy', responses: { 200: emptyResponse, ...getStandardResponses(401, 403, 404), }, }), ], }); this.route({ method: 'get', path: PATH, handler: this.getFeatures, permission: NONE, middleware: [ openApiService.validPath({ summary: 'Get all features in a project', description: 'A list of all features for the specified project.', tags: ['Features'], operationId: 'getFeatures', responses: { 200: createResponseSchema('projectFeaturesSchema'), ...getStandardResponses(400, 401, 403), }, }), ], }); this.route({ method: 'post', path: PATH, handler: this.createFeature, permission: CREATE_FEATURE, middleware: [ openApiService.validPath({ summary: 'Add a new feature flag', description: 'Create a new feature flag in a specified project.', tags: ['Features'], operationId: 'createFeature', requestBody: createRequestSchema('createFeatureSchema'), responses: { 200: createResponseSchema('featureSchema'), ...getStandardResponses(401, 403, 404, 415), }, }), ], }); this.route({ method: 'post', path: PATH_FEATURE_CLONE, acceptAnyContentType: true, handler: this.cloneFeature, permission: CREATE_FEATURE, middleware: [ openApiService.validPath({ summary: 'Clone a feature flag', description: 'Creates a copy of the specified feature flag. The copy can be created in any project.', tags: ['Features'], operationId: 'cloneFeature', requestBody: createRequestSchema('cloneFeatureSchema'), responses: { 200: createResponseSchema('featureSchema'), ...getStandardResponses(401, 403, 404, 415), }, }), ], }); this.route({ method: 'get', path: PATH_FEATURE, handler: this.getFeature, permission: NONE, middleware: [ openApiService.validPath({ operationId: 'getFeature', tags: ['Features'], summary: 'Get a feature', description: 'This endpoint returns the information about the requested feature if the feature belongs to the specified project.', responses: { 200: createResponseSchema('featureSchema'), 403: { description: 'You either do not have the required permissions or used an invalid URL.', }, ...getStandardResponses(401, 403, 404), }, }), ], }); this.route({ method: 'put', path: PATH_FEATURE, handler: this.updateFeature, permission: UPDATE_FEATURE, middleware: [ openApiService.validPath({ tags: ['Features'], operationId: 'updateFeature', summary: 'Update a feature flag', description: 'Updates the specified feature if the feature belongs to the specified project. Only the provided properties are updated; any feature properties left out of the request body are left untouched.', requestBody: createRequestSchema('updateFeatureSchema'), responses: { 200: createResponseSchema('featureSchema'), ...getStandardResponses(401, 403, 404, 415), }, }), ], }); this.route({ method: 'patch', path: PATH_FEATURE, handler: this.patchFeature, permission: UPDATE_FEATURE, middleware: [ openApiService.validPath({ tags: ['Features'], operationId: 'patchFeature', summary: 'Modify a feature flag', description: 'Change specific properties of a feature flag.', requestBody: createRequestSchema('patchesSchema'), responses: { 200: createResponseSchema('featureSchema'), ...getStandardResponses(401, 403, 404, 415), }, }), ], }); this.route({ method: 'delete', path: PATH_FEATURE, acceptAnyContentType: true, handler: this.archiveFeature, permission: DELETE_FEATURE, middleware: [ openApiService.validPath({ tags: ['Features'], operationId: 'archiveFeature', summary: 'Archive a feature flag', description: 'This endpoint archives the specified feature if the feature belongs to the specified project.', responses: { 202: emptyResponse, 403: { description: 'You either do not have the required permissions or used an invalid URL.', }, ...getStandardResponses(401, 403, 404), }, }), ], }); this.route({ method: 'post', path: PATH_STALE, handler: this.staleFeatures, permission: UPDATE_FEATURE, middleware: [ openApiService.validPath({ tags: ['Features'], operationId: 'staleFeatures', summary: 'Mark features as stale / not stale', description: `This endpoint marks the provided list of features as either [stale](https://docs.getunleash.io/reference/technical-debt#stale-and-potentially-stale-flags) or not stale depending on the request body you send. Any provided features that don't exist are ignored.`, requestBody: createRequestSchema('batchStaleSchema'), responses: { 202: emptyResponse, ...getStandardResponses(401, 403, 415), }, }), ], }); this.route({ method: 'put', path: PATH_TAGS, handler: this.updateFeaturesTags, permission: UPDATE_FEATURE, middleware: [ openApiService.validPath({ tags: ['Tags'], operationId: 'addTagToFeatures', summary: 'Adds a tag to the specified features', description: 'Add a tag to a list of features. Create tags if needed.', requestBody: createRequestSchema('tagsBulkAddSchema'), responses: { 200: emptyResponse, ...getStandardResponses(401, 403, 404, 415), }, }), ], }); } async getFeatures(req, res) { const { projectId } = req.params; const query = await this.prepQuery(req.query, projectId); const features = await this.featureService.getFeatureOverview({ ...query, userId: req.user.id, }); this.openApiService.respondWithValidation(200, res, projectFeaturesSchema.$id, { version: 2, features: serializeDates(features) }); } async prepQuery( // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types { tag, namePrefix }, projectId) { if (!tag && !namePrefix) { return { projectId }; } const tagQuery = this.paramToArray(tag); const query = await querySchema.validateAsync({ tag: tagQuery, namePrefix, }); if (query.tag) { query.tag = query.tag.map((q) => q.split(':')); } return { projectId, ...query }; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types paramToArray(param) { if (!param) { return param; } return Array.isArray(param) ? param : [param]; } async cloneFeature(req, res) { const { projectId, featureName } = req.params; const { name, replaceGroupId } = req.body; const created = await this.transactionalFeatureToggleService.transactional((service) => service.cloneFeatureToggle(featureName, projectId, name, req.audit, replaceGroupId)); this.openApiService.respondWithValidation(201, res, featureSchema.$id, serializeDates({ ...created, stale: created.stale || false })); } async createFeature(req, res) { const { projectId } = req.params; const created = await this.transactionalFeatureToggleService.transactional((service) => service.createFeatureToggle(projectId, { ...req.body, description: req.body.description || undefined, }, req.audit)); this.openApiService.respondWithValidation(201, res, featureSchema.$id, serializeDates({ ...created, stale: created.stale || false })); } maybeAnonymise(feature) { if (this.flagResolver.isEnabled('anonymiseEventLog') && feature.createdBy) { return { ...feature, ...(feature.collaborators ? { collaborators: { ...feature.collaborators, users: feature.collaborators.users.map((user) => ({ ...user, name: anonymise(user.name), })), }, } : {}), createdBy: { ...feature.createdBy, name: anonymise(feature.createdBy?.name), }, }; } return feature; } async getFeature(req, res) { const { featureName, projectId } = req.params; const { variantEnvironments } = req.query; const { user } = req; const feature = await this.featureService.getFeature({ featureName, archived: false, projectId, environmentVariants: variantEnvironments === 'true', userId: user.id, }); const maybeAnonymized = this.maybeAnonymise(feature); res.status(200).json(serializeDates({ ...maybeAnonymized, stale: maybeAnonymized.stale || false, })); } async updateFeature(req, res) { const { projectId, featureName } = req.params; const { createdAt, ...data } = req.body; if (data.name && data.name !== featureName) { throw new BadDataError('Cannot change name of feature flag'); } const created = await this.transactionalFeatureToggleService.transactional((service) => service.updateFeatureToggle(projectId, { ...data, name: featureName, }, featureName, req.audit)); this.openApiService.respondWithValidation(200, res, featureSchema.$id, serializeDates({ ...created, stale: created.stale || false })); } async patchFeature(req, res) { const { projectId, featureName } = req.params; const updated = await this.transactionalFeatureToggleService.transactional((service) => service.patchFeature(projectId, featureName, req.body, req.audit)); this.openApiService.respondWithValidation(200, res, featureSchema.$id, serializeDates({ ...updated, stale: updated.stale || false })); } async archiveFeature(req, res) { const { featureName, projectId } = req.params; await this.transactionalFeatureToggleService.transactional((service) => service.archiveToggle(featureName, req.user, req.audit, projectId)); res.status(202).send(); } async staleFeatures(req, res) { const { features, stale } = req.body; const { projectId } = req.params; await this.featureService.setToggleStaleness(features, stale, projectId, req.audit); res.status(202).end(); } async getFeatureEnvironment(req, res) { const { environment, featureName, projectId } = req.params; const { defaultStrategy, ...environmentInfo } = await this.featureService.getEnvironmentInfo(projectId, environment, featureName); const result = { ...environmentInfo, strategies: environmentInfo.strategies.map((strategy) => { const { strategyName, projectId: project, environment: environmentId, createdAt, milestoneId, ...rest } = strategy; return { ...rest, name: strategyName }; }), }; this.openApiService.respondWithValidation(200, res, featureEnvironmentSchema.$id, serializeDates(result)); } async toggleFeatureEnvironmentOn(req, res) { const { featureName, environment, projectId } = req.params; const { shouldActivateDisabledStrategies } = req.query; await this.transactionalFeatureToggleService.transactional((service) => service.updateEnabled(projectId, featureName, environment, true, req.audit, req.user, shouldActivateDisabledStrategies === 'true')); res.status(200).end(); } async bulkToggleFeaturesEnvironmentOn(req, res) { const { environment, projectId } = req.params; const { shouldActivateDisabledStrategies } = req.query; const { features } = req.body; await this.transactionalFeatureToggleService.transactional((service) => service.bulkUpdateEnabled(projectId, features, environment, true, req.audit, req.user, shouldActivateDisabledStrategies === 'true')); res.status(200).end(); } async bulkToggleFeaturesEnvironmentOff(req, res) { const { environment, projectId } = req.params; const { shouldActivateDisabledStrategies } = req.query; const { features } = req.body; await this.transactionalFeatureToggleService.transactional((service) => service.bulkUpdateEnabled(projectId, features, environment, false, req.audit, req.user, shouldActivateDisabledStrategies === 'true')); res.status(200).end(); } async toggleFeatureEnvironmentOff(req, res) { const { featureName, environment, projectId } = req.params; await this.transactionalFeatureToggleService.transactional((service) => service.updateEnabled(projectId, featureName, environment, false, req.audit, req.user)); res.status(200).end(); } async addFeatureStrategy(req, res) { const { projectId, featureName, environment } = req.params; const { ...strategyConfig } = req.body; if (!strategyConfig.segmentIds) { strategyConfig.segmentIds = []; } const strategy = await this.transactionalFeatureToggleService.transactional((service) => service.createStrategy(strategyConfig, { environment, projectId, featureName }, req.audit, req.user)); const updatedStrategy = await this.featureService.getStrategy(strategy.id); res.status(200).json(updatedStrategy); } async getFeatureStrategies(req, res) { const { projectId, featureName, environment } = req.params; const featureStrategies = await this.featureService.getStrategiesForEnvironment(projectId, featureName, environment); res.status(200).json(featureStrategies); } async setStrategiesSortOrder(req, res) { const { featureName, projectId, environment } = req.params; await this.transactionalFeatureToggleService.transactional((service) => service.updateStrategiesSortOrder({ featureName, environment, projectId, }, req.body, req.audit)); res.status(200).send(); } async updateFeatureStrategy(req, res) { const { strategyId, environment, projectId, featureName } = req.params; if (!req.body.segmentIds) { req.body.segmentIds = []; } const updatedStrategy = await this.transactionalFeatureToggleService.transactional((service) => service.updateStrategy(strategyId, req.body, { environment, projectId, featureName }, req.audit, req.user)); res.status(200).json(updatedStrategy); } async patchFeatureStrategy(req, res) { const { strategyId, projectId, environment, featureName } = req.params; const patch = req.body; const strategy = await this.featureService.getStrategy(strategyId); const { newDocument } = applyPatch(strategy, patch); throwOnInvalidSchema(featureStrategySchema.$id, newDocument); const updatedStrategy = await this.transactionalFeatureToggleService.transactional((service) => service.updateStrategy(strategyId, newDocument, { environment, projectId, featureName }, req.audit, req.user)); res.status(200).json(updatedStrategy); } async getFeatureStrategy(req, res) { this.logger.info('Getting strategy'); const { strategyId } = req.params; this.logger.info(strategyId); const strategy = await this.featureService.getStrategy(strategyId); res.status(200).json(strategy); } async deleteFeatureStrategy(req, res) { this.logger.info('Deleting strategy'); const { environment, projectId, featureName } = req.params; const { strategyId } = req.params; this.logger.info(strategyId); const strategy = await this.transactionalFeatureToggleService.transactional((service) => service.deleteStrategy(strategyId, { environment, projectId, featureName }, req.audit, req.user)); res.status(200).json(strategy); } async updateFeaturesTags(req, res) { const { features, tags } = req.body; await this.featureTagService.updateTags(features, tags.addedTags, tags.removedTags, req.audit); res.status(200).end(); } } //# sourceMappingURL=feature-toggle-controller.js.map