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, |         projectId, | ||||||
|         archived, |         archived, | ||||||
|         userId, |         userId, | ||||||
|  |         tag, | ||||||
|  |         namePrefix, | ||||||
|     }: IFeatureProjectUserParams): Promise<IFeatureOverview[]> { |     }: IFeatureProjectUserParams): Promise<IFeatureOverview[]> { | ||||||
|         let query = this.db('features') |         let query = this.db('features').where({ project: projectId }); | ||||||
|             .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) |             .modify(FeatureToggleStore.filterByArchived, archived) | ||||||
|             .leftJoin( |             .leftJoin( | ||||||
|                 'feature_environments', |                 'feature_environments', | ||||||
| @ -461,7 +477,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         query = query.select(selectColumns); |         query = query.select(selectColumns); | ||||||
| 
 |  | ||||||
|         const rows = await query; |         const rows = await query; | ||||||
| 
 | 
 | ||||||
|         if (rows.length > 0) { |         if (rows.length > 0) { | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| import { OpenAPIV3 } from 'openapi-types'; | import { OpenAPIV3 } from 'openapi-types'; | ||||||
| import { | import { | ||||||
|  |     adminFeaturesQuerySchema, | ||||||
|     addonParameterSchema, |     addonParameterSchema, | ||||||
|     addonSchema, |     addonSchema, | ||||||
|     addonsSchema, |     addonsSchema, | ||||||
| @ -131,6 +132,7 @@ import apiVersion from '../util/version'; | |||||||
| 
 | 
 | ||||||
| // All schemas in `openapi/spec` should be listed here.
 | // All schemas in `openapi/spec` should be listed here.
 | ||||||
| export const schemas = { | export const schemas = { | ||||||
|  |     adminFeaturesQuerySchema, | ||||||
|     addonParameterSchema, |     addonParameterSchema, | ||||||
|     addonSchema, |     addonSchema, | ||||||
|     addonsSchema, |     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 './upsert-context-field-schema'; | ||||||
| export * from './validate-edge-tokens-schema'; | export * from './validate-edge-tokens-schema'; | ||||||
| export * from './client-features-query-schema'; | export * from './client-features-query-schema'; | ||||||
|  | export * from './admin-features-query-schema'; | ||||||
| export * from './playground-constraint-schema'; | export * from './playground-constraint-schema'; | ||||||
| export * from './create-feature-strategy-schema'; | export * from './create-feature-strategy-schema'; | ||||||
| export * from './set-strategy-sort-order-schema'; | export * from './set-strategy-sort-order-schema'; | ||||||
|  | |||||||
| @ -43,6 +43,8 @@ import { | |||||||
|     getStandardResponses, |     getStandardResponses, | ||||||
| } from '../../../openapi/util/standard-responses'; | } from '../../../openapi/util/standard-responses'; | ||||||
| import { SegmentService } from '../../../services/segment-service'; | import { SegmentService } from '../../../services/segment-service'; | ||||||
|  | import { querySchema } from '../../../schema/feature-schema'; | ||||||
|  | import { AdminFeaturesQuerySchema } from '../../../openapi'; | ||||||
| 
 | 
 | ||||||
| interface FeatureStrategyParams { | interface FeatureStrategyParams { | ||||||
|     projectId: string; |     projectId: string; | ||||||
| @ -66,6 +68,9 @@ interface StrategyIdParams extends FeatureStrategyParams { | |||||||
| export interface IFeatureProjectUserParams extends ProjectParam { | export interface IFeatureProjectUserParams extends ProjectParam { | ||||||
|     archived?: boolean; |     archived?: boolean; | ||||||
|     userId?: number; |     userId?: number; | ||||||
|  | 
 | ||||||
|  |     tag?: string[][]; | ||||||
|  |     namePrefix?: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const PATH = '/:projectId/features'; | const PATH = '/:projectId/features'; | ||||||
| @ -399,13 +404,12 @@ export default class ProjectFeaturesController extends Controller { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async getFeatures( |     async getFeatures( | ||||||
|         req: IAuthRequest<ProjectParam, any, any, any>, |         req: IAuthRequest<ProjectParam, any, any, AdminFeaturesQuerySchema>, | ||||||
|         res: Response<FeaturesSchema>, |         res: Response<FeaturesSchema>, | ||||||
|     ): Promise<void> { |     ): Promise<void> { | ||||||
|         const { projectId } = req.params; |         const { projectId } = req.params; | ||||||
|         const features = await this.featureService.getFeatureOverview({ |         const query = await this.prepQuery(req.query, projectId); | ||||||
|             projectId, |         const features = await this.featureService.getFeatureOverview(query); | ||||||
|         }); |  | ||||||
|         this.openApiService.respondWithValidation( |         this.openApiService.respondWithValidation( | ||||||
|             200, |             200, | ||||||
|             res, |             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( |     async cloneFeature( | ||||||
|         req: IAuthRequest< |         req: IAuthRequest< | ||||||
|             FeatureParams, |             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", |         "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": { |       "apiTokenSchema": { | ||||||
|         "additionalProperties": false, |         "additionalProperties": false, | ||||||
|         "properties": { |         "properties": { | ||||||
|  | |||||||
| @ -70,3 +70,58 @@ test('Can successfully update project for all strategies belonging to feature', | |||||||
|         ); |         ); | ||||||
|     return expect(oldProjectStrats).toHaveLength(0); |     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