diff --git a/apps/api/src/common/paginate.spec.ts b/apps/api/src/common/paginate.spec.ts new file mode 100644 index 00000000..6269f9e2 --- /dev/null +++ b/apps/api/src/common/paginate.spec.ts @@ -0,0 +1,93 @@ +import { paginate } from './paginate' + +describe('paginate', () => { + it('should paginate without default query', () => { + const totalCount = 100 + const relativeUrl = '/items' + const query = { page: 2, limit: 10 } + + const result = paginate(totalCount, relativeUrl, query) + + expect(result).toBeDefined() + expect(result).toEqual( + expect.objectContaining({ + page: 2, + perPage: 10, + pageCount: 10, + totalCount: 100 + }) + ) + expect(result.links.self).toEqual('/items?page=2&limit=10') + expect(result.links.first).toEqual('/items?page=0&limit=10') + expect(result.links.previous).toEqual('/items?page=1&limit=10') + expect(result.links.next).toEqual('/items?page=3&limit=10') + expect(result.links.last).toEqual('/items?page=9&limit=10') + }) + + it('should paginate with default query', () => { + const totalCount = 100 + const relativeUrl = '/items' + const query = { page: 5, limit: 10 } + const defaultQuery = { pricing: 'pro', filter: 'admin' } + + const result = paginate(totalCount, relativeUrl, query, defaultQuery) + + expect(result).toBeDefined() + expect(result).toEqual( + expect.objectContaining({ + page: 5, + perPage: 10, + pageCount: 10, + totalCount: 100 + }) + ) + expect(result.links.self).toEqual( + '/items?filter=admin&pricing=pro&page=5&limit=10' + ) + expect(result.links.first).toEqual( + '/items?filter=admin&pricing=pro&page=0&limit=10' + ) + expect(result.links.previous).toEqual( + '/items?filter=admin&pricing=pro&page=4&limit=10' + ) + expect(result.links.next).toEqual( + '/items?filter=admin&pricing=pro&page=6&limit=10' + ) + expect(result.links.last).toEqual( + '/items?filter=admin&pricing=pro&page=9&limit=10' + ) + }) + + it('should paginate correctly edge cases where pervious or next is null', () => { + const totalCount = 10 + const relativeUrl = '/items' + const query = { page: 0, limit: 10 } + + const result = paginate(totalCount, relativeUrl, query) + + expect(result).toBeDefined() + expect(result).toEqual( + expect.objectContaining({ + page: 0, + perPage: 10, + pageCount: 1, + totalCount: 10 + }) + ) + expect(result.links.self).toEqual('/items?page=0&limit=10') + expect(result.links.first).toEqual('/items?page=0&limit=10') + expect(result.links.previous).toBeNull() + expect(result.links.next).toBeNull() + expect(result.links.last).toEqual('/items?page=0&limit=10') + }) + + it('should not be able to paginate when limit is 0 or undefined', () => { + const totalCount = 10 + const relativeUrl = '/items' + const query = { page: 0, limit: 0 } + + expect(() => paginate(totalCount, relativeUrl, query)).toThrow( + 'Limit is required' + ) + }) +}) diff --git a/apps/api/src/common/paginate.ts b/apps/api/src/common/paginate.ts new file mode 100644 index 00000000..18a2243e --- /dev/null +++ b/apps/api/src/common/paginate.ts @@ -0,0 +1,75 @@ +export interface PaginatedMetadata { + page: number + perPage: number + pageCount: number + totalCount: number + links: { + self: string + first: string + previous: string | null + next: string | null + last: string + } +} + +interface QueryOptions { + page: number + limit: number + sort?: string + order?: string + search?: string +} + +//convert query object to query string to use in links +const getQueryString = (query: QueryOptions) => { + return Object.keys(query) + .map((key) => `${key}=${query[key]}`) + .join('&') +} + +export const paginate = ( + totalCount: number, + relativeUrl: string, + query: QueryOptions, + defaultQuery?: Record +) => { + //query.limit cannot be 0 or undefined + if (!query.limit) throw new Error('Limit is required') + let defaultQueryStr = '' + if (defaultQuery) { + //sorting entries to make sure the order is consistent and predictable during tests + const sortedEntries = Object.entries(defaultQuery).sort(([keyA], [keyB]) => + keyA.localeCompare(keyB) + ) + //ignore keys with undefined values. Undefined values may occur when qury params are optional + defaultQueryStr = sortedEntries.reduce((res, [key, value]) => { + if (value !== undefined) { + res += `${key}=${value}&` + } + return res + }, '') + } + + const metadata = {} as PaginatedMetadata + metadata.page = query.page + metadata.perPage = query.limit + metadata.pageCount = Math.ceil(totalCount / query.limit) + metadata.totalCount = totalCount + + //create links from relativeUrl , defalutQueryStr and query of type QueryOptions + metadata.links = { + self: `${relativeUrl}?${defaultQueryStr + getQueryString(query)}`, + first: `${relativeUrl}?${defaultQueryStr + getQueryString({ ...query, page: 0 })}`, + previous: + query.page === 0 + ? null + : `${relativeUrl}?${defaultQueryStr + getQueryString({ ...query, page: query.page - 1 })}`, + next: + query.page === metadata.pageCount - 1 + ? null + : `${relativeUrl}?${defaultQueryStr + getQueryString({ ...query, page: query.page + 1 })}`, + last: `${relativeUrl}?${defaultQueryStr + getQueryString({ ...query, page: metadata.pageCount - 1 })}` + } + + return metadata +}