Skip to content

Commit

Permalink
feat: bulk stale features (#3311)
Browse files Browse the repository at this point in the history
  • Loading branch information
sjaanus committed Mar 15, 2023
1 parent 240bb7b commit 6c813ab
Show file tree
Hide file tree
Showing 12 changed files with 215 additions and 15 deletions.
11 changes: 11 additions & 0 deletions src/lib/db/feature-toggle-store.ts
Expand Up @@ -283,6 +283,17 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
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> {
await this.db(TABLE)
.where({ name }) // Feature toggle must be archived to allow deletion
Expand Down
6 changes: 4 additions & 2 deletions src/lib/openapi/index.ts
Expand Up @@ -9,7 +9,7 @@ import {
apiTokensSchema,
applicationSchema,
applicationsSchema,
archiveFeaturesSchema,
batchFeaturesSchema,
changePasswordSchema,
clientApplicationSchema,
clientFeatureSchema,
Expand Down Expand Up @@ -144,6 +144,7 @@ import { bulkRegistrationSchema } from './spec/bulk-registration-schema';
import { bulkMetricsSchema } from './spec/bulk-metrics-schema';
import { clientMetricsEnvSchema } from './spec/client-metrics-env-schema';
import { updateTagsSchema } from './spec/update-tags-schema';
import { batchStaleSchema } from './spec/batch-stale-schema';

// All schemas in `openapi/spec` should be listed here.
export const schemas = {
Expand All @@ -156,7 +157,8 @@ export const schemas = {
apiTokensSchema,
applicationSchema,
applicationsSchema,
archiveFeaturesSchema,
batchFeaturesSchema,
batchStaleSchema,
bulkRegistrationSchema,
bulkMetricsSchema,
changePasswordSchema,
Expand Down
@@ -1,7 +1,7 @@
import { FromSchema } from 'json-schema-to-ts';

export const archiveFeaturesSchema = {
$id: '#/components/schemas/archiveFeaturesSchema',
export const batchFeaturesSchema = {
$id: '#/components/schemas/batchFeaturesSchema',
type: 'object',
required: ['features'],
properties: {
Expand All @@ -17,4 +17,4 @@ export const archiveFeaturesSchema = {
},
} as const;

export type ArchiveFeaturesSchema = FromSchema<typeof archiveFeaturesSchema>;
export type BatchFeaturesSchema = FromSchema<typeof batchFeaturesSchema>;
23 changes: 23 additions & 0 deletions src/lib/openapi/spec/batch-stale-schema.ts
@@ -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>;
2 changes: 1 addition & 1 deletion src/lib/openapi/spec/index.ts
Expand Up @@ -131,4 +131,4 @@ export * from './import-toggles-validate-schema';
export * from './import-toggles-schema';
export * from './stickiness-schema';
export * from './tags-bulk-add-schema';
export * from './archive-features-schema';
export * from './batch-features-schema';
47 changes: 43 additions & 4 deletions src/lib/routes/admin-api/project/project-features.ts
Expand Up @@ -20,7 +20,7 @@ import { extractUsername } from '../../../util';
import { IAuthRequest } from '../../unleash-types';
import {
AdminFeaturesQuerySchema,
ArchiveFeaturesSchema,
BatchFeaturesSchema,
CreateFeatureSchema,
CreateFeatureStrategySchema,
createRequestSchema,
Expand All @@ -46,6 +46,7 @@ import {
} from '../../../services';
import { querySchema } from '../../../schema/feature-schema';
import NotFoundError from '../../../error/notfound-error';
import { BatchStaleSchema } from '../../../openapi/spec/batch-stale-schema';

interface FeatureStrategyParams {
projectId: string;
Expand Down Expand Up @@ -76,6 +77,7 @@ export interface IFeatureProjectUserParams extends ProjectParam {

const PATH = '/:projectId/features';
const PATH_ARCHIVE = '/:projectId/archive';
const PATH_STALE = '/:projectId/stale';
const PATH_FEATURE = `${PATH}/:featureName`;
const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`;
const PATH_ENV = `${PATH_FEATURE}/environments/:environment`;
Expand Down Expand Up @@ -418,8 +420,24 @@ export default class ProjectFeaturesController extends Controller {
operationId: 'archiveFeatures',
description:
'This endpoint archives the specified features.',
summary: 'Archive a list of features',
requestBody: createRequestSchema('archiveFeaturesSchema'),
summary: 'Archives a list of features',
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 },
}),
],
Expand Down Expand Up @@ -603,7 +621,7 @@ export default class ProjectFeaturesController extends Controller {
}

async archiveFeatures(
req: IAuthRequest<{ projectId: string }, void, ArchiveFeaturesSchema>,
req: IAuthRequest<{ projectId: string }, void, BatchFeaturesSchema>,
res: Response,
): Promise<void> {
if (!this.flagResolver.isEnabled('bulkOperations')) {
Expand All @@ -618,6 +636,27 @@ export default class ProjectFeaturesController extends Controller {
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(
req: Request<FeatureStrategyParams, any, any, any>,
res: Response<FeatureEnvironmentSchema>,
Expand Down
31 changes: 31 additions & 0 deletions src/lib/services/feature-toggle-service.ts
Expand Up @@ -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(
project: string,
featureName: string,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/types/events.ts
Expand Up @@ -163,7 +163,7 @@ export class FeatureStaleEvent extends BaseEvent {
project: string;
featureName: string;
createdBy: string | IUser;
tags: ITag[];
tags?: ITag[];
}) {
super(
p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/types/stores/feature-toggle-store.ts
Expand Up @@ -16,6 +16,10 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
archive(featureName: string): Promise<FeatureToggle>;
batchArchive(featureNames: string[]): Promise<FeatureToggle[]>;
batchStale(
featureNames: string[],
stale: boolean,
): Promise<FeatureToggle[]>;
revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
Expand Down
23 changes: 23 additions & 0 deletions src/test/e2e/api/admin/project/features.e2e.test.ts
Expand Up @@ -2845,3 +2845,26 @@ test('Should be able to bulk archive features', async () => {
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();
});
62 changes: 58 additions & 4 deletions src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
Expand Up @@ -328,7 +328,7 @@ exports[`should serve the OpenAPI spec 1`] = `
},
"type": "object",
},
"archiveFeaturesSchema": {
"batchFeaturesSchema": {
"properties": {
"features": {
"items": {
Expand All @@ -342,6 +342,24 @@ exports[`should serve the OpenAPI spec 1`] = `
],
"type": "object",
},
"batchStaleSchema": {
"properties": {
"features": {
"items": {
"type": "string",
},
"type": "array",
},
"stale": {
"type": "boolean",
},
},
"required": [
"features",
"stale",
],
"type": "object",
},
"bulkMetricsSchema": {
"properties": {
"applications": {
Expand Down Expand Up @@ -5998,19 +6016,19 @@ If the provided project does not exist, the list of events will be empty.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/archiveFeaturesSchema",
"$ref": "#/components/schemas/batchFeaturesSchema",
},
},
},
"description": "archiveFeaturesSchema",
"description": "batchFeaturesSchema",
"required": true,
},
"responses": {
"202": {
"description": "This response has no body.",
},
},
"summary": "Archive a list of features",
"summary": "Archives a list of features",
"tags": [
"Features",
],
Expand Down Expand Up @@ -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": {
"get": {
"operationId": "getProjectDefaultStickiness",
Expand Down
13 changes: 13 additions & 0 deletions src/test/fixtures/fake-feature-toggle-store.ts
Expand Up @@ -34,6 +34,19 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
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> {
return this.features.filter(this.getFilterQuery(query)).length;
}
Expand Down

0 comments on commit 6c813ab

Please sign in to comment.