diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index b1dc56e14d..8942448b6f 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -140,6 +140,7 @@ import { maintenanceSchema } from './spec/maintenance-schema'; import { bulkRegistrationSchema } from './spec/bulk-registration-schema'; import { bulkMetricsSchema } from './spec/bulk-metrics-schema'; import { clientMetricsEnvSchema } from './spec/client-metrics-env-schema'; +import { updateTagsSchema } from './spec/update-tags-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -261,6 +262,7 @@ export const schemas = { updateFeatureStrategySchema, updateTagTypeSchema, updateUserSchema, + updateTagsSchema, upsertContextFieldSchema, upsertStrategySchema, userSchema, diff --git a/src/lib/openapi/spec/update-tags-schema.ts b/src/lib/openapi/spec/update-tags-schema.ts new file mode 100644 index 0000000000..43bfed7fb9 --- /dev/null +++ b/src/lib/openapi/spec/update-tags-schema.ts @@ -0,0 +1,30 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { tagSchema } from './tag-schema'; + +export const updateTagsSchema = { + $id: '#/components/schemas/updateTagsSchema', + type: 'object', + additionalProperties: false, + required: ['addedTags', 'removedTags'], + properties: { + addedTags: { + type: 'array', + items: { + $ref: '#/components/schemas/tagSchema', + }, + }, + removedTags: { + type: 'array', + items: { + $ref: '#/components/schemas/tagSchema', + }, + }, + }, + components: { + schemas: { + tagSchema, + }, + }, +} as const; + +export type UpdateTagsSchema = FromSchema; diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index 8e1aa4a327..9305f53869 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -30,6 +30,7 @@ import { resourceCreatedResponseSchema, } from '../../openapi/util/create-response-schema'; import { emptyResponse } from '../../openapi/util/standard-responses'; +import { UpdateTagsSchema } from '../../openapi/spec/update-tags-schema'; const version = 1; @@ -133,6 +134,23 @@ class FeatureController extends Controller { ], }); + this.route({ + method: 'put', + path: '/:featureName/tags', + permission: UPDATE_FEATURE, + handler: this.updateTags, + middleware: [ + openApiService.validPath({ + tags: ['Features'], + operationId: 'updateTags', + requestBody: createRequestSchema('updateTagsSchema'), + responses: { + 200: resourceCreatedResponseSchema('tagsSchema'), + }, + }), + ], + }); + this.route({ method: 'delete', path: '/:featureName/tags/:type/:value', @@ -230,6 +248,35 @@ class FeatureController extends Controller { res.status(201).header('location', `${featureName}/tags`).json(tag); } + async updateTags( + req: IAuthRequest< + { featureName: string }, + Response, + UpdateTagsSchema, + any + >, + res: Response, + ): Promise { + const { featureName } = req.params; + const { addedTags, removedTags } = req.body; + const userName = extractUsername(req); + + await Promise.all( + addedTags.map((addedTag) => + this.tagService.addTag(featureName, addedTag, userName), + ), + ); + + await Promise.all( + removedTags.map((removedTag) => + this.tagService.removeTag(featureName, removedTag, userName), + ), + ); + + const tags = await this.tagService.listTags(featureName); + res.json({ version, tags }); + } + // TODO async removeTag( req: IAuthRequest<{ featureName: string; type: string; value: string }>, diff --git a/src/test/e2e/api/admin/feature.e2e.test.ts b/src/test/e2e/api/admin/feature.e2e.test.ts index 3be5968684..92879869f8 100644 --- a/src/test/e2e/api/admin/feature.e2e.test.ts +++ b/src/test/e2e/api/admin/feature.e2e.test.ts @@ -11,6 +11,7 @@ import { IVariant, } from '../../../../lib/types/model'; import { randomId } from '../../../../lib/util/random-id'; +import { UpdateTagsSchema } from '../../../../lib/openapi/spec/update-tags-schema'; let app: IUnleashTest; let db: ITestDb; @@ -842,3 +843,29 @@ test('should have access to the get all features endpoint even if api is disable .get('/api/admin/features') .expect(200); }); + +test('Can add and remove tags at the same time', async () => { + const tag = { type: 'simple', value: 'addremove-first-tag' }; + const secondTag = { type: 'simple', value: 'addremove-second-tag' }; + await db.stores.tagStore.createTag(tag); + await db.stores.tagStore.createTag(secondTag); + const taggedWithFirst = await db.stores.featureToggleStore.create( + 'default', + { + name: 'tagged-with-first-tag-1', + }, + ); + + const data: UpdateTagsSchema = { + addedTags: [secondTag], + removedTags: [tag], + }; + + await db.stores.featureTagStore.tagFeature(taggedWithFirst.name, tag); + await app.request + .put(`/api/admin/features/${taggedWithFirst.name}/tags`) + .send(data) + .expect((res) => { + expect(res.body.tags).toHaveLength(1); + }); +}); 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 70c5232ace..69ac9c9268 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 @@ -3853,6 +3853,28 @@ Stats are divided into current and previous **windows**. }, "type": "object", }, + "updateTagsSchema": { + "additionalProperties": false, + "properties": { + "addedTags": { + "items": { + "$ref": "#/components/schemas/tagSchema", + }, + "type": "array", + }, + "removedTags": { + "items": { + "$ref": "#/components/schemas/tagSchema", + }, + "type": "array", + }, + }, + "required": [ + "addedTags", + "removedTags", + ], + "type": "object", + }, "updateUserSchema": { "additionalProperties": true, "properties": { @@ -5250,6 +5272,54 @@ If the provided project does not exist, the list of events will be empty.", "Features", ], }, + "put": { + "operationId": "updateTags", + "parameters": [ + { + "in": "path", + "name": "featureName", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/updateTagsSchema", + }, + }, + }, + "description": "updateTagsSchema", + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/tagsSchema", + }, + }, + }, + "description": "The resource was successfully created.", + "headers": { + "location": { + "description": "The location of the newly created resource.", + "schema": { + "format": "uri", + "type": "string", + }, + }, + }, + }, + }, + "tags": [ + "Features", + ], + }, }, "/api/admin/features/{featureName}/tags/{type}/{value}": { "delete": {