mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-04 00:18:01 +01:00
feat: Cursor based hateoas (#5230)
This commit is contained in:
parent
b1ea2c3b88
commit
e5bbe5829f
@ -1,4 +1,4 @@
|
|||||||
import { Response } from 'express';
|
import { Response, Request } from 'express';
|
||||||
import Controller from '../../routes/controller';
|
import Controller from '../../routes/controller';
|
||||||
import { FeatureSearchService, OpenApiService } from '../../services';
|
import { FeatureSearchService, OpenApiService } from '../../services';
|
||||||
import {
|
import {
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
FeatureSearchQueryParameters,
|
FeatureSearchQueryParameters,
|
||||||
featureSearchQueryParameters,
|
featureSearchQueryParameters,
|
||||||
} from '../../openapi/spec/feature-search-query-parameters';
|
} from '../../openapi/spec/feature-search-query-parameters';
|
||||||
|
import { nextLink } from './next-link';
|
||||||
|
|
||||||
const PATH = '/features';
|
const PATH = '/features';
|
||||||
|
|
||||||
@ -79,7 +80,7 @@ export default class FeatureSearchController extends Controller {
|
|||||||
tag,
|
tag,
|
||||||
status,
|
status,
|
||||||
cursor,
|
cursor,
|
||||||
limit = 50,
|
limit = '50',
|
||||||
} = req.query;
|
} = req.query;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const normalizedTag = tag
|
const normalizedTag = tag
|
||||||
@ -92,8 +93,10 @@ export default class FeatureSearchController extends Controller {
|
|||||||
tag.length === 2 &&
|
tag.length === 2 &&
|
||||||
['enabled', 'disabled'].includes(tag[1]),
|
['enabled', 'disabled'].includes(tag[1]),
|
||||||
);
|
);
|
||||||
const normalizedLimit = limit > 0 && limit <= 50 ? limit : 50;
|
const normalizedLimit =
|
||||||
const features = await this.featureSearchService.search({
|
Number(limit) > 0 && Number(limit) <= 50 ? Number(limit) : 50;
|
||||||
|
const { features, nextCursor } =
|
||||||
|
await this.featureSearchService.search({
|
||||||
query,
|
query,
|
||||||
projectId,
|
projectId,
|
||||||
type,
|
type,
|
||||||
@ -103,6 +106,8 @@ export default class FeatureSearchController extends Controller {
|
|||||||
cursor,
|
cursor,
|
||||||
limit: normalizedLimit,
|
limit: normalizedLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
res.header('Link', nextLink(req, nextCursor));
|
||||||
res.json({ features });
|
res.json({ features });
|
||||||
} else {
|
} else {
|
||||||
throw new InvalidOperationError(
|
throw new InvalidOperationError(
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
IFeatureStrategiesStore,
|
IFeatureStrategiesStore,
|
||||||
IUnleashConfig,
|
IUnleashConfig,
|
||||||
IUnleashStores,
|
IUnleashStores,
|
||||||
|
serializeDates,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { IFeatureSearchParams } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
import { IFeatureSearchParams } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||||
|
|
||||||
@ -20,10 +21,24 @@ export class FeatureSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async search(params: IFeatureSearchParams) {
|
async search(params: IFeatureSearchParams) {
|
||||||
const features = await this.featureStrategiesStore.searchFeatures(
|
// fetch one more item than needed to get a cursor of the next item
|
||||||
params,
|
const features = await this.featureStrategiesStore.searchFeatures({
|
||||||
);
|
...params,
|
||||||
|
limit: params.limit + 1,
|
||||||
|
});
|
||||||
|
|
||||||
return features;
|
const nextCursor =
|
||||||
|
features.length > params.limit
|
||||||
|
? features[features.length - 1].createdAt.toJSON()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// do not return the items with the next cursor
|
||||||
|
return {
|
||||||
|
features:
|
||||||
|
features.length > params.limit
|
||||||
|
? features.slice(0, -1)
|
||||||
|
: features,
|
||||||
|
nextCursor,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,7 +48,7 @@ const searchFeaturesWithCursor = async (
|
|||||||
query = '',
|
query = '',
|
||||||
projectId = 'default',
|
projectId = 'default',
|
||||||
cursor = '',
|
cursor = '',
|
||||||
limit = 10,
|
limit = '10',
|
||||||
}: FeatureSearchQueryParameters,
|
}: FeatureSearchQueryParameters,
|
||||||
expectedCode = 200,
|
expectedCode = 200,
|
||||||
) => {
|
) => {
|
||||||
@ -59,6 +59,10 @@ const searchFeaturesWithCursor = async (
|
|||||||
.expect(expectedCode);
|
.expect(expectedCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPage = async (url: string, expectedCode = 200) => {
|
||||||
|
return app.request.get(url).expect(expectedCode);
|
||||||
|
};
|
||||||
|
|
||||||
const filterFeaturesByType = async (types: string[], expectedCode = 200) => {
|
const filterFeaturesByType = async (types: string[], expectedCode = 200) => {
|
||||||
const typeParams = types.map((type) => `type[]=${type}`).join('&');
|
const typeParams = types.map((type) => `type[]=${type}`).join('&');
|
||||||
return app.request
|
return app.request
|
||||||
@ -107,38 +111,26 @@ test('should paginate with cursor', async () => {
|
|||||||
await app.createFeature('my_feature_c');
|
await app.createFeature('my_feature_c');
|
||||||
await app.createFeature('my_feature_d');
|
await app.createFeature('my_feature_d');
|
||||||
|
|
||||||
const { body: firstPage } = await searchFeaturesWithCursor({
|
const { body: firstPage, headers: firstHeaders } =
|
||||||
|
await searchFeaturesWithCursor({
|
||||||
query: 'feature',
|
query: 'feature',
|
||||||
cursor: '',
|
cursor: '',
|
||||||
limit: 2,
|
limit: '2',
|
||||||
});
|
});
|
||||||
const nextCursor =
|
|
||||||
firstPage.features[firstPage.features.length - 1].createdAt;
|
|
||||||
|
|
||||||
expect(firstPage).toMatchObject({
|
expect(firstPage).toMatchObject({
|
||||||
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
|
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { body: secondPage } = await searchFeaturesWithCursor({
|
const { body: secondPage, headers: secondHeaders } = await getPage(
|
||||||
query: 'feature',
|
firstHeaders.link,
|
||||||
cursor: nextCursor,
|
);
|
||||||
limit: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(secondPage).toMatchObject({
|
expect(secondPage).toMatchObject({
|
||||||
features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }],
|
features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }],
|
||||||
});
|
});
|
||||||
const lastCursor =
|
|
||||||
secondPage.features[secondPage.features.length - 1].createdAt;
|
|
||||||
|
|
||||||
const { body: lastPage } = await searchFeaturesWithCursor({
|
expect(secondHeaders.link).toBe('');
|
||||||
query: 'feature',
|
|
||||||
cursor: lastCursor,
|
|
||||||
limit: 2,
|
|
||||||
});
|
|
||||||
expect(lastPage).toMatchObject({
|
|
||||||
features: [],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should filter features by type', async () => {
|
test('should filter features by type', async () => {
|
||||||
|
55
src/lib/features/feature-search/next-link.test.ts
Normal file
55
src/lib/features/feature-search/next-link.test.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { nextLink } from './next-link';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
describe('nextLink', () => {
|
||||||
|
it('should generate the correct next link with a cursor', () => {
|
||||||
|
const req = {
|
||||||
|
baseUrl: '/api/events',
|
||||||
|
path: '/',
|
||||||
|
query: { page: '2', limit: '10' },
|
||||||
|
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
|
||||||
|
|
||||||
|
const cursor = 'abc123';
|
||||||
|
const result = nextLink(req, cursor);
|
||||||
|
|
||||||
|
expect(result).toBe('/api/events/?page=2&limit=10&cursor=abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate the correct next link without a cursor', () => {
|
||||||
|
const req = {
|
||||||
|
baseUrl: '/api/events',
|
||||||
|
path: '/',
|
||||||
|
query: { page: '2', limit: '10' },
|
||||||
|
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
|
||||||
|
|
||||||
|
const result = nextLink(req);
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude existing cursor from query parameters', () => {
|
||||||
|
const req = {
|
||||||
|
baseUrl: '/api/events',
|
||||||
|
path: '/',
|
||||||
|
query: { page: '2', limit: '10', cursor: 'oldCursor' },
|
||||||
|
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
|
||||||
|
|
||||||
|
const cursor = 'newCursor';
|
||||||
|
const result = nextLink(req, cursor);
|
||||||
|
|
||||||
|
expect(result).toBe('/api/events/?page=2&limit=10&cursor=newCursor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty query parameters correctly', () => {
|
||||||
|
const req = {
|
||||||
|
baseUrl: '/api/events',
|
||||||
|
path: '/',
|
||||||
|
query: {},
|
||||||
|
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
|
||||||
|
|
||||||
|
const cursor = 'abc123';
|
||||||
|
const result = nextLink(req, cursor);
|
||||||
|
|
||||||
|
expect(result).toBe('/api/events/?cursor=abc123');
|
||||||
|
});
|
||||||
|
});
|
18
src/lib/features/feature-search/next-link.ts
Normal file
18
src/lib/features/feature-search/next-link.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
export function nextLink(
|
||||||
|
req: Pick<Request, 'baseUrl' | 'path' | 'query'>,
|
||||||
|
cursor?: string,
|
||||||
|
): string {
|
||||||
|
if (!cursor) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${req.baseUrl}${req.path}?`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(req.query as Record<string, string>);
|
||||||
|
|
||||||
|
params.set('cursor', cursor);
|
||||||
|
|
||||||
|
return `${url}${params.toString()}`;
|
||||||
|
}
|
@ -533,7 +533,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
}: IFeatureSearchParams): Promise<IFeatureOverview[]> {
|
}: IFeatureSearchParams): Promise<IFeatureOverview[]> {
|
||||||
let query = this.db('features');
|
let query = this.db('features').limit(limit);
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
query = query.where({ project: projectId });
|
query = query.where({ project: projectId });
|
||||||
}
|
}
|
||||||
@ -582,20 +582,10 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// workaround for imprecise timestamp that was including the cursor itself
|
|
||||||
const addMillisecond = (cursor: string) =>
|
|
||||||
format(
|
|
||||||
addMilliseconds(parseISO(cursor), 1),
|
|
||||||
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
|
||||||
);
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
query = query.where(
|
query = query.where('features.created_at', '>=', cursor);
|
||||||
'features.created_at',
|
|
||||||
'>',
|
|
||||||
addMillisecond(cursor),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
query = query.orderBy('features.created_at', 'asc').limit(limit);
|
query = query.orderBy('features.created_at', 'asc');
|
||||||
|
|
||||||
query = query
|
query = query
|
||||||
.modify(FeatureToggleStore.filterByArchived, false)
|
.modify(FeatureToggleStore.filterByArchived, false)
|
||||||
|
@ -61,17 +61,17 @@ export const featureSearchQueryParameters = [
|
|||||||
name: 'cursor',
|
name: 'cursor',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
example: '1',
|
example: '2023-10-31T09:21:04.056Z',
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
'The last feature id the client has seen. Used for cursor-based pagination.',
|
'The next feature created at date the client has not seen. Used for cursor-based pagination. Empty if starting from the beginning.',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'limit',
|
name: 'limit',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'number',
|
type: 'string',
|
||||||
example: 10,
|
example: '10',
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
'The number of results to return in a page. By default it is set to 50',
|
'The number of results to return in a page. By default it is set to 50',
|
||||||
|
Loading…
Reference in New Issue
Block a user