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:
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