From 838ae3fad97248c64d455887cf5312070cfbc37d Mon Sep 17 00:00:00 2001 From: Wang Sijie Date: Tue, 30 Nov 2021 11:06:50 +0800 Subject: [PATCH] feat: add koa-pagination (#143) * feat: add koa-pagination * fix: pr fix --- packages/core/package.json | 1 + .../src/middleware/koa-pagination.test.ts | 139 ++++++++++++++++++ .../core/src/middleware/koa-pagination.ts | 67 +++++++++ packages/core/src/utils/pagination.test.ts | 29 ++++ packages/core/src/utils/pagination.ts | 15 ++ packages/phrases/src/locales/en.ts | 1 + packages/phrases/src/locales/zh-cn.ts | 1 + pnpm-lock.yaml | 43 ++++-- 8 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/middleware/koa-pagination.test.ts create mode 100644 packages/core/src/middleware/koa-pagination.ts create mode 100644 packages/core/src/utils/pagination.test.ts create mode 100644 packages/core/src/utils/pagination.ts diff --git a/packages/core/package.json b/packages/core/package.json index 20a93db76..3210ecdfc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/middleware/koa-pagination.test.ts b/packages/core/src/middleware/koa-pagination.test.ts new file mode 100644 index 000000000..707faade2 --- /dev/null +++ b/packages/core/src/middleware/koa-pagination.test.ts @@ -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(); +const appendHeader = jest.fn((key: string, value: string) => { + if (key === 'Link') { + links.add(value); + } +}); + +const createContext = (query: Record): WithPaginationContext => { + 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('; rel="first"')).toBeTruthy(); + expect(links.has('; 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('; rel="first"')).toBeTruthy(); + expect(links.has('; rel="next"')).toBeTruthy(); + expect(links.has('; 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('; rel="first"')).toBeTruthy(); + expect(links.has('; rel="prev"')).toBeTruthy(); + expect(links.has('; 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('; rel="first"')).toBeTruthy(); + expect(links.has('; rel="prev"')).toBeTruthy(); + expect(links.has('; rel="next"')).toBeTruthy(); + expect(links.has('; rel="last"')).toBeTruthy(); + }); + }); +}); diff --git a/packages/core/src/middleware/koa-pagination.ts b/packages/core/src/middleware/koa-pagination.ts new file mode 100644 index 000000000..cf98eabca --- /dev/null +++ b/packages/core/src/middleware/koa-pagination.ts @@ -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 & { + pagination: Pagination; +}; + +export interface PaginationConfig { + defaultPageSize?: number; + maxPageSize?: number; +} + +export default function koaPagination({ + defaultPageSize = 20, + maxPageSize = 100, +}: PaginationConfig = {}): MiddlewareType, 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')); + } + } + }; +} diff --git a/packages/core/src/utils/pagination.test.ts b/packages/core/src/utils/pagination.test.ts new file mode 100644 index 000000000..fdbf86989 --- /dev/null +++ b/packages/core/src/utils/pagination.test.ts @@ -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('; rel="first"'); + }); + + it('build a `prev` link', () => { + const link = buildLink(request, 2, 'prev'); + expect(link).toEqual('; rel="prev"'); + }); + + it('build a `next` link', () => { + const link = buildLink(request, 4, 'next'); + expect(link).toEqual('; rel="next"'); + }); + + it('build a `last` link', () => { + const link = buildLink(request, 10, 'last'); + expect(link).toEqual('; rel="last"'); + }); +}); diff --git a/packages/core/src/utils/pagination.ts b/packages/core/src/utils/pagination.ts new file mode 100644 index 000000000..102e4c121 --- /dev/null +++ b/packages/core/src/utils/pagination.ts @@ -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, + page: number, + type: LinkRelationType +): string => { + const baseUrl = `${request.origin}${request.path}`; + return `<${baseUrl}?${stringify({ ...request.query, page })}>; rel="${type}"`; +}; diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index e0863e438..7a5510b35 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -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.', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 51743ae9b..fbb0084f1 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -25,6 +25,7 @@ const errors = { }, guard: { invalid_input: '请求内容有误。', + invalid_pagination: '分页参数有误。', }, oidc: { aborted: '用户终止了交互。', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1353ce0ad..340fff701 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==}