0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

feat: add koa-pagination (#143)

* feat: add koa-pagination

* fix: pr fix
This commit is contained in:
Wang Sijie 2021-11-30 11:06:50 +08:00 committed by GitHub
parent acdbc5db56
commit 838ae3fad9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 281 additions and 15 deletions

View file

@ -44,6 +44,7 @@
"zod": "^3.8.1"
},
"devDependencies": {
"@shopify/jest-koa-mocks": "^3.0.8",
"@silverhand/eslint-config": "^0.2.2",
"@silverhand/ts-config": "^0.2.2",
"@types/jest": "^27.0.1",

View file

@ -0,0 +1,139 @@
import { createMockContext } from '@shopify/jest-koa-mocks';
import { Context } from 'koa';
import koaPagination, { WithPaginationContext } from './koa-pagination';
const next = jest.fn();
const setHeader = jest.fn();
const links = new Set<string>();
const appendHeader = jest.fn((key: string, value: string) => {
if (key === 'Link') {
links.add(value);
}
});
const createContext = (query: Record<string, string>): WithPaginationContext<Context> => {
const baseContext = createMockContext();
const context = {
...baseContext,
request: {
...baseContext.request,
origin: '',
path: '',
query,
},
pagination: { limit: 0, offset: 0 },
set: setHeader,
append: appendHeader,
};
return context;
};
afterEach(() => {
setHeader.mockClear();
appendHeader.mockClear();
links.clear();
});
describe('request', () => {
it('should get limit and offset from queries', async () => {
const context = createContext({ page: '1', page_size: '30' });
await koaPagination()(context, next);
expect(context.pagination.limit).toEqual(30);
expect(context.pagination.offset).toEqual(0);
});
it('should set default page to 1 (offset to 0) if non is provided', async () => {
const context = createContext({});
await koaPagination()(context, next);
expect(context.pagination.offset).toEqual(0);
});
it('should set default pageSize(limit) to 20', async () => {
const context = createContext({});
await koaPagination({ defaultPageSize: 20 })(context, next);
expect(context.pagination.limit).toEqual(20);
});
it('throw when page value is not number-like', async () => {
const context = createContext({ page: 'invalid_number' });
await expect(koaPagination()(context, next)).rejects.toThrow();
});
it('throw when page number is 0', async () => {
const context = createContext({ page: '0' });
await expect(koaPagination()(context, next)).rejects.toThrow();
});
it('throw when page_size value is not number-like', async () => {
const context = createContext({ page_size: 'invalid_number' });
await expect(koaPagination()(context, next)).rejects.toThrow();
});
it('throw when page_size number is 0', async () => {
const context = createContext({ page_size: '0' });
await expect(koaPagination()(context, next)).rejects.toThrow();
});
it('throw when page_size number exceeds max', async () => {
const context = createContext({ page_size: '200' });
await expect(koaPagination({ maxPageSize: 100 })(context, next)).rejects.toThrow();
});
});
describe('response', () => {
it('should add Total-Number to response header', async () => {
const context = createContext({});
await koaPagination()(context, async () => {
// eslint-disable-next-line @silverhand/fp/no-mutation
context.pagination.totalCount = 100;
});
expect(setHeader).toHaveBeenCalledWith('Total-Number', '100');
});
describe('Link in response header', () => {
it('should append `first` and `last` in 1 of 1', async () => {
const context = createContext({});
await koaPagination({ defaultPageSize: 20 })(context, async () => {
// eslint-disable-next-line @silverhand/fp/no-mutation
context.pagination.totalCount = 10;
});
expect(links.has('<?page=1>; rel="first"')).toBeTruthy();
expect(links.has('<?page=1>; rel="last"')).toBeTruthy();
});
it('should append `first`, `next`, `last` in 1 of 2', async () => {
const context = createContext({});
await koaPagination({ defaultPageSize: 20 })(context, async () => {
// eslint-disable-next-line @silverhand/fp/no-mutation
context.pagination.totalCount = 30;
});
expect(links.has('<?page=1>; rel="first"')).toBeTruthy();
expect(links.has('<?page=2>; rel="next"')).toBeTruthy();
expect(links.has('<?page=2>; rel="last"')).toBeTruthy();
});
it('should append `first`, `prev`, `last` in 2 of 2', async () => {
const context = createContext({ page: '2' });
await koaPagination({ defaultPageSize: 20 })(context, async () => {
// eslint-disable-next-line @silverhand/fp/no-mutation
context.pagination.totalCount = 30;
});
expect(links.has('<?page=1>; rel="first"')).toBeTruthy();
expect(links.has('<?page=1>; rel="prev"')).toBeTruthy();
expect(links.has('<?page=2>; rel="last"')).toBeTruthy();
});
it('should append `first`, `prev`, `next`, `last` in 2 of 3', async () => {
const context = createContext({ page: '2' });
await koaPagination({ defaultPageSize: 20 })(context, async () => {
// eslint-disable-next-line @silverhand/fp/no-mutation
context.pagination.totalCount = 50;
});
expect(links.has('<?page=1>; rel="first"')).toBeTruthy();
expect(links.has('<?page=1>; rel="prev"')).toBeTruthy();
expect(links.has('<?page=3>; rel="next"')).toBeTruthy();
expect(links.has('<?page=3>; rel="last"')).toBeTruthy();
});
});
});

View file

@ -0,0 +1,67 @@
import { MiddlewareType } from 'koa';
import { number } from 'zod';
import RequestError from '@/errors/RequestError';
import { buildLink } from '@/utils/pagination';
export interface Pagination {
offset: number;
limit: number;
totalCount?: number;
}
export type WithPaginationContext<ContextT> = ContextT & {
pagination: Pagination;
};
export interface PaginationConfig {
defaultPageSize?: number;
maxPageSize?: number;
}
export default function koaPagination<StateT, ContextT, ResponseBodyT>({
defaultPageSize = 20,
maxPageSize = 100,
}: PaginationConfig = {}): MiddlewareType<StateT, WithPaginationContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
try {
const {
request: {
query: { page, page_size },
},
} = ctx;
// Query values are all string, need to convert to number first.
const pageNumber = page ? number().positive().parse(Number(page)) : 1;
const pageSize = page_size
? number().positive().max(maxPageSize).parse(Number(page_size))
: defaultPageSize;
ctx.pagination = { offset: (pageNumber - 1) * pageSize, limit: pageSize };
} catch {
throw new RequestError({ code: 'guard.invalid_pagination', status: 400 });
}
await next();
// Only handle response when count value is set.
if (ctx.pagination.totalCount !== undefined) {
const { limit, offset, totalCount } = ctx.pagination;
const totalPage = Math.ceil(totalCount / limit) || 1; // Minimum page number is 1
// Our custom response header: Total-Number
ctx.set('Total-Number', String(totalCount));
// Response header's `Link`: https://datatracker.ietf.org/doc/html/rfc5988
const page = Math.floor(offset / limit) + 1; // Start from 1
ctx.append('Link', buildLink(ctx.request, 1, 'first'));
ctx.append('Link', buildLink(ctx.request, totalPage, 'last'));
if (page > 1) {
ctx.append('Link', buildLink(ctx.request, page - 1, 'prev'));
}
if (page < totalPage) {
ctx.append('Link', buildLink(ctx.request, page + 1, 'next'));
}
}
};
}

