1
0
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:
Mateusz Kwasniewski 2023-10-31 14:10:31 +01:00 committed by GitHub
parent b1ea2c3b88
commit e5bbe5829f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 132 additions and 57 deletions

View File

@ -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,17 +93,21 @@ 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;
query, const { features, nextCursor } =
projectId, await this.featureSearchService.search({
type, query,
userId, projectId,
tag: normalizedTag, type,
status: normalizedStatus, userId,
cursor, tag: normalizedTag,
limit: normalizedLimit, status: normalizedStatus,
}); cursor,
limit: normalizedLimit,
});
res.header('Link', nextLink(req, nextCursor));
res.json({ features }); res.json({ features });
} else { } else {
throw new InvalidOperationError( throw new InvalidOperationError(

View File

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

View File

@ -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 } =
query: 'feature', await searchFeaturesWithCursor({
cursor: '', query: 'feature',
limit: 2, cursor: '',
}); 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 () => {

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

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

View File

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

View File

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