diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index 4ee0848bdd..17417f9ba8 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -414,9 +414,25 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { projectId, archived, userId, + tag, + namePrefix, }: IFeatureProjectUserParams): Promise { - let query = this.db('features') - .where({ project: projectId }) + let query = this.db('features').where({ project: projectId }); + if (tag) { + const tagQuery = this.db + .from('feature_tag') + .select('feature_name') + .whereIn(['tag_type', 'tag_value'], tag); + query = query.whereIn('features.name', tagQuery); + } + if (namePrefix && namePrefix.trim()) { + let namePrefixQuery = namePrefix; + if (!namePrefix.endsWith('%')) { + namePrefixQuery = namePrefixQuery + '%'; + } + query = query.whereILike('features.name', namePrefixQuery); + } + query = query .modify(FeatureToggleStore.filterByArchived, archived) .leftJoin( 'feature_environments', @@ -461,7 +477,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { } query = query.select(selectColumns); - const rows = await query; if (rows.length > 0) { diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 1529843cd5..05b69c5cd8 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -1,5 +1,6 @@ import { OpenAPIV3 } from 'openapi-types'; import { + adminFeaturesQuerySchema, addonParameterSchema, addonSchema, addonsSchema, @@ -131,6 +132,7 @@ import apiVersion from '../util/version'; // All schemas in `openapi/spec` should be listed here. export const schemas = { + adminFeaturesQuerySchema, addonParameterSchema, addonSchema, addonsSchema, diff --git a/src/lib/openapi/spec/admin-features-query-schema.test.ts b/src/lib/openapi/spec/admin-features-query-schema.test.ts new file mode 100644 index 0000000000..c02736cffb --- /dev/null +++ b/src/lib/openapi/spec/admin-features-query-schema.test.ts @@ -0,0 +1,31 @@ +import { validateSchema } from '../validate'; +import { AdminFeaturesQuerySchema } from './admin-features-query-schema'; + +test('adminFeaturesQuerySchema empty', () => { + const data: AdminFeaturesQuerySchema = {}; + + expect( + validateSchema('#/components/schemas/adminFeaturesQuerySchema', data), + ).toBeUndefined(); +}); + +test('adminFeatureQuerySchema all fields', () => { + const data: AdminFeaturesQuerySchema = { + tag: ['simple:some-tag', 'simple:some-other-tag'], + namePrefix: 'some-prefix', + }; + + expect( + validateSchema('#/components/schemas/adminFeaturesQuerySchema', data), + ).toBeUndefined(); +}); + +test('pattern validation should deny invalid tags', () => { + const data: AdminFeaturesQuerySchema = { + tag: ['something', 'somethingelse'], + }; + + expect( + validateSchema('#/components/schemas/adminFeaturesQuerySchema', data), + ).toBeDefined(); +}); diff --git a/src/lib/openapi/spec/admin-features-query-schema.ts b/src/lib/openapi/spec/admin-features-query-schema.ts new file mode 100644 index 0000000000..c51fdbf727 --- /dev/null +++ b/src/lib/openapi/spec/admin-features-query-schema.ts @@ -0,0 +1,30 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const adminFeaturesQuerySchema = { + $id: '#/components/schemas/adminFeaturesQuerySchema', + type: 'object', + additionalProperties: false, + properties: { + tag: { + type: 'array', + items: { + type: 'string', + pattern: '\\w+:\\w+', + }, + description: + 'Used to filter by tags. For each entry, a TAGTYPE:TAGVALUE is expected', + example: ['simple:mytag'], + }, + namePrefix: { + type: 'string', + description: + 'A case-insensitive prefix filter for the names of feature toggles', + example: 'demo.part1', + }, + }, + components: {}, +} as const; + +export type AdminFeaturesQuerySchema = FromSchema< + typeof adminFeaturesQuerySchema +>; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 0c8e50e660..ef4ab71ea9 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -111,6 +111,7 @@ export * from './public-signup-tokens-schema'; export * from './upsert-context-field-schema'; export * from './validate-edge-tokens-schema'; export * from './client-features-query-schema'; +export * from './admin-features-query-schema'; export * from './playground-constraint-schema'; export * from './create-feature-strategy-schema'; export * from './set-strategy-sort-order-schema'; diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/features.ts index a4e9d3a0d1..c7678a9968 100644 --- a/src/lib/routes/admin-api/project/features.ts +++ b/src/lib/routes/admin-api/project/features.ts @@ -43,6 +43,8 @@ import { getStandardResponses, } from '../../../openapi/util/standard-responses'; import { SegmentService } from '../../../services/segment-service'; +import { querySchema } from '../../../schema/feature-schema'; +import { AdminFeaturesQuerySchema } from '../../../openapi'; interface FeatureStrategyParams { projectId: string; @@ -66,6 +68,9 @@ interface StrategyIdParams extends FeatureStrategyParams { export interface IFeatureProjectUserParams extends ProjectParam { archived?: boolean; userId?: number; + + tag?: string[][]; + namePrefix?: string; } const PATH = '/:projectId/features'; @@ -399,13 +404,12 @@ export default class ProjectFeaturesController extends Controller { } async getFeatures( - req: IAuthRequest, + req: IAuthRequest, res: Response, ): Promise { const { projectId } = req.params; - const features = await this.featureService.getFeatureOverview({ - projectId, - }); + const query = await this.prepQuery(req.query, projectId); + const features = await this.featureService.getFeatureOverview(query); this.openApiService.respondWithValidation( 200, res, @@ -414,6 +418,33 @@ export default class ProjectFeaturesController extends Controller { ); } + async prepQuery( + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + { tag, namePrefix }: AdminFeaturesQuerySchema, + projectId: string, + ): Promise { + if (!tag && !namePrefix) { + return { projectId }; + } + const tagQuery = this.paramToArray(tag); + const query = await querySchema.validateAsync({ + tag: tagQuery, + namePrefix, + }); + if (query.tag) { + query.tag = query.tag.map((q) => q.split(':')); + } + return { projectId, ...query }; + } + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + paramToArray(param: any): Array { + if (!param) { + return param; + } + return Array.isArray(param) ? param : [param]; + } + async cloneFeature( req: IAuthRequest< FeatureParams, diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts index 15c352fbec..59d14427d6 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -2710,3 +2710,102 @@ test('should add multiple segments to a strategy', async () => { ]); }); }); + +test('Can filter based on tags', async () => { + const tag = { type: 'simple', value: 'hello-tags' }; + await db.stores.tagStore.createTag(tag); + await db.stores.featureToggleStore.create('default', { + name: 'to-be-tagged', + }); + await db.stores.featureToggleStore.create('default', { + name: 'not-tagged', + }); + await db.stores.featureTagStore.tagFeature('to-be-tagged', tag); + await app.request + .get('/api/admin/projects/default/features?tag=simple:hello-tags') + .expect((res) => { + expect(res.body.features).toHaveLength(1); + }); +}); + +test('Can query for features with namePrefix', async () => { + await db.stores.featureToggleStore.create('default', { + name: 'nameprefix-to-be-hit', + }); + await db.stores.featureToggleStore.create('default', { + name: 'nameprefix-not-be-hit', + }); + await app.request + .get('/api/admin/projects/default/features?namePrefix=nameprefix-to') + .expect((res) => { + expect(res.body.features).toHaveLength(1); + }); +}); + +test('Can query for features with namePrefix and tags', async () => { + const tag = { type: 'simple', value: 'hello-nameprefix-tags' }; + await db.stores.tagStore.createTag(tag); + await db.stores.featureToggleStore.create('default', { + name: 'to-be-tagged-nameprefix-and-tags', + }); + await db.stores.featureToggleStore.create('default', { + name: 'not-tagged-nameprefix-and-tags', + }); + await db.stores.featureToggleStore.create('default', { + name: 'tagged-but-not-hit-nameprefix-and-tags', + }); + await db.stores.featureTagStore.tagFeature( + 'to-be-tagged-nameprefix-and-tags', + tag, + ); + await db.stores.featureTagStore.tagFeature( + 'tagged-but-not-hit-nameprefix-and-tags', + tag, + ); + await app.request + .get( + '/api/admin/projects/default/features?namePrefix=to&tag=simple:hello-nameprefix-tags', + ) + .expect((res) => { + expect(res.body.features).toHaveLength(1); + }); +}); + +test('Can query for two tags at the same time. Tags are ORed together', async () => { + const tag = { type: 'simple', value: 'twotags-first-tag' }; + const secondTag = { type: 'simple', value: 'twotags-second-tag' }; + await db.stores.tagStore.createTag(tag); + await db.stores.tagStore.createTag(secondTag); + const taggedWithFirst = await db.stores.featureToggleStore.create( + 'default', + { + name: 'tagged-with-first-tag', + }, + ); + const taggedWithSecond = await db.stores.featureToggleStore.create( + 'default', + { + name: 'tagged-with-second-tag', + }, + ); + const taggedWithBoth = await db.stores.featureToggleStore.create( + 'default', + { + name: 'tagged-with-both-tags', + }, + ); + await db.stores.featureTagStore.tagFeature(taggedWithFirst.name, tag); + await db.stores.featureTagStore.tagFeature( + taggedWithSecond.name, + secondTag, + ); + await db.stores.featureTagStore.tagFeature(taggedWithBoth.name, tag); + await db.stores.featureTagStore.tagFeature(taggedWithBoth.name, secondTag); + await app.request + .get( + `/api/admin/projects/default/features?tag=${tag.type}:${tag.value}&tag=${secondTag.type}:${secondTag.value}`, + ) + .expect((res) => { + expect(res.body.features).toHaveLength(3); + }); +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index a5ce6781f8..cb33cb1875 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -189,6 +189,28 @@ exports[`should serve the OpenAPI spec 1`] = ` ], "type": "object", }, + "adminFeaturesQuerySchema": { + "additionalProperties": false, + "properties": { + "namePrefix": { + "description": "A case-insensitive prefix filter for the names of feature toggles", + "example": "demo.part1", + "type": "string", + }, + "tag": { + "description": "Used to filter by tags. For each entry, a TAGTYPE:TAGVALUE is expected", + "example": [ + "simple:mytag", + ], + "items": { + "pattern": "\\w+:\\w+", + "type": "string", + }, + "type": "array", + }, + }, + "type": "object", + }, "apiTokenSchema": { "additionalProperties": false, "properties": { diff --git a/src/test/e2e/stores/feature-strategies-store.e2e.test.ts b/src/test/e2e/stores/feature-strategies-store.e2e.test.ts index a7be8af2ce..af21e48eca 100644 --- a/src/test/e2e/stores/feature-strategies-store.e2e.test.ts +++ b/src/test/e2e/stores/feature-strategies-store.e2e.test.ts @@ -70,3 +70,58 @@ test('Can successfully update project for all strategies belonging to feature', ); return expect(oldProjectStrats).toHaveLength(0); }); + +test('Can query for features with tags', async () => { + const tag = { type: 'simple', value: 'hello-tags' }; + await stores.tagStore.createTag(tag); + await featureToggleStore.create('default', { name: 'to-be-tagged' }); + await featureToggleStore.create('default', { name: 'not-tagged' }); + await stores.featureTagStore.tagFeature('to-be-tagged', tag); + const features = await featureStrategiesStore.getFeatureOverview({ + projectId: 'default', + tag: [[tag.type, tag.value]], + }); + expect(features).toHaveLength(1); +}); + +test('Can query for features with namePrefix', async () => { + await featureToggleStore.create('default', { + name: 'nameprefix-to-be-hit', + }); + await featureToggleStore.create('default', { + name: 'nameprefix-not-be-hit', + }); + const features = await featureStrategiesStore.getFeatureOverview({ + projectId: 'default', + namePrefix: 'nameprefix-to', + }); + expect(features).toHaveLength(1); +}); + +test('Can query for features with namePrefix and tags', async () => { + const tag = { type: 'simple', value: 'hello-nameprefix-and-tags' }; + await stores.tagStore.createTag(tag); + await featureToggleStore.create('default', { + name: 'to-be-tagged-nameprefix-and-tags', + }); + await featureToggleStore.create('default', { + name: 'not-tagged-nameprefix-and-tags', + }); + await featureToggleStore.create('default', { + name: 'tagged-but-not-hit-nameprefix-and-tags', + }); + await stores.featureTagStore.tagFeature( + 'to-be-tagged-nameprefix-and-tags', + tag, + ); + await stores.featureTagStore.tagFeature( + 'tagged-but-not-hit-nameprefix-and-tags', + tag, + ); + const features = await featureStrategiesStore.getFeatureOverview({ + projectId: 'default', + tag: [[tag.type, tag.value]], + namePrefix: 'to', + }); + expect(features).toHaveLength(1); +});