View file

@ -0,0 +1,29 @@
import { buildLink } from './pagination';
const request = {
origin: 'https://logto.dev',
path: '/users',
query: { order: 'desc', page: '3' },
};
describe('buildLink()', () => {
it('build a `first` link', () => {
const link = buildLink(request, 1, 'first');
expect(link).toEqual('<https://logto.dev/users?order=desc&page=1>; rel="first"');
});
it('build a `prev` link', () => {
const link = buildLink(request, 2, 'prev');
expect(link).toEqual('<https://logto.dev/users?order=desc&page=2>; rel="prev"');
});
it('build a `next` link', () => {
const link = buildLink(request, 4, 'next');
expect(link).toEqual('<https://logto.dev/users?order=desc&page=4>; rel="next"');
});
it('build a `last` link', () => {
const link = buildLink(request, 10, 'last');
expect(link).toEqual('<https://logto.dev/users?order=desc&page=10>; rel="last"');
});
});

View file

@ -0,0 +1,15 @@
// eslint-disable-next-line no-restricted-imports
import { stringify } from 'querystring';
import { Request } from 'koa';
type LinkRelationType = 'first' | 'prev' | 'next' | 'last';
export const buildLink = (
request: Pick<Request, 'origin' | 'path' | 'query'>,
page: number,
type: LinkRelationType
): string => {
const baseUrl = `${request.origin}${request.path}`;
return `<${baseUrl}?${stringify({ ...request.query, page })}>; rel="${type}"`;
};

View file

