mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-09 01:17:06 +02:00
feat: bulk tag features (#3258)
This commit is contained in:
parent
a077967760
commit
0819d64448
@ -21,7 +21,7 @@ import FeatureToggleStore from './feature-toggle-store';
|
|||||||
import { ensureStringValue } from '../util/ensureStringValue';
|
import { ensureStringValue } from '../util/ensureStringValue';
|
||||||
import { mapValues } from '../util/map-values';
|
import { mapValues } from '../util/map-values';
|
||||||
import { IFlagResolver } from '../types/experimental';
|
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 Raw = Knex.Raw;
|
||||||
import { Db } from './db';
|
import { Db } from './db';
|
||||||
|
|
||||||
|
@ -136,6 +136,23 @@ class FeatureTagStore implements IFeatureTagStore {
|
|||||||
return tag;
|
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.
|
* Only gets tags for active feature toggles.
|
||||||
*/
|
*/
|
||||||
@ -227,6 +244,17 @@ 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;
|
||||||
|
@ -102,6 +102,7 @@ import {
|
|||||||
stateSchema,
|
stateSchema,
|
||||||
strategiesSchema,
|
strategiesSchema,
|
||||||
strategySchema,
|
strategySchema,
|
||||||
|
tagsBulkAddSchema,
|
||||||
tagSchema,
|
tagSchema,
|
||||||
tagsSchema,
|
tagsSchema,
|
||||||
tagTypeSchema,
|
tagTypeSchema,
|
||||||
@ -250,6 +251,7 @@ export const schemas = {
|
|||||||
stateSchema,
|
stateSchema,
|
||||||
strategiesSchema,
|
strategiesSchema,
|
||||||
strategySchema,
|
strategySchema,
|
||||||
|
tagsBulkAddSchema,
|
||||||
tagSchema,
|
tagSchema,
|
||||||
tagsSchema,
|
tagsSchema,
|
||||||
tagTypeSchema,
|
tagTypeSchema,
|
||||||
|
@ -129,3 +129,4 @@ export * from './project-overview-schema';
|
|||||||
export * from './import-toggles-validate-item-schema';
|
export * from './import-toggles-validate-item-schema';
|
||||||
export * from './import-toggles-validate-schema';
|
export * from './import-toggles-validate-schema';
|
||||||
export * from './import-toggles-schema';
|
export * from './import-toggles-schema';
|
||||||
|
export * from './tags-bulk-add-schema';
|
||||||
|
16
src/lib/openapi/spec/tags-bulk-add-schema.test.ts
Normal file
16
src/lib/openapi/spec/tags-bulk-add-schema.test.ts
Normal 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();
|
||||||
|
});
|
28
src/lib/openapi/spec/tags-bulk-add-schema.ts
Normal file
28
src/lib/openapi/spec/tags-bulk-add-schema.ts
Normal 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>;
|
@ -2,7 +2,7 @@ import { Response } from 'express';
|
|||||||
import Controller from '../../controller';
|
import Controller from '../../controller';
|
||||||
import { IUnleashConfig } from '../../../types/option';
|
import { IUnleashConfig } from '../../../types/option';
|
||||||
import { IUnleashServices } from '../../../types/services';
|
import { IUnleashServices } from '../../../types/services';
|
||||||
import ProjectFeaturesController from './features';
|
import ProjectFeaturesController from './project-features';
|
||||||
import EnvironmentsController from './environments';
|
import EnvironmentsController from './environments';
|
||||||
import ProjectHealthReport from './health-report';
|
import ProjectHealthReport from './health-report';
|
||||||
import ProjectService from '../../../services/project-service';
|
import ProjectService from '../../../services/project-service';
|
||||||
|
@ -22,6 +22,8 @@ import {
|
|||||||
TagWithVersionSchema,
|
TagWithVersionSchema,
|
||||||
} from '../../openapi/spec/tag-with-version-schema';
|
} from '../../openapi/spec/tag-with-version-schema';
|
||||||
import { emptyResponse } from '../../openapi/util/standard-responses';
|
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;
|
const version = 1;
|
||||||
|
|
||||||
@ -30,6 +32,8 @@ class TagController extends Controller {
|
|||||||
|
|
||||||
private tagService: TagService;
|
private tagService: TagService;
|
||||||
|
|
||||||
|
private featureTagService: FeatureTagService;
|
||||||
|
|
||||||
private openApiService: OpenApiService;
|
private openApiService: OpenApiService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -37,11 +41,16 @@ class TagController extends Controller {
|
|||||||
{
|
{
|
||||||
tagService,
|
tagService,
|
||||||
openApiService,
|
openApiService,
|
||||||
}: Pick<IUnleashServices, 'tagService' | 'openApiService'>,
|
featureTagService,
|
||||||
|
}: Pick<
|
||||||
|
IUnleashServices,
|
||||||
|
'tagService' | 'openApiService' | 'featureTagService'
|
||||||
|
>,
|
||||||
) {
|
) {
|
||||||
super(config);
|
super(config);
|
||||||
this.tagService = tagService;
|
this.tagService = tagService;
|
||||||
this.openApiService = openApiService;
|
this.openApiService = openApiService;
|
||||||
|
this.featureTagService = featureTagService;
|
||||||
this.logger = config.getLogger('/admin-api/tag.js');
|
this.logger = config.getLogger('/admin-api/tag.js');
|
||||||
|
|
||||||
this.route({
|
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({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/:type',
|
path: '/:type',
|
||||||
@ -181,5 +206,19 @@ class TagController extends Controller {
|
|||||||
await this.tagService.deleteTag({ type, value }, userName);
|
await this.tagService.deleteTag({ type, value }, userName);
|
||||||
res.status(200).end();
|
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;
|
export default TagController;
|
||||||
|
@ -64,6 +64,32 @@ class FeatureTagService {
|
|||||||
return validatedTag;
|
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> {
|
async createTagIfNeeded(tag: ITag, userName: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.tagStore.getTag(tag.type, tag.value);
|
await this.tagStore.getTag(tag.type, tag.value);
|
||||||
|
@ -81,7 +81,7 @@ import {
|
|||||||
SKIP_CHANGE_REQUEST,
|
SKIP_CHANGE_REQUEST,
|
||||||
} from '../types/permissions';
|
} from '../types/permissions';
|
||||||
import NoAccessError from '../error/no-access-error';
|
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 {
|
interface IFeatureContext {
|
||||||
featureName: string;
|
featureName: string;
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
IVariant,
|
IVariant,
|
||||||
} from '../model';
|
} from '../model';
|
||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
import { IFeatureProjectUserParams } from '../../routes/admin-api/project/features';
|
import { IFeatureProjectUserParams } from '../../routes/admin-api/project/project-features';
|
||||||
|
|
||||||
export interface FeatureConfigurationClient {
|
export interface FeatureConfigurationClient {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -15,6 +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>;
|
||||||
importFeatureTags(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
|
importFeatureTags(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
|
||||||
untagFeature(featureName: string, tag: ITag): Promise<void>;
|
untagFeature(featureName: string, tag: ITag): Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -108,3 +108,39 @@ test('Can delete a tag', async () => {
|
|||||||
).toBe(-1);
|
).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] });
|
||||||
|
});
|
||||||
|
@ -3642,6 +3642,26 @@ Stats are divided into current and previous **windows**.
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"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": {
|
"tagsSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"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}": {
|
"/api/admin/tags/{type}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getTagsByType",
|
"operationId": "getTagsByType",
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
} from '../../lib/types/model';
|
} from '../../lib/types/model';
|
||||||
import NotFoundError from '../../lib/error/notfound-error';
|
import NotFoundError from '../../lib/error/notfound-error';
|
||||||
import { IFeatureStrategiesStore } from '../../lib/types/stores/feature-strategies-store';
|
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 {
|
interface ProjectEnvironment {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
10
src/test/fixtures/fake-feature-tag-store.ts
vendored
10
src/test/fixtures/fake-feature-tag-store.ts
vendored
@ -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;
|
module.exports = FakeFeatureTagStore;
|
||||||
|
Loading…
Reference in New Issue
Block a user