1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-14 01:16:17 +02:00

feat: bulk update tags (#3274)

This commit is contained in:
Jaanus Sellin 2023-03-09 11:58:06 +02:00 committed by GitHub
parent c42e3f2348
commit a52dd10cf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 176 additions and 157 deletions

View File

@ -48,6 +48,7 @@ export interface IFlags {
proPlanAutoCharge?: boolean; proPlanAutoCharge?: boolean;
notifications?: boolean; notifications?: boolean;
loginHistory?: boolean; loginHistory?: boolean;
bulkOperations?: boolean;
projectScopedSegments?: boolean; projectScopedSegments?: boolean;
} }

View File

@ -69,6 +69,7 @@ exports[`should create default config 1`] = `
"flags": { "flags": {
"ENABLE_DARK_MODE_SUPPORT": false, "ENABLE_DARK_MODE_SUPPORT": false,
"anonymiseEventLog": false, "anonymiseEventLog": false,
"bulkOperations": false,
"caseInsensitiveInOperators": false, "caseInsensitiveInOperators": false,
"crOnVariants": false, "crOnVariants": false,
"embedProxy": true, "embedProxy": true,
@ -92,6 +93,7 @@ exports[`should create default config 1`] = `
"experiments": { "experiments": {
"ENABLE_DARK_MODE_SUPPORT": false, "ENABLE_DARK_MODE_SUPPORT": false,
"anonymiseEventLog": false, "anonymiseEventLog": false,
"bulkOperations": false,
"caseInsensitiveInOperators": false, "caseInsensitiveInOperators": false,
"crOnVariants": false, "crOnVariants": false,
"embedProxy": true, "embedProxy": true,

View File

@ -1,10 +1,8 @@
import { EventEmitter } from 'stream';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import { ITag } from '../types/model'; import { ITag } from '../types';
import EventEmitter from 'events';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error';
import FeatureHasTagError from '../error/feature-has-tag-error';
import { import {
IFeatureAndTag, IFeatureAndTag,
IFeatureTag, IFeatureTag,
@ -123,34 +121,22 @@ class FeatureTagStore implements IFeatureTagStore {
const stopTimer = this.timer('tagFeature'); const stopTimer = this.timer('tagFeature');
await this.db<FeatureTagTable>(TABLE) await this.db<FeatureTagTable>(TABLE)
.insert(this.featureAndTagToRow(featureName, tag)) .insert(this.featureAndTagToRow(featureName, tag))
.catch((err) => { .onConflict(COLUMNS)
if (err.code === UNIQUE_CONSTRAINT_VIOLATION) { .merge();
throw new FeatureHasTagError(
`${featureName} already has the tag: [${tag.type}:${tag.value}]`,
);
} else {
throw err;
}
});
stopTimer(); stopTimer();
return tag; return tag;
} }
async tagFeatures(featureNames: string[], tag: ITag): Promise<ITag> { async untagFeatures(featureTags: IFeatureTag[]): Promise<void> {
const stopTimer = this.timer('tagFeatures'); const stopTimer = this.timer('untagFeatures');
await this.db<FeatureTagTable>(TABLE) try {
.insert(this.featuresAndTagToRow(featureNames, tag)) await this.db(TABLE)
.catch((err) => { .whereIn(COLUMNS, featureTags.map(this.featureTagArray))
if (err.code === UNIQUE_CONSTRAINT_VIOLATION) { .delete();
throw new FeatureHasTagError( } catch (err) {
`Some of the features already have the tag: [${tag.type}:${tag.value}]`, this.logger.error(err);
); }
} else {
throw err;
}
});
stopTimer(); stopTimer();
return tag;
} }
/** /**
@ -176,11 +162,9 @@ class FeatureTagStore implements IFeatureTagStore {
stopTimer(); stopTimer();
} }
async importFeatureTags( async tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]> {
featureTags: IFeatureTag[],
): Promise<IFeatureAndTag[]> {
const rows = await this.db(TABLE) const rows = await this.db(TABLE)
.insert(featureTags.map(this.importToRow)) .insert(featureTags.map(this.featureTagToRow))
.returning(COLUMNS) .returning(COLUMNS)
.onConflict(COLUMNS) .onConflict(COLUMNS)
.ignore(); .ignore();
@ -222,7 +206,7 @@ class FeatureTagStore implements IFeatureTagStore {
}; };
} }
importToRow({ featureTagToRow({
featureName, featureName,
tagType, tagType,
tagValue, tagValue,
@ -234,6 +218,10 @@ class FeatureTagStore implements IFeatureTagStore {
}; };
} }
featureTagArray({ featureName, tagType, tagValue }: IFeatureTag): string[] {
return [featureName, tagType, tagValue];
}
featureAndTagToRow( featureAndTagToRow(
featureName: string, featureName: string,
{ type, value }: ITag, { type, value }: ITag,
@ -244,17 +232,6 @@ class FeatureTagStore implements IFeatureTagStore {
tag_value: value, 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; module.exports = FeatureTagStore;

View File

@ -4,9 +4,19 @@ import { TagsBulkAddSchema } from './tags-bulk-add-schema';
test('tagsBulkAddSchema', () => { test('tagsBulkAddSchema', () => {
const data: TagsBulkAddSchema = { const data: TagsBulkAddSchema = {
features: ['my-feature'], features: ['my-feature'],
tag: { tags: {
type: 'simple', addedTags: [
value: 'besttag', {
type: 'simple',
value: 'besttag',
},
],
removedTags: [
{
type: 'simple2',
value: 'besttag2',
},
],
}, },
}; };

View File

@ -1,11 +1,12 @@
import { FromSchema } from 'json-schema-to-ts'; import { FromSchema } from 'json-schema-to-ts';
import { updateTagsSchema } from './update-tags-schema';
import { tagSchema } from './tag-schema'; import { tagSchema } from './tag-schema';
export const tagsBulkAddSchema = { export const tagsBulkAddSchema = {
$id: '#/components/schemas/tagsBulkAddSchema', $id: '#/components/schemas/tagsBulkAddSchema',
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['features', 'tag'], required: ['features', 'tags'],
properties: { properties: {
features: { features: {
type: 'array', type: 'array',
@ -14,12 +15,13 @@ export const tagsBulkAddSchema = {
minLength: 1, minLength: 1,
}, },
}, },
tag: { tags: {
$ref: '#/components/schemas/tagSchema', $ref: '#/components/schemas/updateTagsSchema',
}, },
}, },
components: { components: {
schemas: { schemas: {
updateTagsSchema,
tagSchema, tagSchema,
}, },
}, },

View File

@ -0,0 +1,23 @@
import { validateSchema } from '../validate';
import { UpdateTagsSchema } from './update-tags-schema';
test('updateTagsSchema', () => {
const data: UpdateTagsSchema = {
addedTags: [
{
type: 'simple',
value: 'besttag',
},
],
removedTags: [
{
type: 'simple2',
value: 'besttag2',
},
],
};
expect(
validateSchema('#/components/schemas/updateTagsSchema', data),
).toBeUndefined();
});

View File

@ -24,6 +24,8 @@ import {
import { emptyResponse } from '../../openapi/util/standard-responses'; import { emptyResponse } from '../../openapi/util/standard-responses';
import FeatureTagService from 'lib/services/feature-tag-service'; import FeatureTagService from 'lib/services/feature-tag-service';
import { TagsBulkAddSchema } from '../../openapi/spec/tags-bulk-add-schema'; import { TagsBulkAddSchema } from '../../openapi/spec/tags-bulk-add-schema';
import NotFoundError from '../../error/notfound-error';
import { IFlagResolver } from '../../types';
const version = 1; const version = 1;
@ -36,6 +38,8 @@ class TagController extends Controller {
private openApiService: OpenApiService; private openApiService: OpenApiService;
private flagResolver: IFlagResolver;
constructor( constructor(
config: IUnleashConfig, config: IUnleashConfig,
{ {
@ -52,6 +56,7 @@ class TagController extends Controller {
this.openApiService = openApiService; this.openApiService = openApiService;
this.featureTagService = featureTagService; this.featureTagService = featureTagService;
this.logger = config.getLogger('/admin-api/tag.js'); this.logger = config.getLogger('/admin-api/tag.js');
this.flagResolver = config.flagResolver;
this.route({ this.route({
method: 'get', method: 'get',
@ -85,18 +90,16 @@ class TagController extends Controller {
], ],
}); });
this.route({ this.route({
method: 'post', method: 'put',
path: '/features', path: '/features',
handler: this.addTagToFeatures, handler: this.updateFeaturesTags,
permission: UPDATE_FEATURE, permission: UPDATE_FEATURE,
middleware: [ middleware: [
openApiService.validPath({ openApiService.validPath({
tags: ['Tags'], tags: ['Tags'],
operationId: 'addTagToFeatures', operationId: 'addTagToFeatures',
requestBody: createRequestSchema('tagsBulkAddSchema'), requestBody: createRequestSchema('tagsBulkAddSchema'),
responses: { responses: { 200: emptyResponse },
201: resourceCreatedResponseSchema('tagSchema'),
},
}), }),
], ],
}); });
@ -207,18 +210,22 @@ class TagController extends Controller {
res.status(200).end(); res.status(200).end();
} }
async addTagToFeatures( async updateFeaturesTags(
req: IAuthRequest<void, void, TagsBulkAddSchema>, req: IAuthRequest<void, void, TagsBulkAddSchema>,
res: Response<TagSchema>, res: Response<TagSchema>,
): Promise<void> { ): Promise<void> {
const { features, tag } = req.body; if (!this.flagResolver.isEnabled('bulkOperations')) {
throw new NotFoundError('Bulk operations are not enabled');
}
const { features, tags } = req.body;
const userName = extractUsername(req); const userName = extractUsername(req);
const addedTag = await this.featureTagService.addTags( await this.featureTagService.updateTags(
features, features,
tag, tags.addedTags,
tags.removedTags,
userName, userName,
); );
res.status(201).json(addedTag); res.status(200).end();
} }
} }
export default TagController; export default TagController;

View File

@ -4,7 +4,10 @@ import { FEATURE_TAGGED, FEATURE_UNTAGGED, TAG_CREATED } from '../types/events';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import { IFeatureToggleStore, IUnleashStores } from '../types/stores'; import { IFeatureToggleStore, IUnleashStores } from '../types/stores';
import { tagSchema } from './tag-schema'; import { tagSchema } from './tag-schema';
import { IFeatureTagStore } from '../types/stores/feature-tag-store'; import {
IFeatureTag,
IFeatureTagStore,
} from '../types/stores/feature-tag-store';
import { IEventStore } from '../types/stores/event-store'; import { IEventStore } from '../types/stores/event-store';
import { ITagStore } from '../types/stores/tag-store'; import { ITagStore } from '../types/stores/tag-store';
import { ITag } from '../types/model'; import { ITag } from '../types/model';
@ -64,30 +67,61 @@ class FeatureTagService {
return validatedTag; return validatedTag;
} }
async addTags( async updateTags(
featureNames: string[], featureNames: string[],
tag: ITag, addedTags: ITag[],
removedTags: ITag[],
userName: string, userName: string,
): Promise<ITag> { ): Promise<void> {
const featureToggles = await this.featureToggleStore.getAllByNames( const featureToggles = await this.featureToggleStore.getAllByNames(
featureNames, featureNames,
); );
const validatedTag = await tagSchema.validateAsync(tag);
await this.createTagIfNeeded(validatedTag, userName);
await this.featureTagStore.tagFeatures(featureNames, validatedTag);
await Promise.all( await Promise.all(
featureToggles.map((featureToggle) => addedTags.map((tag) => this.createTagIfNeeded(tag, userName)),
this.eventStore.store({
type: FEATURE_TAGGED,
createdBy: userName,
featureName: featureToggle.name,
project: featureToggle.project,
data: validatedTag,
}),
),
); );
return validatedTag; const createdFeatureTags: IFeatureTag[] = featureNames.flatMap(
(featureName) =>
addedTags.map((addedTag) => ({
featureName,
tagType: addedTag.type,
tagValue: addedTag.value,
})),
);
await this.featureTagStore.tagFeatures(createdFeatureTags);
const removedFeatureTags: IFeatureTag[] = featureNames.flatMap(
(featureName) =>
removedTags.map((addedTag) => ({
featureName,
tagType: addedTag.type,
tagValue: addedTag.value,
})),
);
await this.featureTagStore.untagFeatures(removedFeatureTags);
const creationEvents = featureToggles.flatMap((featureToggle) =>
addedTags.map((addedTag) => ({
type: FEATURE_TAGGED,
createdBy: userName,
featureName: featureToggle.name,
project: featureToggle.project,
data: addedTag,
})),
);
const removalEvents = featureToggles.flatMap((featureToggle) =>
removedTags.map((removedTag) => ({
type: FEATURE_UNTAGGED,
createdBy: userName,
featureName: featureToggle.name,
project: featureToggle.project,
data: removedTag,
})),
);
await this.eventStore.batchStore([...creationEvents, ...removalEvents]);
} }
async createTagIfNeeded(tag: ITag, userName: string): Promise<void> { async createTagIfNeeded(tag: ITag, userName: string): Promise<void> {

View File

@ -600,10 +600,9 @@ export default class StateService {
: true, : true,
); );
if (featureTagsToInsert.length > 0) { if (featureTagsToInsert.length > 0) {
const importedFeatureTags = const importedFeatureTags = await this.featureTagStore.tagFeatures(
await this.featureTagStore.importFeatureTags( featureTagsToInsert,
featureTagsToInsert, );
);
const importedFeatureTagEvents = importedFeatureTags.map((tag) => ({ const importedFeatureTagEvents = importedFeatureTags.map((tag) => ({
type: FEATURE_TAG_IMPORT, type: FEATURE_TAG_IMPORT,
createdBy: userName, createdBy: userName,

View File

@ -64,6 +64,10 @@ const flags = {
), ),
notifications: parseEnvVarBoolean(process.env.NOTIFICATIONS, false), notifications: parseEnvVarBoolean(process.env.NOTIFICATIONS, false),
loginHistory: parseEnvVarBoolean(process.env.UNLEASH_LOGIN_HISTORY, false), loginHistory: parseEnvVarBoolean(process.env.UNLEASH_LOGIN_HISTORY, false),
bulkOperations: parseEnvVarBoolean(
process.env.UNLEASH_BULK_OPERATIONS,
false,
),
projectScopedSegments: parseEnvVarBoolean( projectScopedSegments: parseEnvVarBoolean(
process.env.PROJECT_SCOPED_SEGMENTS, process.env.PROJECT_SCOPED_SEGMENTS,
false, false,

View File

@ -15,7 +15,7 @@ export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> {
getAllTagsForFeature(featureName: string): Promise<ITag[]>; getAllTagsForFeature(featureName: string): Promise<ITag[]>;
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>; getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
tagFeature(featureName: string, tag: ITag): Promise<ITag>; tagFeature(featureName: string, tag: ITag): Promise<ITag>;
tagFeatures(featureNames: string[], tag: ITag): Promise<ITag>; tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
importFeatureTags(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
untagFeature(featureName: string, tag: ITag): Promise<void>; untagFeature(featureName: string, tag: ITag): Promise<void>;
untagFeatures(featureTags: IFeatureTag[]): Promise<void>;
} }

View File

@ -739,31 +739,6 @@ test('Querying with multiple filters ANDs the filters', async () => {
}); });
}); });
test('Tagging a feature with a tag it already has should return 409', async () => {
const feature1Name = `test.${randomId()}`;
await app.request.post('/api/admin/features').send({
name: feature1Name,
type: 'killswitch',
enabled: true,
strategies: [{ name: 'default' }],
});
const tag = { value: randomId(), type: 'simple' };
await app.request
.post(`/api/admin/features/${feature1Name}/tags`)
.send(tag)
.expect(201);
return app.request
.post(`/api/admin/features/${feature1Name}/tags`)
.send(tag)
.expect(409)
.expect((res) => {
expect(res.body.details[0].message).toBe(
`${feature1Name} already has the tag: [${tag.type}:${tag.value}]`,
);
});
});
test('marks feature toggle as stale', async () => { test('marks feature toggle as stale', async () => {
expect.assertions(1); expect.assertions(1);
await app.request await app.request

View File

@ -11,6 +11,7 @@ beforeAll(async () => {
experimental: { experimental: {
flags: { flags: {
strictSchemaValidation: true, strictSchemaValidation: true,
bulkOperations: true,
}, },
}, },
}); });
@ -112,16 +113,30 @@ test('Can delete a tag', async () => {
test('Can tag features', async () => { test('Can tag features', async () => {
const featureName = 'test.feature'; const featureName = 'test.feature';
const featureName2 = 'test.feature2'; const featureName2 = 'test.feature2';
const tag = { const addedTag = {
value: 'TeamRed', value: 'TeamRed',
type: 'simple', type: 'simple',
}; };
const removedTag = {
value: 'remove_me',
type: 'simple',
};
await app.request.post('/api/admin/features').send({ await app.request.post('/api/admin/features').send({
name: featureName, name: featureName,
type: 'killswitch', type: 'killswitch',
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
await db.stores.tagStore.createTag(removedTag);
await db.stores.featureTagStore.tagFeature(featureName, removedTag);
const initialTagState = await app.request.get(
`/api/admin/features/${featureName}/tags`,
);
expect(initialTagState.body).toMatchObject({ tags: [removedTag] });
await app.request.post('/api/admin/features').send({ await app.request.post('/api/admin/features').send({
name: featureName2, name: featureName2,
type: 'killswitch', type: 'killswitch',
@ -129,9 +144,12 @@ test('Can tag features', async () => {
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
await app.request.post('/api/admin/tags/features').send({ await app.request.put('/api/admin/tags/features').send({
features: [featureName, featureName2], features: [featureName, featureName2],
tag: tag, tags: {
addedTags: [addedTag],
removedTags: [removedTag],
},
}); });
const res = await app.request.get( const res = await app.request.get(
`/api/admin/features/${featureName}/tags`, `/api/admin/features/${featureName}/tags`,
@ -141,6 +159,6 @@ test('Can tag features', async () => {
`/api/admin/features/${featureName2}/tags`, `/api/admin/features/${featureName2}/tags`,
); );
expect(res.body).toMatchObject({ tags: [tag] }); expect(res.body).toMatchObject({ tags: [addedTag] });
expect(res2.body).toMatchObject({ tags: [tag] }); expect(res2.body).toMatchObject({ tags: [addedTag] });
}); });

View File

@ -3652,13 +3652,13 @@ Stats are divided into current and previous **windows**.
}, },
"type": "array", "type": "array",
}, },
"tag": { "tags": {
"$ref": "#/components/schemas/tagSchema", "$ref": "#/components/schemas/updateTagsSchema",
}, },
}, },
"required": [ "required": [
"features", "features",
"tag", "tags",
], ],
"type": "object", "type": "object",
}, },
@ -7960,7 +7960,7 @@ If the provided project does not exist, the list of events will be empty.",
}, },
}, },
"/api/admin/tags/features": { "/api/admin/tags/features": {
"post": { "put": {
"operationId": "addTagToFeatures", "operationId": "addTagToFeatures",
"requestBody": { "requestBody": {
"content": { "content": {
@ -7974,24 +7974,8 @@ If the provided project does not exist, the list of events will be empty.",
"required": true, "required": true,
}, },
"responses": { "responses": {
"201": { "200": {
"content": { "description": "This response has no body.",
"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": [

View File

@ -71,16 +71,6 @@ test('should untag feature', async () => {
expect(featureTags).toHaveLength(0); expect(featureTags).toHaveLength(0);
}); });
test('should throw if feature have tag', async () => {
expect.assertions(1);
await featureTagStore.tagFeature(featureName, tag);
try {
await featureTagStore.tagFeature(featureName, tag);
} catch (e) {
expect(e.message).toContain('already has the tag');
}
});
test('get all feature tags', async () => { test('get all feature tags', async () => {
await featureTagStore.tagFeature(featureName, tag); await featureTagStore.tagFeature(featureName, tag);
await featureToggleStore.create('default', { await featureToggleStore.create('default', {
@ -95,7 +85,7 @@ test('should import feature tags', async () => {
await featureToggleStore.create('default', { await featureToggleStore.create('default', {
name: 'some-other-toggle-import', name: 'some-other-toggle-import',
}); });
await featureTagStore.importFeatureTags([ await featureTagStore.tagFeatures([
{ featureName, tagType: tag.type, tagValue: tag.value }, { featureName, tagType: tag.type, tagValue: tag.value },
{ {
featureName: 'some-other-toggle-import', featureName: 'some-other-toggle-import',

View File

@ -57,9 +57,7 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
return Promise.resolve(); return Promise.resolve();
} }
async importFeatureTags( async tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]> {
featureTags: IFeatureTag[],
): Promise<IFeatureAndTag[]> {
return Promise.all( return Promise.all(
featureTags.map(async (fT) => { featureTags.map(async (fT) => {
const saved = await this.tagFeature(fT.featureName, { const saved = await this.tagFeature(fT.featureName, {
@ -92,14 +90,9 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
); );
} }
async tagFeatures(featureNames: string[], tag: ITag): Promise<ITag> { // eslint-disable-next-line @typescript-eslint/no-unused-vars
const featureTags = featureNames.map((featureName) => ({ untagFeatures(featureTags: IFeatureTag[]): Promise<void> {
featureName, throw new Error('Method not implemented.');
tagType: tag.type,
tagValue: tag.value,
}));
this.featureTags.push(...featureTags);
return Promise.resolve(tag);
} }
} }