1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +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:
Christopher Kolstad 2022-12-16 12:05:18 +01:00 committed by GitHub
parent 1d1219a055
commit eafba10cac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 293 additions and 7 deletions

View File

@ -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) {

View File

@ -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,

View 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();
});

View 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
>;

View File

@ -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';

View File

@ -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,

View File

@ -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);
});
});

View File

@ -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": {

View File

@ -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);
});