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