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