mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
feat: bulk stale features (#3311)
This commit is contained in:
parent
240bb7b027
commit
6c813ab066
@ -283,6 +283,17 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return rows.map((row) => this.rowToFeature(row));
|
return rows.map((row) => this.rowToFeature(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchStale(
|
||||||
|
names: string[],
|
||||||
|
stale: boolean,
|
||||||
|
): Promise<FeatureToggle[]> {
|
||||||
|
const rows = await this.db(TABLE)
|
||||||
|
.whereIn('name', names)
|
||||||
|
.update({ stale })
|
||||||
|
.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,7 +9,7 @@ import {
|
|||||||
apiTokensSchema,
|
apiTokensSchema,
|
||||||
applicationSchema,
|
applicationSchema,
|
||||||
applicationsSchema,
|
applicationsSchema,
|
||||||
archiveFeaturesSchema,
|
batchFeaturesSchema,
|
||||||
changePasswordSchema,
|
changePasswordSchema,
|
||||||
clientApplicationSchema,
|
clientApplicationSchema,
|
||||||
clientFeatureSchema,
|
clientFeatureSchema,
|
||||||
@ -144,6 +144,7 @@ import { bulkRegistrationSchema } from './spec/bulk-registration-schema';
|
|||||||
import { bulkMetricsSchema } from './spec/bulk-metrics-schema';
|
import { bulkMetricsSchema } from './spec/bulk-metrics-schema';
|
||||||
import { clientMetricsEnvSchema } from './spec/client-metrics-env-schema';
|
import { clientMetricsEnvSchema } from './spec/client-metrics-env-schema';
|
||||||
import { updateTagsSchema } from './spec/update-tags-schema';
|
import { updateTagsSchema } from './spec/update-tags-schema';
|
||||||
|
import { batchStaleSchema } from './spec/batch-stale-schema';
|
||||||
|
|
||||||
// All schemas in `openapi/spec` should be listed here.
|
// All schemas in `openapi/spec` should be listed here.
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
@ -156,7 +157,8 @@ export const schemas = {
|
|||||||
apiTokensSchema,
|
apiTokensSchema,
|
||||||
applicationSchema,
|
applicationSchema,
|
||||||
applicationsSchema,
|
applicationsSchema,
|
||||||
archiveFeaturesSchema,
|
batchFeaturesSchema,
|
||||||
|
batchStaleSchema,
|
||||||
bulkRegistrationSchema,
|
bulkRegistrationSchema,
|
||||||
bulkMetricsSchema,
|
bulkMetricsSchema,
|
||||||
changePasswordSchema,
|
changePasswordSchema,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { FromSchema } from 'json-schema-to-ts';
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
export const archiveFeaturesSchema = {
|
export const batchFeaturesSchema = {
|
||||||
$id: '#/components/schemas/archiveFeaturesSchema',
|
$id: '#/components/schemas/batchFeaturesSchema',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['features'],
|
required: ['features'],
|
||||||
properties: {
|
properties: {
|
||||||
@ -17,4 +17,4 @@ export const archiveFeaturesSchema = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ArchiveFeaturesSchema = FromSchema<typeof archiveFeaturesSchema>;
|
export type BatchFeaturesSchema = FromSchema<typeof batchFeaturesSchema>;
|
23
src/lib/openapi/spec/batch-stale-schema.ts
Normal file
23
src/lib/openapi/spec/batch-stale-schema.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const batchStaleSchema = {
|
||||||
|
$id: '#/components/schemas/batchStaleSchema',
|
||||||
|
type: 'object',
|
||||||
|
required: ['features', 'stale'],
|
||||||
|
properties: {
|
||||||
|
features: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stale: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BatchStaleSchema = FromSchema<typeof batchStaleSchema>;
|
@ -131,4 +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';
|
export * from './batch-features-schema';
|
||||||
|
@ -20,7 +20,7 @@ import { extractUsername } from '../../../util';
|
|||||||
import { IAuthRequest } from '../../unleash-types';
|
import { IAuthRequest } from '../../unleash-types';
|
||||||
import {
|
import {
|
||||||
AdminFeaturesQuerySchema,
|
AdminFeaturesQuerySchema,
|
||||||
ArchiveFeaturesSchema,
|
BatchFeaturesSchema,
|
||||||
CreateFeatureSchema,
|
CreateFeatureSchema,
|
||||||
CreateFeatureStrategySchema,
|
CreateFeatureStrategySchema,
|
||||||
createRequestSchema,
|
createRequestSchema,
|
||||||
@ -46,6 +46,7 @@ import {
|
|||||||
} from '../../../services';
|
} from '../../../services';
|
||||||
import { querySchema } from '../../../schema/feature-schema';
|
import { querySchema } from '../../../schema/feature-schema';
|
||||||
import NotFoundError from '../../../error/notfound-error';
|
import NotFoundError from '../../../error/notfound-error';
|
||||||
|
import { BatchStaleSchema } from '../../../openapi/spec/batch-stale-schema';
|
||||||
|
|
||||||
interface FeatureStrategyParams {
|
interface FeatureStrategyParams {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -76,6 +77,7 @@ export interface IFeatureProjectUserParams extends ProjectParam {
|
|||||||
|
|
||||||
const PATH = '/:projectId/features';
|
const PATH = '/:projectId/features';
|
||||||
const PATH_ARCHIVE = '/:projectId/archive';
|
const PATH_ARCHIVE = '/:projectId/archive';
|
||||||
|
const PATH_STALE = '/:projectId/stale';
|
||||||
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`;
|
||||||
@ -418,8 +420,24 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
operationId: 'archiveFeatures',
|
operationId: 'archiveFeatures',
|
||||||
description:
|
description:
|
||||||
'This endpoint archives the specified features.',
|
'This endpoint archives the specified features.',
|
||||||
summary: 'Archive a list of features',
|
summary: 'Archives a list of features',
|
||||||
requestBody: createRequestSchema('archiveFeaturesSchema'),
|
requestBody: createRequestSchema('batchFeaturesSchema'),
|
||||||
|
responses: { 202: emptyResponse },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
this.route({
|
||||||
|
method: 'post',
|
||||||
|
path: PATH_STALE,
|
||||||
|
handler: this.staleFeatures,
|
||||||
|
permission: UPDATE_FEATURE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Features'],
|
||||||
|
operationId: 'staleFeatures',
|
||||||
|
description: 'This endpoint stales the specified features.',
|
||||||
|
summary: 'Stales a list of features',
|
||||||
|
requestBody: createRequestSchema('batchStaleSchema'),
|
||||||
responses: { 202: emptyResponse },
|
responses: { 202: emptyResponse },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@ -603,7 +621,7 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async archiveFeatures(
|
async archiveFeatures(
|
||||||
req: IAuthRequest<{ projectId: string }, void, ArchiveFeaturesSchema>,
|
req: IAuthRequest<{ projectId: string }, void, BatchFeaturesSchema>,
|
||||||
res: Response,
|
res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.flagResolver.isEnabled('bulkOperations')) {
|
if (!this.flagResolver.isEnabled('bulkOperations')) {
|
||||||
@ -618,6 +636,27 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
res.status(202).end();
|
res.status(202).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async staleFeatures(
|
||||||
|
req: IAuthRequest<{ projectId: string }, void, BatchStaleSchema>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.flagResolver.isEnabled('bulkOperations')) {
|
||||||
|
throw new NotFoundError('Bulk operations are not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { features, stale } = req.body;
|
||||||
|
const { projectId } = req.params;
|
||||||
|
const userName = extractUsername(req);
|
||||||
|
|
||||||
|
await this.featureService.setToggleStaleness(
|
||||||
|
features,
|
||||||
|
stale,
|
||||||
|
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>,
|
||||||
|
@ -1101,6 +1101,37 @@ class FeatureToggleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setToggleStaleness(
|
||||||
|
featureNames: string[],
|
||||||
|
stale: boolean,
|
||||||
|
createdBy: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.validateFeaturesContext(featureNames, projectId);
|
||||||
|
|
||||||
|
const features = await this.featureToggleStore.getAllByNames(
|
||||||
|
featureNames,
|
||||||
|
);
|
||||||
|
const relevantFeatures = features.filter(
|
||||||
|
(feature) => feature.stale !== stale,
|
||||||
|
);
|
||||||
|
await this.featureToggleStore.batchStale(
|
||||||
|
relevantFeatures.map((feature) => feature.name),
|
||||||
|
stale,
|
||||||
|
);
|
||||||
|
await this.eventStore.batchStore(
|
||||||
|
relevantFeatures.map(
|
||||||
|
(feature) =>
|
||||||
|
new FeatureStaleEvent({
|
||||||
|
stale: stale,
|
||||||
|
project: projectId,
|
||||||
|
featureName: feature.name,
|
||||||
|
createdBy,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async updateEnabled(
|
async updateEnabled(
|
||||||
project: string,
|
project: string,
|
||||||
featureName: string,
|
featureName: string,
|
||||||
|
@ -163,7 +163,7 @@ export class FeatureStaleEvent extends BaseEvent {
|
|||||||
project: string;
|
project: string;
|
||||||
featureName: string;
|
featureName: string;
|
||||||
createdBy: string | IUser;
|
createdBy: string | IUser;
|
||||||
tags: ITag[];
|
tags?: ITag[];
|
||||||
}) {
|
}) {
|
||||||
super(
|
super(
|
||||||
p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF,
|
p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF,
|
||||||
|
@ -16,6 +16,10 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
|||||||
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[]>;
|
batchArchive(featureNames: string[]): Promise<FeatureToggle[]>;
|
||||||
|
batchStale(
|
||||||
|
featureNames: string[],
|
||||||
|
stale: boolean,
|
||||||
|
): 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[]>;
|
||||||
|
@ -2845,3 +2845,26 @@ test('Should be able to bulk archive features', async () => {
|
|||||||
features: [{}, { name: featureName1 }, { name: featureName2 }],
|
features: [{}, { name: featureName1 }, { name: featureName2 }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Should batch stale features', async () => {
|
||||||
|
const staledFeatureName1 = 'staledFeature1';
|
||||||
|
const staledFeatureName2 = 'staledFeature2';
|
||||||
|
|
||||||
|
await createFeatureToggle(staledFeatureName1);
|
||||||
|
await createFeatureToggle(staledFeatureName2);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.post(`/api/admin/projects/${DEFAULT_PROJECT}/stale`)
|
||||||
|
.send({
|
||||||
|
features: [staledFeatureName1, staledFeatureName2],
|
||||||
|
stale: true,
|
||||||
|
})
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get(
|
||||||
|
`/api/admin/projects/${DEFAULT_PROJECT}/features/${staledFeatureName1}`,
|
||||||
|
)
|
||||||
|
.expect(200);
|
||||||
|
expect(body.stale).toBeTruthy();
|
||||||
|
});
|
||||||
|
@ -328,7 +328,7 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
"archiveFeaturesSchema": {
|
"batchFeaturesSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"features": {
|
"features": {
|
||||||
"items": {
|
"items": {
|
||||||
@ -342,6 +342,24 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"batchStaleSchema": {
|
||||||
|
"properties": {
|
||||||
|
"features": {
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
"stale": {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"features",
|
||||||
|
"stale",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
"bulkMetricsSchema": {
|
"bulkMetricsSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"applications": {
|
"applications": {
|
||||||
@ -5998,11 +6016,11 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/archiveFeaturesSchema",
|
"$ref": "#/components/schemas/batchFeaturesSchema",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"description": "archiveFeaturesSchema",
|
"description": "batchFeaturesSchema",
|
||||||
"required": true,
|
"required": true,
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -6010,7 +6028,7 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
"description": "This response has no body.",
|
"description": "This response has no body.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"summary": "Archive a list of features",
|
"summary": "Archives a list of features",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Features",
|
"Features",
|
||||||
],
|
],
|
||||||
@ -7396,6 +7414,42 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/admin/projects/{projectId}/stale": {
|
||||||
|
"post": {
|
||||||
|
"description": "This endpoint stales the specified features.",
|
||||||
|
"operationId": "staleFeatures",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "projectId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/batchStaleSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "batchStaleSchema",
|
||||||
|
"required": true,
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "This response has no body.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"summary": "Stales a list of features",
|
||||||
|
"tags": [
|
||||||
|
"Features",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/admin/projects/{projectId}/stickiness": {
|
"/api/admin/projects/{projectId}/stickiness": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getProjectDefaultStickiness",
|
"operationId": "getProjectDefaultStickiness",
|
||||||
|
13
src/test/fixtures/fake-feature-toggle-store.ts
vendored
13
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -34,6 +34,19 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return features;
|
return features;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchStale(
|
||||||
|
featureNames: string[],
|
||||||
|
stale: boolean,
|
||||||
|
): Promise<FeatureToggle[]> {
|
||||||
|
const features = this.features.filter((feature) =>
|
||||||
|
featureNames.includes(feature.name),
|
||||||
|
);
|
||||||
|
for (const feature of features) {
|
||||||
|
feature.stale = stale;
|
||||||
|
}
|
||||||
|
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