1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: bulk tag features (#3258)

This commit is contained in:
Jaanus Sellin 2023-03-08 10:07:06 +02:00 committed by GitHub
parent a077967760
commit 0819d64448
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 253 additions and 6 deletions

View File

@ -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';

View File

@ -136,6 +136,23 @@ class FeatureTagStore implements IFeatureTagStore {
return tag;
}
async tagFeatures(featureNames: string[], tag: ITag): Promise<ITag> {
const stopTimer = this.timer('tagFeatures');
await this.db<FeatureTagTable>(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;

View File

@ -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,

View File

@ -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';

View File

@ -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();
});

View File

@ -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<typeof tagsBulkAddSchema>;

View File

@ -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';

View File

@ -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<IUnleashServices, 'tagService' | 'openApiService'>,
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<void, void, TagsBulkAddSchema>,
res: Response<TagSchema>,
): Promise<void> {
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;

View File

@ -64,6 +64,32 @@ class FeatureTagService {
return validatedTag;
}
async addTags(
featureNames: string[],
tag: ITag,
userName: string,
): Promise<ITag> {
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<void> {
try {
await this.tagStore.getTag(tag.type, tag.value);

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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] });
});

View File

@ -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",

View File

@ -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;

View File

@ -91,6 +91,16 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
),
);
}
async tagFeatures(featureNames: string[], tag: ITag): Promise<ITag> {
const featureTags = featureNames.map((featureName) => ({
featureName,
tagType: tag.type,
tagValue: tag.value,
}));
this.featureTags.push(...featureTags);
return Promise.resolve(tag);
}
}
module.exports = FakeFeatureTagStore;