Implement pagination response handling and related enhancements
- Introduced `@Pagination()` decorator to mark list endpoints for pagination.
- Added `TransformInterceptor` to wrap responses in a standardized format `{ data, meta }`.
- Created pagination-related utility functions and constants for managing pagination logic.
- Defined `PaginationQueryDto` for handling pagination query parameters.
- Established `PaginationMetaDto` for OpenAPI documentation of pagination metadata.
- Updated existing controller and service structures to support pagination in responses.
- Added unit tests for pagination utilities and interceptor to ensure correct functionality.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/** Reflector metadata key set by `@Pagination()`. */
|
||||
export const PAGINATION_RESPONSE = 'http.paginationResponse';
|
||||
|
||||
/**
|
||||
* Reflector metadata key set by `@RawResponse()`.
|
||||
* When present, the transform interceptor leaves the handler result untouched.
|
||||
*/
|
||||
export const RAW_RESPONSE = 'http.rawResponse';
|
||||
@@ -0,0 +1,24 @@
|
||||
export { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
|
||||
export { Pagination, RawResponse } from './pagination.decorator';
|
||||
export type {
|
||||
PaginationMeta,
|
||||
PaginationResponse,
|
||||
SuccessResponse,
|
||||
} from './ok-response.interface';
|
||||
export { PaginationMetaDto } from './pagination-meta.dto';
|
||||
export { TransformInterceptor } from './transform.interceptor';
|
||||
export {
|
||||
createPaginationMeta,
|
||||
createPaginationResponse,
|
||||
resolvePaginationQuery,
|
||||
} from './pagination-meta.helper';
|
||||
export { PaginationQueryDto } from './pagination-query.dto';
|
||||
export {
|
||||
toListPage,
|
||||
type ListPage,
|
||||
type PaginationQueryInput,
|
||||
} from './list-page';
|
||||
export {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
import { toListPage } from './list-page';
|
||||
|
||||
describe('list-page', () => {
|
||||
describe('toListPage', () => {
|
||||
it('defaults to page 1 with default limit', () => {
|
||||
expect(toListPage({})).toEqual({
|
||||
limit: PAGINATION_DEFAULT_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives offset from page and limit', () => {
|
||||
expect(toListPage({ page: 3, limit: 10 })).toEqual({
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses explicit offset when page is omitted', () => {
|
||||
expect(toListPage({ offset: 15, limit: 5 })).toEqual({
|
||||
limit: 5,
|
||||
offset: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers page over offset when both are present', () => {
|
||||
expect(toListPage({ page: 2, offset: 50, limit: 10 })).toEqual({
|
||||
limit: 10,
|
||||
offset: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps limit to max', () => {
|
||||
expect(toListPage({ limit: PAGINATION_MAX_LIMIT + 50 })).toEqual({
|
||||
limit: PAGINATION_MAX_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
|
||||
export type PaginationQueryInput = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListPage = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves repository page args from list query params.
|
||||
* When `page` is present it wins over `offset` (aligned with `resolvePaginationQuery`).
|
||||
* Otherwise derives from `offset`, or from page 1 when both are omitted.
|
||||
*/
|
||||
export function toListPage(
|
||||
query: PaginationQueryInput,
|
||||
defaults?: { limit?: number; maxLimit?: number },
|
||||
): ListPage {
|
||||
const maxLimit = defaults?.maxLimit ?? PAGINATION_MAX_LIMIT;
|
||||
const limit = clampLimit(
|
||||
query.limit ?? defaults?.limit ?? PAGINATION_DEFAULT_LIMIT,
|
||||
maxLimit,
|
||||
);
|
||||
if (query.page != null && query.page >= 1) {
|
||||
const page = Math.trunc(query.page);
|
||||
return { limit, offset: (page - 1) * limit };
|
||||
}
|
||||
if (query.offset != null) {
|
||||
return { limit, offset: Math.max(0, Math.trunc(query.offset)) };
|
||||
}
|
||||
return { limit, offset: 0 };
|
||||
}
|
||||
|
||||
function clampLimit(limit: number, maxLimit: number): number {
|
||||
return Math.max(1, Math.min(Math.trunc(limit), maxLimit));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface PaginationMeta {
|
||||
currentPage: number;
|
||||
itemCount: number;
|
||||
itemsPerPage: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface SuccessResponse<T> {
|
||||
data: T;
|
||||
meta?: PaginationMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape a list handler must return when the route is marked with `@Pagination()`.
|
||||
* The transform interceptor turns this into `{ data, meta }`.
|
||||
*/
|
||||
export interface PaginationResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { PaginationMeta } from './ok-response.interface';
|
||||
|
||||
/** OpenAPI-visible pagination meta (DTO class, not an interface). */
|
||||
export class PaginationMetaDto implements PaginationMeta {
|
||||
@ApiProperty({ example: 1 })
|
||||
currentPage!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
itemCount!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
itemsPerPage!: number;
|
||||
|
||||
@ApiProperty({ example: 42 })
|
||||
totalItems!: number;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
totalPages!: number;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
createPaginationMeta,
|
||||
createPaginationResponse,
|
||||
resolvePaginationQuery,
|
||||
} from './pagination-meta.helper';
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
import { toListPage } from './list-page';
|
||||
|
||||
describe('pagination-meta.helper', () => {
|
||||
describe('createPaginationMeta', () => {
|
||||
it('builds meta from page, limit, data count, and total', () => {
|
||||
expect(createPaginationMeta(2, 10, 10, 25)).toEqual({
|
||||
currentPage: 2,
|
||||
itemCount: 10,
|
||||
itemsPerPage: 10,
|
||||
totalItems: 25,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps page and limit to at least 1', () => {
|
||||
expect(createPaginationMeta(0, 0, 0, 0)).toEqual({
|
||||
currentPage: 1,
|
||||
itemCount: 0,
|
||||
itemsPerPage: 1,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPaginationResponse', () => {
|
||||
it('wraps handler payload as { data, meta }', () => {
|
||||
const items = [{ id: '1' }, { id: '2' }];
|
||||
|
||||
expect(
|
||||
createPaginationResponse({ data: items, total: 12 }, 1, 2),
|
||||
).toEqual({
|
||||
data: items,
|
||||
meta: {
|
||||
currentPage: 1,
|
||||
itemCount: 2,
|
||||
itemsPerPage: 2,
|
||||
totalItems: 12,
|
||||
totalPages: 6,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through payloads that are not PaginationResponse shapes', () => {
|
||||
const payload = { id: 'not-a-list' };
|
||||
expect(createPaginationResponse(payload as never, 1, 10)).toEqual(
|
||||
payload,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePaginationQuery', () => {
|
||||
it('defaults to page 1 and default limit', () => {
|
||||
expect(resolvePaginationQuery({})).toEqual({
|
||||
page: 1,
|
||||
limit: PAGINATION_DEFAULT_LIMIT,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads page and limit from query', () => {
|
||||
expect(resolvePaginationQuery({ page: '3', limit: '20' })).toEqual({
|
||||
page: 3,
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives page from offset and limit', () => {
|
||||
expect(resolvePaginationQuery({ offset: '20', limit: '10' })).toEqual({
|
||||
page: 3,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers page over offset when both are present', () => {
|
||||
expect(
|
||||
resolvePaginationQuery({ page: '2', offset: '50', limit: '10' }),
|
||||
).toEqual({ page: 2, limit: 10 });
|
||||
});
|
||||
|
||||
it('falls back on invalid page or limit', () => {
|
||||
expect(resolvePaginationQuery({ page: 'abc', limit: '-1' })).toEqual({
|
||||
page: 1,
|
||||
limit: PAGINATION_DEFAULT_LIMIT,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps limit to max', () => {
|
||||
expect(
|
||||
resolvePaginationQuery({
|
||||
limit: String(PAGINATION_MAX_LIMIT + 50),
|
||||
}),
|
||||
).toEqual({ page: 1, limit: PAGINATION_MAX_LIMIT });
|
||||
});
|
||||
|
||||
it('stays aligned with toListPage for page and offset', () => {
|
||||
const query = { page: 2, offset: 50, limit: 10 };
|
||||
const resolved = resolvePaginationQuery(query);
|
||||
const listPage = toListPage(query);
|
||||
|
||||
expect(resolved.limit).toBe(listPage.limit);
|
||||
expect((resolved.page - 1) * resolved.limit).toBe(listPage.offset);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
import type {
|
||||
PaginationMeta,
|
||||
PaginationResponse,
|
||||
} from './ok-response.interface';
|
||||
|
||||
export function createPaginationMeta(
|
||||
page: number,
|
||||
limit: number,
|
||||
dataCount: number,
|
||||
total: number,
|
||||
): PaginationMeta {
|
||||
const safeLimit = Math.max(1, limit);
|
||||
return {
|
||||
currentPage: Math.max(1, page),
|
||||
itemCount: dataCount,
|
||||
itemsPerPage: safeLimit,
|
||||
totalItems: total,
|
||||
totalPages: Math.ceil(total / safeLimit) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the public paginated envelope:
|
||||
* `{ data, meta: { currentPage, itemsPerPage, totalItems, totalPages, itemCount } }`.
|
||||
* Returns the original payload when it is not a PaginationResponse shape.
|
||||
*/
|
||||
export function createPaginationResponse(
|
||||
response: unknown,
|
||||
page: number,
|
||||
limit: number,
|
||||
): unknown {
|
||||
if (!isPaginationResponse(response)) {
|
||||
return response;
|
||||
}
|
||||
const { data, total } = response;
|
||||
return {
|
||||
data,
|
||||
meta: createPaginationMeta(page, limit, data.length, total),
|
||||
};
|
||||
}
|
||||
|
||||
function isPaginationResponse(
|
||||
value: unknown,
|
||||
): value is PaginationResponse<unknown> {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return Array.isArray(record.data) && typeof record.total === 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves page/limit from query params.
|
||||
* Supports `?page=&limit=` and `?offset=&limit=`
|
||||
* (page is derived as `floor(offset / limit) + 1`).
|
||||
* When both `page` and `offset` are present, `page` wins (same as `toListPage`).
|
||||
*/
|
||||
export function resolvePaginationQuery(query: Record<string, unknown>): {
|
||||
page: number;
|
||||
limit: number;
|
||||
} {
|
||||
const limit = clampLimit(
|
||||
toPositiveInt(query.limit, PAGINATION_DEFAULT_LIMIT),
|
||||
PAGINATION_MAX_LIMIT,
|
||||
);
|
||||
|
||||
if (query.page != null && query.page !== '') {
|
||||
return { page: toPositiveInt(query.page, 1), limit };
|
||||
}
|
||||
|
||||
const offset = toNonNegativeInt(query.offset, 0);
|
||||
return { page: Math.floor(offset / limit) + 1, limit };
|
||||
}
|
||||
|
||||
function clampLimit(limit: number, maxLimit: number): number {
|
||||
return Math.max(1, Math.min(limit, maxLimit));
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(n);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { PAGINATION_MAX_LIMIT } from './pagination.constants';
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(PAGINATION_MAX_LIMIT)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Default max page size for collection list endpoints. */
|
||||
export const PAGINATION_DEFAULT_LIMIT = 10;
|
||||
export const PAGINATION_MAX_LIMIT = 200;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
|
||||
|
||||
/**
|
||||
* Marks a handler as a paginated list endpoint.
|
||||
*
|
||||
* The handler must return `{ data: T[]; total: number }`. The global
|
||||
* transform interceptor then wraps it as `{ data, meta }` using `page` /
|
||||
* `limit` (or `offset` / `limit`) from the query string.
|
||||
*/
|
||||
export const Pagination = (
|
||||
isPagination = true,
|
||||
): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(PAGINATION_RESPONSE, isPagination);
|
||||
|
||||
/**
|
||||
* Skips response wrapping for the handler (e.g. file downloads, health probes).
|
||||
*/
|
||||
export const RawResponse = (): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(RAW_RESPONSE, true);
|
||||
@@ -0,0 +1,116 @@
|
||||
import { CallHandler, ExecutionContext, StreamableFile } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { of, firstValueFrom } from 'rxjs';
|
||||
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
|
||||
import { TransformInterceptor } from './transform.interceptor';
|
||||
|
||||
describe('TransformInterceptor', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn(),
|
||||
};
|
||||
const interceptor = new TransformInterceptor(
|
||||
reflector as unknown as Reflector,
|
||||
);
|
||||
|
||||
function createContext(
|
||||
query: Record<string, unknown> = {},
|
||||
): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => jest.fn(),
|
||||
getClass: () => class TestController {},
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ query }),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function createHandler(payload: unknown): CallHandler {
|
||||
return { handle: () => of(payload) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reflector.getAllAndOverride.mockReset();
|
||||
});
|
||||
|
||||
it('passes through when not marked as pagination', async () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(false);
|
||||
const payload = { id: '1' };
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(createContext(), createHandler(payload)),
|
||||
);
|
||||
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('passes through when marked @RawResponse()', async () => {
|
||||
reflector.getAllAndOverride.mockImplementation((key: string) => {
|
||||
if (key === RAW_RESPONSE) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const payload = { data: [], total: 0 };
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(createContext(), createHandler(payload)),
|
||||
);
|
||||
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('wraps @Pagination() handler return as { data, meta }', async () => {
|
||||
reflector.getAllAndOverride.mockImplementation((key: string) => {
|
||||
if (key === RAW_RESPONSE) {
|
||||
return false;
|
||||
}
|
||||
if (key === PAGINATION_RESPONSE) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(
|
||||
createContext({ page: '2', limit: '5' }),
|
||||
createHandler({ data: [{ id: 'a' }], total: 11 }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [{ id: 'a' }],
|
||||
meta: {
|
||||
currentPage: 2,
|
||||
itemCount: 1,
|
||||
itemsPerPage: 5,
|
||||
totalItems: 11,
|
||||
totalPages: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not wrap null payloads', async () => {
|
||||
reflector.getAllAndOverride.mockImplementation((key: string) => {
|
||||
return key === PAGINATION_RESPONSE;
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(createContext(), createHandler(null)),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('does not wrap StreamableFile payloads', async () => {
|
||||
reflector.getAllAndOverride.mockImplementation((key: string) => {
|
||||
return key === PAGINATION_RESPONSE;
|
||||
});
|
||||
const file = new StreamableFile(Buffer.from('x'));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(createContext(), createHandler(file)),
|
||||
);
|
||||
|
||||
expect(result).toBe(file);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
|
||||
import {
|
||||
createPaginationResponse,
|
||||
resolvePaginationQuery,
|
||||
} from './pagination-meta.helper';
|
||||
|
||||
/**
|
||||
* Applies pagination enveloping when a handler is marked with `@Pagination()`.
|
||||
*
|
||||
* Non-paginated handlers pass through unchanged. `@RawResponse()`, empty
|
||||
* bodies, and `StreamableFile` downloads are never wrapped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isRaw) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const isPagination = this.reflector.getAllAndOverride<boolean>(
|
||||
PAGINATION_RESPONSE,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!isPagination) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
query: Record<string, unknown>;
|
||||
}>();
|
||||
const { page, limit } = resolvePaginationQuery(request.query);
|
||||
|
||||
return next.handle().pipe(
|
||||
map((payload: unknown): unknown => {
|
||||
if (payload == null || payload instanceof StreamableFile) {
|
||||
return payload;
|
||||
}
|
||||
return createPaginationResponse(payload, page, limit);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user