mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feature: add query support to features endpoint (#2693)
## About the changes The deprecated /api/admin/features endpoint supported querying with tag and namePrefix parameters. This PR adds this functionality to /api/admin/projects/<project>/features as well, allowing to replicate queries that used to work. Closes #2306 ### Important files src/lib/db/feature-strategy-store.ts src/test/e2e/stores/feature-strategies-store.e2e.test.ts ## Discussion points I'm extending our query parameters support for /api/admin/projects/<projectId>/features endpoint. This will be reflected in our open-api spec, so I also made an adminFeaturesQuerySchema for this. Also, very open for something similar to what we did for the modifyQuery for the archived parameter, but couldn't come up with a good way to support subselects using the query builder, it just ended up blowing the stack. If anyone has a suggestion, I'm all ears. Co-authored-by: Thomas Heartman <thomas@getunleash.ai>
This commit is contained in:
		
							parent
							
								
									1d1219a055
								
							
						
					
					
						commit
						eafba10cac
					
				| @ -414,9 +414,25 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { | ||||
|         projectId, | ||||
|         archived, | ||||
|         userId, | ||||
|         tag, | ||||
|         namePrefix, | ||||
|     }: IFeatureProjectUserParams): Promise<IFeatureOverview[]> { | ||||
|         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) { | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
							
								
								
									
										31
									
								
								src/lib/openapi/spec/admin-features-query-schema.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/lib/openapi/spec/admin-features-query-schema.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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(); | ||||
| }); | ||||
							
								
								
									
										30
									
								
								src/lib/openapi/spec/admin-features-query-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/lib/openapi/spec/admin-features-query-schema.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
| >; | ||||
| @ -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'; | ||||
|  | ||||
| @ -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<ProjectParam, any, any, any>, | ||||
|         req: IAuthRequest<ProjectParam, any, any, AdminFeaturesQuerySchema>, | ||||
|         res: Response<FeaturesSchema>, | ||||
|     ): Promise<void> { | ||||
|         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<IFeatureProjectUserParams> { | ||||
|         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<any> { | ||||
|         if (!param) { | ||||
|             return param; | ||||
|         } | ||||
|         return Array.isArray(param) ? param : [param]; | ||||
|     } | ||||
| 
 | ||||
|     async cloneFeature( | ||||
|         req: IAuthRequest< | ||||
|             FeatureParams, | ||||
|  | ||||
| @ -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); | ||||
|         }); | ||||
| }); | ||||
|  | ||||
| @ -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": { | ||||
|  | ||||
| @ -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); | ||||
| }); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user