From bc3cf81e94b3c94bc6098b4688939b6c6c649a1c Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Thu, 14 Jul 2022 15:35:50 +0200 Subject: [PATCH] fix: metadata filter support for admin/features Co-authored-by: Fredrik Strand Oseberg --- src/lib/db/feature-toggle-store.ts | 60 ++++++++++++++++++-- src/lib/routes/admin-api/feature.ts | 6 +- src/lib/services/feature-toggle-service.ts | 3 +- src/lib/types/stores/feature-toggle-store.ts | 4 +- src/test/e2e/api/admin/feature.e2e.test.ts | 8 +-- src/test/e2e/api/client/segment.e2e.test.ts | 6 +- 6 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index 91db0006aa..cdd604e12c 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -86,16 +86,26 @@ export default class FeatureToggleStore implements IFeatureToggleStore { async getAll( query: { archived?: boolean; - project?: string; + project?: string | string[]; stale?: boolean; + tag?: string[][]; + namePrefix?: string; } = { archived: false }, ): Promise { - const { archived, ...rest } = query; - const rows = await this.db + const { archived, tag, project, namePrefix } = query; + let queryBuilder = this.db(TABLE) .select(FEATURE_COLUMNS) - .from(TABLE) - .where(rest) - .modify(FeatureToggleStore.filterByArchived, archived); + .modify(FeatureToggleStore.filterByArchived, archived) + .modify(FeatureToggleStore.filterByProject, project) + .modify(FeatureToggleStore.filterByNamePrefix, namePrefix); + if (tag) { + const tagQuery = this.db + .from('feature_tag') + .select('feature_name') + .whereIn(['tag_type', 'tag_value'], tag); + queryBuilder = queryBuilder.whereIn('features.name', tagQuery); + } + const rows = await queryBuilder; return rows.map(this.rowToFeature); } @@ -152,6 +162,44 @@ export default class FeatureToggleStore implements IFeatureToggleStore { : queryBuilder.whereNull('archived_at'); }; + static filterByTags: Knex.QueryCallbackWithArgs = ( + queryBuilder: Knex.QueryBuilder, + tag?: string[][], + ) => { + if (tag && tag.length > 0) { + const tagQuery = queryBuilder + .from('feature_tag') + .select('feature_name') + .whereIn(['tag_type', 'tag_value'], tag); + return queryBuilder.whereIn('feature.name', tagQuery); + } + return queryBuilder; + }; + + static filterByProject: Knex.QueryCallbackWithArgs = ( + queryBuilder: Knex.QueryBuilder, + project?: string | string[], + ) => { + if (project) { + if (Array.isArray(project) && project.length > 0) { + return queryBuilder.whereIn('features.project', project); + } else { + return queryBuilder.where({ project }); + } + } + return queryBuilder; + }; + + static filterByNamePrefix: Knex.QueryCallbackWithArgs = ( + queryBuilder: Knex.QueryBuilder, + namePrefix?: string, + ) => { + if (namePrefix) { + return queryBuilder.whereILike('name', `${namePrefix}%`); + } + return queryBuilder; + }; + rowToFeature(row: FeaturesTable): FeatureToggle { if (!row) { throw new NotFoundError('No feature toggle found'); diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index fcf4dcd5ca..be0ec0e9fe 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -177,7 +177,11 @@ class FeatureController extends Controller { req: Request, res: Response, ): Promise { - const features = await this.service.getMetadataForAllFeatures(false); + const query = await this.prepQuery(req.query); + const features = await this.service.getMetadataForAllFeatures( + false, + query, + ); this.openApiService.respondWithValidation( 200, res, diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index caaeabbfbd..98e48b892e 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -988,8 +988,9 @@ class FeatureToggleService { async getMetadataForAllFeatures( archived: boolean, + query?: Partial, ): Promise { - return this.featureToggleStore.getAll({ archived }); + return this.featureToggleStore.getAll({ ...query, archived }); } async getMetadataForAllFeaturesByProjectId( diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index 77e5626607..94e0c60037 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -3,8 +3,10 @@ import { Store } from './store'; export interface IFeatureToggleQuery { archived: boolean; - project: string; + project: string | string[]; + namePrefix?: string; stale: boolean; + tag?: string[][]; } export interface IFeatureToggleStore extends Store { diff --git a/src/test/e2e/api/admin/feature.e2e.test.ts b/src/test/e2e/api/admin/feature.e2e.test.ts index 814751df4c..51bd1eec88 100644 --- a/src/test/e2e/api/admin/feature.e2e.test.ts +++ b/src/test/e2e/api/admin/feature.e2e.test.ts @@ -180,8 +180,8 @@ afterAll(async () => { test('returns list of feature toggles', async () => app.request .get('/api/admin/features') - .expect('Content-Type', /json/) .expect(200) + .expect('Content-Type', /json/) .expect((res) => { expect(res.body.features).toHaveLength(4); })); @@ -628,8 +628,8 @@ test('Can get features tagged by tag', async () => { .expect(201); return app.request .get(`/api/admin/features?tag=${tag.type}:${tag.value}`) - .expect('Content-Type', /json/) .expect(200) + .expect('Content-Type', /json/) .expect((res) => { expect(res.body.features).toHaveLength(1); expect(res.body.features[0].name).toBe(feature1Name); @@ -665,8 +665,8 @@ test('Can query for multiple tags using OR', async () => { .get( `/api/admin/features?tag[]=${tag.type}:${tag.value}&tag[]=${tag2.type}:${tag2.value}`, ) - .expect('Content-Type', /json/) .expect(200) + .expect('Content-Type', /json/) .expect((res) => { expect(res.body.features).toHaveLength(2); expect(res.body.features.some((f) => f.name === feature1Name)).toBe( @@ -715,8 +715,8 @@ test('Querying with multiple filters ANDs the filters', async () => { .expect(201); await app.request .get(`/api/admin/features?tag=${tag.type}:${tag.value}`) - .expect('Content-Type', /json/) .expect(200) + .expect('Content-Type', /json/) .expect((res) => expect(res.body.features).toHaveLength(2)); await app.request .get(`/api/admin/features?namePrefix=test&tag=${tag.type}:${tag.value}`) diff --git a/src/test/e2e/api/client/segment.e2e.test.ts b/src/test/e2e/api/client/segment.e2e.test.ts index 25875bc71d..55064f81d0 100644 --- a/src/test/e2e/api/client/segment.e2e.test.ts +++ b/src/test/e2e/api/client/segment.e2e.test.ts @@ -26,10 +26,8 @@ const fetchSegments = (): Promise => { }; const fetchFeatures = (): Promise => { - return app.request - .get(FEATURES_ADMIN_BASE_PATH) - .expect(200) - .then((res) => res.body.features); + //@ts-expect-error + return app.services.featureToggleService.getFeatureToggles({}, false); }; const fetchClientFeatures = (): Promise => {