1
0
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:
Jaanus Sellin 2023-03-15 08:37:06 +02:00 committed by GitHub
parent 240bb7b027
commit 6c813ab066
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 215 additions and 15 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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