Skip to content

Commit

Permalink
feature: add query support to features endpoint (#2693)
Browse files Browse the repository at this point in the history
## 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>
  • Loading branch information
Christopher Kolstad and thomasheartman committed Dec 16, 2022
1 parent 1d1219a commit eafba10
Show file tree
Hide file tree
Showing 9 changed files with 293 additions and 7 deletions.
21 changes: 18 additions & 3 deletions src/lib/db/feature-strategy-store.ts
Expand Up @@ -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',
Expand Down Expand Up @@ -461,7 +477,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
}

query = query.select(selectColumns);

const rows = await query;

if (rows.length > 0) {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/openapi/index.ts
@@ -1,5 +1,6 @@
import { OpenAPIV3 } from 'openapi-types';
import {
adminFeaturesQuerySchema,
addonParameterSchema,
addonSchema,
addonsSchema,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/lib/openapi/spec/admin-features-query-schema.test.ts
@@ -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 changes: 30 additions & 0 deletions src/lib/openapi/spec/admin-features-query-schema.ts
@@ -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
>;
1 change: 1 addition & 0 deletions src/lib/openapi/spec/index.ts
Expand Up @@ -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';
Expand Down
39 changes: 35 additions & 4 deletions src/lib/routes/admin-api/project/features.ts
Expand Up @@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions src/test/e2e/api/admin/project/features.e2e.test.ts
Expand Up @@ -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);
});
});
22 changes: 22 additions & 0 deletions src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
Expand Up @@ -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": {
Expand Down
55 changes: 55 additions & 0 deletions src/test/e2e/stores/feature-strategies-store.e2e.test.ts
Expand Up @@ -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);
});

0 comments on commit eafba10

Please sign in to comment.