diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index e7206c03ee..2f1aa17081 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -21,7 +21,7 @@ import FeatureToggleStore from './feature-toggle-store'; import { ensureStringValue } from '../util/ensureStringValue'; import { mapValues } from '../util/map-values'; import { IFlagResolver } from '../types/experimental'; -import { IFeatureProjectUserParams } from '../routes/admin-api/project/features'; +import { IFeatureProjectUserParams } from '../routes/admin-api/project/project-features'; import Raw = Knex.Raw; import { Db } from './db'; diff --git a/src/lib/db/feature-tag-store.ts b/src/lib/db/feature-tag-store.ts index 9c3dfea473..f3d68e51c5 100644 --- a/src/lib/db/feature-tag-store.ts +++ b/src/lib/db/feature-tag-store.ts @@ -136,6 +136,23 @@ class FeatureTagStore implements IFeatureTagStore { return tag; } + async tagFeatures(featureNames: string[], tag: ITag): Promise { + const stopTimer = this.timer('tagFeatures'); + await this.db(TABLE) + .insert(this.featuresAndTagToRow(featureNames, tag)) + .catch((err) => { + if (err.code === UNIQUE_CONSTRAINT_VIOLATION) { + throw new FeatureHasTagError( + `Some of the features already have the tag: [${tag.type}:${tag.value}]`, + ); + } else { + throw err; + } + }); + stopTimer(); + return tag; + } + /** * Only gets tags for active feature toggles. */ @@ -227,6 +244,17 @@ class FeatureTagStore implements IFeatureTagStore { tag_value: value, }; } + + featuresAndTagToRow( + featureNames: string[], + { type, value }: ITag, + ): FeatureTagTable[] { + return featureNames.map((featureName) => ({ + feature_name: featureName, + tag_type: type, + tag_value: value, + })); + } } module.exports = FeatureTagStore; diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 8942448b6f..96c0a0c3a6 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -102,6 +102,7 @@ import { stateSchema, strategiesSchema, strategySchema, + tagsBulkAddSchema, tagSchema, tagsSchema, tagTypeSchema, @@ -250,6 +251,7 @@ export const schemas = { stateSchema, strategiesSchema, strategySchema, + tagsBulkAddSchema, tagSchema, tagsSchema, tagTypeSchema, diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 0677a1e2a1..167fe147c1 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -129,3 +129,4 @@ export * from './project-overview-schema'; export * from './import-toggles-validate-item-schema'; export * from './import-toggles-validate-schema'; export * from './import-toggles-schema'; +export * from './tags-bulk-add-schema'; diff --git a/src/lib/openapi/spec/tags-bulk-add-schema.test.ts b/src/lib/openapi/spec/tags-bulk-add-schema.test.ts new file mode 100644 index 0000000000..ecf8d4c020 --- /dev/null +++ b/src/lib/openapi/spec/tags-bulk-add-schema.test.ts @@ -0,0 +1,16 @@ +import { validateSchema } from '../validate'; +import { TagsBulkAddSchema } from './tags-bulk-add-schema'; + +test('tagsBulkAddSchema', () => { + const data: TagsBulkAddSchema = { + features: ['my-feature'], + tag: { + type: 'simple', + value: 'besttag', + }, + }; + + expect( + validateSchema('#/components/schemas/tagsBulkAddSchema', data), + ).toBeUndefined(); +}); diff --git a/src/lib/openapi/spec/tags-bulk-add-schema.ts b/src/lib/openapi/spec/tags-bulk-add-schema.ts new file mode 100644 index 0000000000..224c58941d --- /dev/null +++ b/src/lib/openapi/spec/tags-bulk-add-schema.ts @@ -0,0 +1,28 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { tagSchema } from './tag-schema'; + +export const tagsBulkAddSchema = { + $id: '#/components/schemas/tagsBulkAddSchema', + type: 'object', + additionalProperties: false, + required: ['features', 'tag'], + properties: { + features: { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + }, + tag: { + $ref: '#/components/schemas/tagSchema', + }, + }, + components: { + schemas: { + tagSchema, + }, + }, +} as const; + +export type TagsBulkAddSchema = FromSchema; diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index c9f0c8d830..94feee05b4 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -2,7 +2,7 @@ import { Response } from 'express'; import Controller from '../../controller'; import { IUnleashConfig } from '../../../types/option'; import { IUnleashServices } from '../../../types/services'; -import ProjectFeaturesController from './features'; +import ProjectFeaturesController from './project-features'; import EnvironmentsController from './environments'; import ProjectHealthReport from './health-report'; import ProjectService from '../../../services/project-service'; diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/project-features.ts similarity index 100% rename from src/lib/routes/admin-api/project/features.ts rename to src/lib/routes/admin-api/project/project-features.ts diff --git a/src/lib/routes/admin-api/tag.ts b/src/lib/routes/admin-api/tag.ts index f15ffcc6e7..de946c0bbf 100644 --- a/src/lib/routes/admin-api/tag.ts +++ b/src/lib/routes/admin-api/tag.ts @@ -22,6 +22,8 @@ import { TagWithVersionSchema, } from '../../openapi/spec/tag-with-version-schema'; import { emptyResponse } from '../../openapi/util/standard-responses'; +import FeatureTagService from 'lib/services/feature-tag-service'; +import { TagsBulkAddSchema } from '../../openapi/spec/tags-bulk-add-schema'; const version = 1; @@ -30,6 +32,8 @@ class TagController extends Controller { private tagService: TagService; + private featureTagService: FeatureTagService; + private openApiService: OpenApiService; constructor( @@ -37,11 +41,16 @@ class TagController extends Controller { { tagService, openApiService, - }: Pick, + featureTagService, + }: Pick< + IUnleashServices, + 'tagService' | 'openApiService' | 'featureTagService' + >, ) { super(config); this.tagService = tagService; this.openApiService = openApiService; + this.featureTagService = featureTagService; this.logger = config.getLogger('/admin-api/tag.js'); this.route({ @@ -75,6 +84,22 @@ class TagController extends Controller { }), ], }); + this.route({ + method: 'post', + path: '/features', + handler: this.addTagToFeatures, + permission: UPDATE_FEATURE, + middleware: [ + openApiService.validPath({ + tags: ['Tags'], + operationId: 'addTagToFeatures', + requestBody: createRequestSchema('tagsBulkAddSchema'), + responses: { + 201: resourceCreatedResponseSchema('tagSchema'), + }, + }), + ], + }); this.route({ method: 'get', path: '/:type', @@ -181,5 +206,19 @@ class TagController extends Controller { await this.tagService.deleteTag({ type, value }, userName); res.status(200).end(); } + + async addTagToFeatures( + req: IAuthRequest, + res: Response, + ): Promise { + const { features, tag } = req.body; + const userName = extractUsername(req); + const addedTag = await this.featureTagService.addTags( + features, + tag, + userName, + ); + res.status(201).json(addedTag); + } } export default TagController; diff --git a/src/lib/services/feature-tag-service.ts b/src/lib/services/feature-tag-service.ts index 2a67dd3431..20f831f4aa 100644 --- a/src/lib/services/feature-tag-service.ts +++ b/src/lib/services/feature-tag-service.ts @@ -64,6 +64,32 @@ class FeatureTagService { return validatedTag; } + async addTags( + featureNames: string[], + tag: ITag, + userName: string, + ): Promise { + const featureToggles = await this.featureToggleStore.getAllByNames( + featureNames, + ); + const validatedTag = await tagSchema.validateAsync(tag); + await this.createTagIfNeeded(validatedTag, userName); + await this.featureTagStore.tagFeatures(featureNames, validatedTag); + + await Promise.all( + featureToggles.map((featureToggle) => + this.eventStore.store({ + type: FEATURE_TAGGED, + createdBy: userName, + featureName: featureToggle.name, + project: featureToggle.project, + data: validatedTag, + }), + ), + ); + return validatedTag; + } + async createTagIfNeeded(tag: ITag, userName: string): Promise { try { await this.tagStore.getTag(tag.type, tag.value); diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 9813c797b4..19ab473746 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -81,7 +81,7 @@ import { SKIP_CHANGE_REQUEST, } from '../types/permissions'; import NoAccessError from '../error/no-access-error'; -import { IFeatureProjectUserParams } from '../routes/admin-api/project/features'; +import { IFeatureProjectUserParams } from '../routes/admin-api/project/project-features'; interface IFeatureContext { featureName: string; diff --git a/src/lib/types/stores/feature-strategies-store.ts b/src/lib/types/stores/feature-strategies-store.ts index a5aae8aa27..975fdb8df2 100644 --- a/src/lib/types/stores/feature-strategies-store.ts +++ b/src/lib/types/stores/feature-strategies-store.ts @@ -6,7 +6,7 @@ import { IVariant, } from '../model'; import { Store } from './store'; -import { IFeatureProjectUserParams } from '../../routes/admin-api/project/features'; +import { IFeatureProjectUserParams } from '../../routes/admin-api/project/project-features'; export interface FeatureConfigurationClient { name: string; diff --git a/src/lib/types/stores/feature-tag-store.ts b/src/lib/types/stores/feature-tag-store.ts index e4dd71e4ce..1bbd7f2a46 100644 --- a/src/lib/types/stores/feature-tag-store.ts +++ b/src/lib/types/stores/feature-tag-store.ts @@ -15,6 +15,7 @@ export interface IFeatureTagStore extends Store { getAllTagsForFeature(featureName: string): Promise; getAllByFeatures(features: string[]): Promise; tagFeature(featureName: string, tag: ITag): Promise; + tagFeatures(featureNames: string[], tag: ITag): Promise; importFeatureTags(featureTags: IFeatureTag[]): Promise; untagFeature(featureName: string, tag: ITag): Promise; } diff --git a/src/test/e2e/api/admin/tags.e2e.test.ts b/src/test/e2e/api/admin/tags.e2e.test.ts index 272ef01be0..c62cc2adb0 100644 --- a/src/test/e2e/api/admin/tags.e2e.test.ts +++ b/src/test/e2e/api/admin/tags.e2e.test.ts @@ -108,3 +108,39 @@ test('Can delete a tag', async () => { ).toBe(-1); }); }); + +test('Can tag features', async () => { + const featureName = 'test.feature'; + const featureName2 = 'test.feature2'; + const tag = { + value: 'TeamRed', + type: 'simple', + }; + await app.request.post('/api/admin/features').send({ + name: featureName, + type: 'killswitch', + enabled: true, + strategies: [{ name: 'default' }], + }); + await app.request.post('/api/admin/features').send({ + name: featureName2, + type: 'killswitch', + enabled: true, + strategies: [{ name: 'default' }], + }); + + await app.request.post('/api/admin/tags/features').send({ + features: [featureName, featureName2], + tag: tag, + }); + const res = await app.request.get( + `/api/admin/features/${featureName}/tags`, + ); + + const res2 = await app.request.get( + `/api/admin/features/${featureName2}/tags`, + ); + + expect(res.body).toMatchObject({ tags: [tag] }); + expect(res2.body).toMatchObject({ tags: [tag] }); +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 69ac9c9268..fa9302f9c9 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -3642,6 +3642,26 @@ Stats are divided into current and previous **windows**. ], "type": "object", }, + "tagsBulkAddSchema": { + "additionalProperties": false, + "properties": { + "features": { + "items": { + "minLength": 1, + "type": "string", + }, + "type": "array", + }, + "tag": { + "$ref": "#/components/schemas/tagSchema", + }, + }, + "required": [ + "features", + "tag", + ], + "type": "object", + }, "tagsSchema": { "additionalProperties": false, "properties": { @@ -7939,6 +7959,46 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/tags/features": { + "post": { + "operationId": "addTagToFeatures", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/tagsBulkAddSchema", + }, + }, + }, + "description": "tagsBulkAddSchema", + "required": true, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/tagSchema", + }, + }, + }, + "description": "The resource was successfully created.", + "headers": { + "location": { + "description": "The location of the newly created resource.", + "schema": { + "format": "uri", + "type": "string", + }, + }, + }, + }, + }, + "tags": [ + "Tags", + ], + }, + }, "/api/admin/tags/{type}": { "get": { "operationId": "getTagsByType", diff --git a/src/test/fixtures/fake-feature-strategies-store.ts b/src/test/fixtures/fake-feature-strategies-store.ts index da16a6527c..b09cf6ead6 100644 --- a/src/test/fixtures/fake-feature-strategies-store.ts +++ b/src/test/fixtures/fake-feature-strategies-store.ts @@ -9,7 +9,7 @@ import { } from '../../lib/types/model'; import NotFoundError from '../../lib/error/notfound-error'; import { IFeatureStrategiesStore } from '../../lib/types/stores/feature-strategies-store'; -import { IFeatureProjectUserParams } from '../../lib/routes/admin-api/project/features'; +import { IFeatureProjectUserParams } from '../../lib/routes/admin-api/project/project-features'; interface ProjectEnvironment { projectName: string; diff --git a/src/test/fixtures/fake-feature-tag-store.ts b/src/test/fixtures/fake-feature-tag-store.ts index 629a58f802..56acbb97cf 100644 --- a/src/test/fixtures/fake-feature-tag-store.ts +++ b/src/test/fixtures/fake-feature-tag-store.ts @@ -91,6 +91,16 @@ export default class FakeFeatureTagStore implements IFeatureTagStore { ), ); } + + async tagFeatures(featureNames: string[], tag: ITag): Promise { + const featureTags = featureNames.map((featureName) => ({ + featureName, + tagType: tag.type, + tagValue: tag.value, + })); + this.featureTags.push(...featureTags); + return Promise.resolve(tag); + } } module.exports = FakeFeatureTagStore;