mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
parent
1ef0ca4ebf
commit
1746a951c2
@ -274,6 +274,15 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return this.rowToFeature(row[0]);
|
return this.rowToFeature(row[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchArchive(names: string[]): Promise<FeatureToggle[]> {
|
||||||
|
const now = new Date();
|
||||||
|
const rows = await this.db(TABLE)
|
||||||
|
.whereIn('name', names)
|
||||||
|
.update({ archived_at: now })
|
||||||
|
.returning(FEATURE_COLUMNS);
|
||||||
|
return rows.map((row) => this.rowToFeature(row));
|
||||||
|
}
|
||||||
|
|
||||||
async delete(name: string): Promise<void> {
|
async delete(name: string): Promise<void> {
|
||||||
await this.db(TABLE)
|
await this.db(TABLE)
|
||||||
.where({ name }) // Feature toggle must be archived to allow deletion
|
.where({ name }) // Feature toggle must be archived to allow deletion
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
apiTokensSchema,
|
apiTokensSchema,
|
||||||
applicationSchema,
|
applicationSchema,
|
||||||
applicationsSchema,
|
applicationsSchema,
|
||||||
|
archiveFeaturesSchema,
|
||||||
changePasswordSchema,
|
changePasswordSchema,
|
||||||
clientApplicationSchema,
|
clientApplicationSchema,
|
||||||
clientFeatureSchema,
|
clientFeatureSchema,
|
||||||
@ -155,6 +156,7 @@ export const schemas = {
|
|||||||
apiTokensSchema,
|
apiTokensSchema,
|
||||||
applicationSchema,
|
applicationSchema,
|
||||||
applicationsSchema,
|
applicationsSchema,
|
||||||
|
archiveFeaturesSchema,
|
||||||
bulkRegistrationSchema,
|
bulkRegistrationSchema,
|
||||||
bulkMetricsSchema,
|
bulkMetricsSchema,
|
||||||
changePasswordSchema,
|
changePasswordSchema,
|
||||||
|
20
src/lib/openapi/spec/archive-features-schema.ts
Normal file
20
src/lib/openapi/spec/archive-features-schema.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const archiveFeaturesSchema = {
|
||||||
|
$id: '#/components/schemas/archiveFeaturesSchema',
|
||||||
|
type: 'object',
|
||||||
|
required: ['features'],
|
||||||
|
properties: {
|
||||||
|
features: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ArchiveFeaturesSchema = FromSchema<typeof archiveFeaturesSchema>;
|
@ -131,3 +131,4 @@ export * from './import-toggles-validate-schema';
|
|||||||
export * from './import-toggles-schema';
|
export * from './import-toggles-schema';
|
||||||
export * from './stickiness-schema';
|
export * from './stickiness-schema';
|
||||||
export * from './tags-bulk-add-schema';
|
export * from './tags-bulk-add-schema';
|
||||||
|
export * from './archive-features-schema';
|
||||||
|
@ -13,12 +13,14 @@ import {
|
|||||||
UPDATE_FEATURE,
|
UPDATE_FEATURE,
|
||||||
UPDATE_FEATURE_ENVIRONMENT,
|
UPDATE_FEATURE_ENVIRONMENT,
|
||||||
UPDATE_FEATURE_STRATEGY,
|
UPDATE_FEATURE_STRATEGY,
|
||||||
|
IFlagResolver,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import { Logger } from '../../../logger';
|
import { Logger } from '../../../logger';
|
||||||
import { extractUsername } from '../../../util';
|
import { extractUsername } from '../../../util';
|
||||||
import { IAuthRequest } from '../../unleash-types';
|
import { IAuthRequest } from '../../unleash-types';
|
||||||
import {
|
import {
|
||||||
AdminFeaturesQuerySchema,
|
AdminFeaturesQuerySchema,
|
||||||
|
ArchiveFeaturesSchema,
|
||||||
CreateFeatureSchema,
|
CreateFeatureSchema,
|
||||||
CreateFeatureStrategySchema,
|
CreateFeatureStrategySchema,
|
||||||
createRequestSchema,
|
createRequestSchema,
|
||||||
@ -43,6 +45,7 @@ import {
|
|||||||
FeatureToggleService,
|
FeatureToggleService,
|
||||||
} from '../../../services';
|
} from '../../../services';
|
||||||
import { querySchema } from '../../../schema/feature-schema';
|
import { querySchema } from '../../../schema/feature-schema';
|
||||||
|
import NotFoundError from '../../../error/notfound-error';
|
||||||
|
|
||||||
interface FeatureStrategyParams {
|
interface FeatureStrategyParams {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -72,6 +75,7 @@ export interface IFeatureProjectUserParams extends ProjectParam {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PATH = '/:projectId/features';
|
const PATH = '/:projectId/features';
|
||||||
|
const PATH_ARCHIVE = '/:projectId/archive';
|
||||||
const PATH_FEATURE = `${PATH}/:featureName`;
|
const PATH_FEATURE = `${PATH}/:featureName`;
|
||||||
const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`;
|
const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`;
|
||||||
const PATH_ENV = `${PATH_FEATURE}/environments/:environment`;
|
const PATH_ENV = `${PATH_FEATURE}/environments/:environment`;
|
||||||
@ -93,6 +97,8 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
|
|
||||||
private segmentService: SegmentService;
|
private segmentService: SegmentService;
|
||||||
|
|
||||||
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -107,6 +113,7 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
this.featureService = featureToggleServiceV2;
|
this.featureService = featureToggleServiceV2;
|
||||||
this.openApiService = openApiService;
|
this.openApiService = openApiService;
|
||||||
this.segmentService = segmentService;
|
this.segmentService = segmentService;
|
||||||
|
this.flagResolver = config.flagResolver;
|
||||||
this.logger = config.getLogger('/admin-api/project/features.ts');
|
this.logger = config.getLogger('/admin-api/project/features.ts');
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
@ -389,7 +396,7 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
'This endpoint archives the specified feature if the feature belongs to the specified project.',
|
'This endpoint archives the specified feature if the feature belongs to the specified project.',
|
||||||
summary: 'Archive a feature.',
|
summary: 'Archive a feature.',
|
||||||
responses: {
|
responses: {
|
||||||
200: emptyResponse,
|
202: emptyResponse,
|
||||||
403: {
|
403: {
|
||||||
description:
|
description:
|
||||||
'You either do not have the required permissions or used an invalid URL.',
|
'You either do not have the required permissions or used an invalid URL.',
|
||||||
@ -399,6 +406,24 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'post',
|
||||||
|
path: PATH_ARCHIVE,
|
||||||
|
handler: this.archiveFeatures,
|
||||||
|
permission: DELETE_FEATURE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Features'],
|
||||||
|
operationId: 'archiveFeatures',
|
||||||
|
description:
|
||||||
|
'This endpoint archives the specified features.',
|
||||||
|
summary: 'Archive a list of features',
|
||||||
|
requestBody: createRequestSchema('archiveFeaturesSchema'),
|
||||||
|
responses: { 202: emptyResponse },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFeatures(
|
async getFeatures(
|
||||||
@ -577,6 +602,22 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
res.status(202).send();
|
res.status(202).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archiveFeatures(
|
||||||
|
req: IAuthRequest<{ projectId: string }, void, ArchiveFeaturesSchema>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.flagResolver.isEnabled('bulkOperations')) {
|
||||||
|
throw new NotFoundError('Bulk operations are not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { features } = req.body;
|
||||||
|
const { projectId } = req.params;
|
||||||
|
const userName = extractUsername(req);
|
||||||
|
|
||||||
|
await this.featureService.archiveToggles(features, userName, projectId);
|
||||||
|
res.status(202).end();
|
||||||
|
}
|
||||||
|
|
||||||
async getFeatureEnvironment(
|
async getFeatureEnvironment(
|
||||||
req: Request<FeatureStrategyParams, any, any, any>,
|
req: Request<FeatureStrategyParams, any, any, any>,
|
||||||
res: Response<FeatureEnvironmentSchema>,
|
res: Response<FeatureEnvironmentSchema>,
|
||||||
|
@ -82,6 +82,7 @@ import {
|
|||||||
} 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/project-features';
|
import { IFeatureProjectUserParams } from '../routes/admin-api/project/project-features';
|
||||||
|
import { unique } from '../util/unique';
|
||||||
|
|
||||||
interface IFeatureContext {
|
interface IFeatureContext {
|
||||||
featureName: string;
|
featureName: string;
|
||||||
@ -171,6 +172,28 @@ class FeatureToggleService {
|
|||||||
this.flagResolver = flagResolver;
|
this.flagResolver = flagResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validateFeaturesContext(
|
||||||
|
featureNames: string[],
|
||||||
|
projectId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const features = await this.featureToggleStore.getAllByNames(
|
||||||
|
featureNames,
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidProjects = unique(
|
||||||
|
features
|
||||||
|
.map((feature) => feature.project)
|
||||||
|
.filter((project) => project !== projectId),
|
||||||
|
);
|
||||||
|
if (invalidProjects.length > 0) {
|
||||||
|
throw new InvalidOperationError(
|
||||||
|
`The operation could not be completed. The features exist, but the provided project ids ("${invalidProjects.join(
|
||||||
|
',',
|
||||||
|
)}") does not match the project provided in request URL ("${projectId}").`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async validateFeatureContext({
|
async validateFeatureContext({
|
||||||
featureName,
|
featureName,
|
||||||
projectId,
|
projectId,
|
||||||
@ -1055,6 +1078,29 @@ class FeatureToggleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archiveToggles(
|
||||||
|
featureNames: string[],
|
||||||
|
createdBy: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.validateFeaturesContext(featureNames, projectId);
|
||||||
|
|
||||||
|
const features = await this.featureToggleStore.getAllByNames(
|
||||||
|
featureNames,
|
||||||
|
);
|
||||||
|
await this.featureToggleStore.batchArchive(featureNames);
|
||||||
|
await this.eventStore.batchStore(
|
||||||
|
features.map(
|
||||||
|
(feature) =>
|
||||||
|
new FeatureArchivedEvent({
|
||||||
|
featureName: feature.name,
|
||||||
|
createdBy,
|
||||||
|
project: feature.project,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async updateEnabled(
|
async updateEnabled(
|
||||||
project: string,
|
project: string,
|
||||||
featureName: string,
|
featureName: string,
|
||||||
|
@ -330,7 +330,7 @@ export class FeatureArchivedEvent extends BaseEvent {
|
|||||||
project: string;
|
project: string;
|
||||||
featureName: string;
|
featureName: string;
|
||||||
createdBy: string | IUser;
|
createdBy: string | IUser;
|
||||||
tags: ITag[];
|
tags?: ITag[];
|
||||||
}) {
|
}) {
|
||||||
super(FEATURE_ARCHIVED, p.createdBy, p.tags);
|
super(FEATURE_ARCHIVED, p.createdBy, p.tags);
|
||||||
const { project, featureName } = p;
|
const { project, featureName } = p;
|
||||||
|
@ -15,6 +15,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
|||||||
create(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
|
create(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
|
||||||
update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
|
update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
|
||||||
archive(featureName: string): Promise<FeatureToggle>;
|
archive(featureName: string): Promise<FeatureToggle>;
|
||||||
|
batchArchive(featureNames: string[]): Promise<FeatureToggle[]>;
|
||||||
revive(featureName: string): Promise<FeatureToggle>;
|
revive(featureName: string): Promise<FeatureToggle>;
|
||||||
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
||||||
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
||||||
|
2
src/lib/util/unique.ts
Normal file
2
src/lib/util/unique.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const unique = <T extends string | number>(items: T[]): T[] =>
|
||||||
|
Array.from(new Set(items));
|
@ -43,6 +43,7 @@ process.nextTick(async () => {
|
|||||||
projectStatusApi: true,
|
projectStatusApi: true,
|
||||||
showProjectApiAccess: true,
|
showProjectApiAccess: true,
|
||||||
projectScopedSegments: true,
|
projectScopedSegments: true,
|
||||||
|
bulkOperations: true,
|
||||||
projectScopedStickiness: true,
|
projectScopedStickiness: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -25,6 +25,7 @@ import {
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import { randomId } from '../../../../../lib/util/random-id';
|
import { randomId } from '../../../../../lib/util/random-id';
|
||||||
|
import { DEFAULT_PROJECT } from '../../../../../lib/types';
|
||||||
|
|
||||||
let app: IUnleashTest;
|
let app: IUnleashTest;
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
@ -32,7 +33,10 @@ const sortOrderFirst = 0;
|
|||||||
const sortOrderSecond = 10;
|
const sortOrderSecond = 10;
|
||||||
const sortOrderDefault = 9999;
|
const sortOrderDefault = 9999;
|
||||||
|
|
||||||
const createFeatureToggle = (featureName: string, project = 'default') => {
|
const createFeatureToggle = (
|
||||||
|
featureName: string,
|
||||||
|
project = DEFAULT_PROJECT,
|
||||||
|
) => {
|
||||||
return app.request.post(`/api/admin/projects/${project}/features`).send({
|
return app.request.post(`/api/admin/projects/${project}/features`).send({
|
||||||
name: featureName,
|
name: featureName,
|
||||||
});
|
});
|
||||||
@ -92,6 +96,7 @@ beforeAll(async () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
|
bulkOperations: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -2818,3 +2823,25 @@ test('Can query for two tags at the same time. Tags are ORed together', async ()
|
|||||||
expect(res.body.features).toHaveLength(3);
|
expect(res.body.features).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Should be able to bulk archive features', async () => {
|
||||||
|
const featureName1 = 'archivedFeature1';
|
||||||
|
const featureName2 = 'archivedFeature2';
|
||||||
|
|
||||||
|
await createFeatureToggle(featureName1);
|
||||||
|
await createFeatureToggle(featureName2);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.post(`/api/admin/projects/${DEFAULT_PROJECT}/archive`)
|
||||||
|
.send({
|
||||||
|
features: [featureName1, featureName2],
|
||||||
|
})
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
features: [{}, { name: featureName1 }, { name: featureName2 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -328,6 +328,20 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"archiveFeaturesSchema": {
|
||||||
|
"properties": {
|
||||||
|
"features": {
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"features",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
"bulkMetricsSchema": {
|
"bulkMetricsSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"applications": {
|
"applications": {
|
||||||
@ -5966,6 +5980,42 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/admin/projects/{projectId}/archive": {
|
||||||
|
"post": {
|
||||||
|
"description": "This endpoint archives the specified features.",
|
||||||
|
"operationId": "archiveFeatures",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "projectId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/archiveFeaturesSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "archiveFeaturesSchema",
|
||||||
|
"required": true,
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "This response has no body.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"summary": "Archive a list of features",
|
||||||
|
"tags": [
|
||||||
|
"Features",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/admin/projects/{projectId}/environments": {
|
"/api/admin/projects/{projectId}/environments": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "addEnvironmentToProject",
|
"operationId": "addEnvironmentToProject",
|
||||||
@ -6167,7 +6217,7 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"202": {
|
||||||
"description": "This response has no body.",
|
"description": "This response has no body.",
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
|
10
src/test/fixtures/fake-feature-toggle-store.ts
vendored
10
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -24,6 +24,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchArchive(featureNames: string[]): Promise<FeatureToggle[]> {
|
||||||
|
const features = this.features.filter((feature) =>
|
||||||
|
featureNames.includes(feature.name),
|
||||||
|
);
|
||||||
|
for (const feature of features) {
|
||||||
|
feature.archived = true;
|
||||||
|
}
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
|
async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
|
||||||
return this.features.filter(this.getFilterQuery(query)).length;
|
return this.features.filter(this.getFilterQuery(query)).length;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user