@ -23,6 +23,7 @@ const errors = {
},
guard: {
invalid_input: 'The request input is invalid.',
invalid_pagination: 'The request pagination value is invalid.',
},
oidc: {
aborted: 'The end-user aborted interaction.',

View file

@ -25,6 +25,7 @@ const errors = {
},
guard: {
invalid_input: '请求内容有误。',
invalid_pagination: '分页参数有误。',
},
oidc: {
aborted: '用户终止了交互。',

43
pnpm-lock.yaml generated
View file

@ -22,6 +22,7 @@ importers:
specifiers:
'@logto/phrases': ^0.1.0
'@logto/schemas': ^0.1.0
'@shopify/jest-koa-mocks': ^3.0.8
'@silverhand/eslint-config': ^0.2.2
'@silverhand/essentials': ^1.1.0
'@silverhand/ts-config': ^0.2.2
@ -94,6 +95,7 @@ importers:
snakecase-keys: 5.1.0
zod: 3.8.1
devDependencies:
'@shopify/jest-koa-mocks': 3.0.8
'@silverhand/eslint-config': 0.2.2_aff669e8eb0d21fc4e2068e6112ef4d0
'@silverhand/ts-config': 0.2.2_typescript@4.3.5
'@types/jest': 27.0.1
@ -3208,6 +3210,16 @@ packages:
webpack-dev-server: 3.11.2_webpack@5.60.0
dev: true
/@shopify/jest-koa-mocks/3.0.8:
resolution: {integrity: sha512-VYb4txR56asRlrKs4QUxYp/7mjD4u4KePfHnuAGKE/ICCYe0PJduIxsX0Fj0MZnL1Vy+IpUbp3Eh7JP6SaeSxQ==}
engines: {node: '>=12.14.0'}
dependencies:
koa: 2.13.4
node-mocks-http: 1.11.0
transitivePeerDependencies:
- supports-color
dev: true
/@silverhand/eslint-config-react/0.2.2_8e322dd0e62beacbfb7b944fe3d15c43:
resolution: {integrity: sha512-rYjOM3DktpATV8On+1+YzBqCnnfVz/4ByDK58QELCm0jvLzkl1e5IbpqM5Ay1KIPDLYo6we6EB7IJeEiRJJG8Q==}
peerDependencies:
@ -5044,7 +5056,6 @@ packages:
dependencies:
mime-types: 2.1.32
ylru: 1.2.1
dev: false
/cacheable-lookup/5.0.4:
resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
@ -5713,7 +5724,6 @@ packages:
dependencies:
depd: 2.0.0
keygrip: 1.1.0
dev: false
/copy-concurrently/1.0.5:
resolution: {integrity: sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==}
@ -6173,7 +6183,6 @@ packages:
/deep-equal/1.0.1:
resolution: {integrity: sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=}
dev: false
/deep-equal/1.1.1:
resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==}
@ -6279,7 +6288,6 @@ packages:
/depd/2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dev: false
/deprecation/2.3.1:
resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==}
@ -8247,7 +8255,6 @@ packages:
dependencies:
deep-equal: 1.0.1
http-errors: 1.7.3
dev: false
/http-cache-semantics/4.1.0:
resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
@ -8303,7 +8310,6 @@ packages:
setprototypeof: 1.2.0
statuses: 1.5.0
toidentifier: 1.0.0
dev: false
/http-parser-js/0.5.3:
resolution: {integrity: sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg==}
@ -8906,7 +8912,6 @@ packages:
/is-generator-function/1.0.9:
resolution: {integrity: sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A==}
engines: {node: '>= 0.4'}
dev: false
/is-get-set-prop/1.0.0:
resolution: {integrity: sha1-JzGHfk14pqae3M5rudaLB3nnYxI=}
@ -10306,7 +10311,6 @@ packages:
engines: {node: '>= 0.6'}
dependencies:
tsscmp: 1.0.6
dev: false
/keyv/4.0.3:
resolution: {integrity: sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==}
@ -10372,7 +10376,6 @@ packages:
/koa-compose/4.1.0:
resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==}
dev: false
/koa-convert/1.2.0:
resolution: {integrity: sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=}
@ -10388,7 +10391,6 @@ packages:
dependencies:
co: 4.6.0
koa-compose: 4.1.0
dev: false
/koa-logger/3.2.1:
resolution: {integrity: sha512-MjlznhLLKy9+kG8nAXKJLM0/ClsQp/Or2vI3a5rbSQmgl8IJBQO0KI5FA70BvW+hqjtxjp49SpH2E7okS6NmHg==}
@ -10514,7 +10516,6 @@ packages:
vary: 1.1.2
transitivePeerDependencies:
- supports-color
dev: false
/ky/0.28.5:
resolution: {integrity: sha512-O5gg9kF4MeyfSw+YkgPAafOPwEUU6xcdGEJKUJmKpIPbLzk3oxUtY4OdBNekG7mawofzkyZ/ZHuR9ev5uZZdAA==}
@ -11511,6 +11512,22 @@ packages:
resolution: {integrity: sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=}
dev: true
/node-mocks-http/1.11.0:
resolution: {integrity: sha512-jS/WzSOcKbOeGrcgKbenZeNhxUNnP36Yw11+hL4TTxQXErGfqYZ+MaYNNvhaTiGIJlzNSqgQkk9j8dSu1YWSuw==}
engines: {node: '>=0.6'}
dependencies:
accepts: 1.3.7
content-disposition: 0.5.3
depd: 1.1.2
fresh: 0.5.2
merge-descriptors: 1.0.1
methods: 1.1.2
mime: 1.6.0
parseurl: 1.3.3
range-parser: 1.2.1
type-is: 1.6.18
dev: true
/node-modules-regexp/1.0.0:
resolution: {integrity: sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=}
engines: {node: '>=0.10.0'}
@ -11921,7 +11938,6 @@ packages:
/only/0.0.2:
resolution: {integrity: sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=}
dev: false
/open/7.4.2:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
@ -14347,7 +14363,6 @@ packages:
/setprototypeof/1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
dev: false
/shallow-clone/3.0.1:
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
@ -15715,7 +15730,6 @@ packages:
/tsscmp/1.0.6:
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
engines: {node: '>=0.6.x'}
dev: false
/tsutils/3.21.0_typescript@4.3.5:
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
@ -16672,7 +16686,6 @@ packages:
/ylru/1.2.1:
resolution: {integrity: sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==}
engines: {node: '>= 4.0.0'}
dev: false
/yn/3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}