diff --git a/docs/list-response-helper.md b/docs/list-response-helper.md new file mode 100644 index 0000000..5d0f697 --- /dev/null +++ b/docs/list-response-helper.md @@ -0,0 +1,195 @@ +# List Response Helper + +## Overview + +The `buildListResponse` helper provides a consistent way to construct paginated list response envelopes across all endpoints. It ensures all list responses follow the same structure: `{ data, meta: { total, hasMore, nextCursor? } }`. + +## Usage + +### Basic Usage (Offset Pagination) + +For offset-based pagination without a cursor: + +```typescript +import { buildListResponse } from '../utils/list-response.utils'; +import { buildOffsetPaginationMeta } from '../utils/pagination.utils'; + +// In your controller +const [items, total] = await fetchActivityFeed(parsed.data); + +const response = buildListResponse(items, { + total, + hasMore: parsed.data.offset + parsed.data.limit < total, +}); + +sendSuccess(res, response); +``` + +### With Existing Offset Meta + +When you already have offset pagination metadata: + +```typescript +import { buildListResponse } from '../utils/list-response.utils'; +import { buildOffsetPaginationMeta } from '../utils/pagination.utils'; + +const [items, total] = await service.fetchData(query); + +const offsetMeta = buildOffsetPaginationMeta({ + limit: query.limit, + offset: query.offset, + total, +}); + +const response = buildListResponse(items, { + total: offsetMeta.total, + hasMore: offsetMeta.hasMore, +}); + +sendSuccess(res, response); +``` + +### Cursor Pagination + +For cursor-based pagination with a nextCursor: + +```typescript +import { buildListResponse } from '../utils/list-response.utils'; + +const [items, total, nextCursor] = await service.fetchCursorData(query); + +const response = buildListResponse(items, { + total, + hasMore: nextCursor !== null, + nextCursor: nextCursor ?? undefined, // Convert null to undefined to omit from output +}); + +sendSuccess(res, response); +``` + +### Empty Results + +The helper correctly handles empty result sets: + +```typescript +const response = buildListResponse([], { + total: 0, + hasMore: false, +}); + +// Returns: { data: [], meta: { total: 0, hasMore: false } } +``` + +## Response Shape + +### Without Cursor (Offset Pagination) + +```json +{ + "data": [ + { "id": "1", "...": "..." }, + { "id": "2", "...": "..." } + ], + "meta": { + "total": 100, + "hasMore": true + } +} +``` + +### With Cursor (Cursor Pagination) + +```json +{ + "data": [ + { "id": "1", "...": "..." }, + { "id": "2", "...": "..." } + ], + "meta": { + "total": 100, + "hasMore": true, + "nextCursor": "eyJpZCI6M30=" + } +} +``` + +### Empty Results + +```json +{ + "data": [], + "meta": { + "total": 0, + "hasMore": false + } +} +``` + +## Key Behaviors + +1. **nextCursor Omission**: When `nextCursor` is `undefined`, it is completely omitted from the output (not serialized as `null`). This keeps the response clean for offset-paginated endpoints. + +2. **Type Safety**: The helper is generic and preserves the type of your data array: + + ```typescript + interface User { + id: string; + name: string; + } + const users: User[] = [...]; + const response: ListResponse = buildListResponse(users, meta); + ``` + +3. **Consistent Structure**: All paginated list responses use the same envelope structure, making it easier for clients to parse responses predictably. + +## Migration Example + +### Before + +```typescript +export const httpGetActivityFeed: AsyncController = async (req, res, next) => { + const [items, total] = await fetchActivityFeed(parsed.data); + + const response = { + items, + meta: buildOffsetPaginationMeta({ + limit: parsed.data.limit, + offset: parsed.data.offset, + total, + }), + }; + + sendSuccess(res, response); +}; +``` + +### After + +```typescript +import { buildListResponse } from '../../utils/list-response.utils'; + +export const httpGetActivityFeed: AsyncController = async (req, res, next) => { + const [items, total] = await fetchActivityFeed(parsed.data); + + const offsetMeta = buildOffsetPaginationMeta({ + limit: parsed.data.limit, + offset: parsed.data.offset, + total, + }); + + const response = buildListResponse(items, { + total: offsetMeta.total, + hasMore: offsetMeta.hasMore, + }); + + sendSuccess(res, response); +}; +``` + +## Benefits + +1. **Consistency**: All list endpoints return the same envelope shape +2. **Type Safety**: Generic typing ensures correct data types +3. **Maintainability**: Changes to the envelope structure only require updating one place +4. **Clarity**: Explicit meta field requirements prevent missing fields +5. **Flexibility**: Supports both offset and cursor pagination patterns diff --git a/src/utils/list-response.utils.test.ts b/src/utils/list-response.utils.test.ts new file mode 100644 index 0000000..b96273f --- /dev/null +++ b/src/utils/list-response.utils.test.ts @@ -0,0 +1,252 @@ +/** + * Unit tests for buildListResponse helper. + * + * Verifies the response envelope shape for: + * - Offset pagination (no cursor) + * - Cursor pagination (with cursor) + * - Empty data arrays + */ + +import { buildListResponse, ListResponse } from './list-response.utils'; + +describe('buildListResponse', () => { + // ── With cursor ───────────────────────────────────────────────────────── + + it('returns correct envelope shape with nextCursor', () => { + const items = [{ id: '1' }, { id: '2' }, { id: '3' }]; + const result = buildListResponse(items, { + total: 10, + hasMore: true, + nextCursor: 'eyJpZCI6M30=', + }); + + expect(result).toEqual({ + data: items, + meta: { + total: 10, + hasMore: true, + nextCursor: 'eyJpZCI6M30=', + }, + }); + }); + + it('includes nextCursor in meta when provided', () => { + const result = buildListResponse([{ id: 'a' }], { + total: 5, + hasMore: true, + nextCursor: 'cursor-abc', + }); + + expect(result.meta).toHaveProperty('nextCursor', 'cursor-abc'); + }); + + it('preserves data array reference when cursor is present', () => { + const items = [{ value: 100 }]; + const result = buildListResponse(items, { + total: 20, + hasMore: true, + nextCursor: 'xyz', + }); + + expect(result.data).toBe(items); + }); + + // ── Without cursor ────────────────────────────────────────────────────── + + it('omits nextCursor from output when undefined', () => { + const items = [{ id: '1' }, { id: '2' }]; + const result = buildListResponse(items, { + total: 2, + hasMore: false, + }); + + expect(result).toEqual({ + data: items, + meta: { + total: 2, + hasMore: false, + }, + }); + expect(result.meta).not.toHaveProperty('nextCursor'); + }); + + it('does not serialize nextCursor when not provided', () => { + const result = buildListResponse([{ id: '1' }], { + total: 1, + hasMore: false, + }); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('nextCursor'); + }); + + it('returns correct shape for offset pagination without cursor', () => { + const items = [{ id: 'x' }, { id: 'y' }]; + const result = buildListResponse(items, { + total: 50, + hasMore: true, + }); + + expect(Object.keys(result)).toEqual(['data', 'meta']); + expect(Object.keys(result.meta).sort()).toEqual(['hasMore', 'total']); + }); + + // ── Empty data array ──────────────────────────────────────────────────── + + it('returns correct shape for empty data array', () => { + const result = buildListResponse([], { + total: 0, + hasMore: false, + }); + + expect(result).toEqual({ + data: [], + meta: { + total: 0, + hasMore: false, + }, + }); + }); + + it('returns empty array with total: 0 and hasMore: false for no results', () => { + const result = buildListResponse([], { + total: 0, + hasMore: false, + }); + + expect(result.data).toEqual([]); + expect(result.meta.total).toBe(0); + expect(result.meta.hasMore).toBe(false); + }); + + it('omits nextCursor from empty result when undefined', () => { + const result = buildListResponse([], { + total: 0, + hasMore: false, + }); + + expect(result.meta).not.toHaveProperty('nextCursor'); + }); + + it('handles empty array with cursor (edge case)', () => { + const result = buildListResponse([], { + total: 0, + hasMore: false, + nextCursor: 'cursor-empty', + }); + + expect(result).toEqual({ + data: [], + meta: { + total: 0, + hasMore: false, + nextCursor: 'cursor-empty', + }, + }); + }); + + // ── Type safety ───────────────────────────────────────────────────────── + + it('preserves generic type of data array', () => { + interface User { + id: string; + name: string; + } + + const users: User[] = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]; + + const result: ListResponse = buildListResponse(users, { + total: 2, + hasMore: false, + }); + + expect(result.data[0].name).toBe('Alice'); + expect(result.data[1].name).toBe('Bob'); + }); + + it('handles different data types correctly', () => { + const numbers = [1, 2, 3, 4, 5]; + const result = buildListResponse(numbers, { + total: 5, + hasMore: false, + }); + + expect(result.data).toEqual([1, 2, 3, 4, 5]); + }); + + // ── Meta field validation ─────────────────────────────────────────────── + + it('includes all required meta fields', () => { + const result = buildListResponse([{ id: '1' }], { + total: 10, + hasMore: true, + }); + + expect(result.meta).toHaveProperty('total'); + expect(result.meta).toHaveProperty('hasMore'); + }); + + it('preserves meta.total value exactly', () => { + const result = buildListResponse([], { + total: 999, + hasMore: true, + }); + + expect(result.meta.total).toBe(999); + }); + + it('preserves meta.hasMore boolean value', () => { + const resultTrue = buildListResponse([], { + total: 10, + hasMore: true, + }); + const resultFalse = buildListResponse([], { + total: 10, + hasMore: false, + }); + + expect(resultTrue.meta.hasMore).toBe(true); + expect(resultFalse.meta.hasMore).toBe(false); + }); + + // ── Edge cases ────────────────────────────────────────────────────────── + + it('handles empty string as nextCursor', () => { + const result = buildListResponse([{ id: '1' }], { + total: 1, + hasMore: false, + nextCursor: '', + }); + + expect(result.meta.nextCursor).toBe(''); + expect(result.meta).toHaveProperty('nextCursor'); + }); + + it('handles large data arrays', () => { + const largeArray = Array.from({ length: 1000 }, (_, i) => ({ id: i })); + const result = buildListResponse(largeArray, { + total: 10000, + hasMore: true, + nextCursor: 'next-1000', + }); + + expect(result.data.length).toBe(1000); + expect(result.meta.total).toBe(10000); + }); + + it('returns new meta object (not mutating input)', () => { + const inputMeta = { + total: 5, + hasMore: true, + nextCursor: 'abc', + }; + + const result = buildListResponse([{ id: '1' }], inputMeta); + + expect(result.meta).not.toBe(inputMeta); + expect(result.meta).toEqual(inputMeta); + }); +}); diff --git a/src/utils/list-response.utils.ts b/src/utils/list-response.utils.ts new file mode 100644 index 0000000..ac3cc1f --- /dev/null +++ b/src/utils/list-response.utils.ts @@ -0,0 +1,93 @@ +/** + * Shared builder for paginated list response envelopes. + * + * All paginated list endpoints return a consistent envelope shape: + * `{ data: T[], meta: { total, hasMore, nextCursor? } }`. + * + * This helper centralizes the envelope construction to ensure the shape + * stays consistent across all list endpoints. + */ + +/** + * Metadata for list responses. + * + * - `total`: Total number of items available across all pages + * - `hasMore`: Whether more results exist beyond the current page + * - `nextCursor`: Optional cursor for the next page (omitted when undefined) + */ +export interface ListResponseMeta { + total: number; + hasMore: boolean; + nextCursor?: string; +} + +/** + * Standard list response envelope. + * + * @template T - Type of items in the data array + */ +export interface ListResponse { + data: T[]; + meta: { + total: number; + hasMore: boolean; + nextCursor?: string; + }; +} + +/** + * Builds a consistent list response envelope. + * + * Returns `{ data, meta }` where meta contains `total`, `hasMore`, and + * optionally `nextCursor`. When `nextCursor` is undefined, it is omitted + * from the output (not serialized as `null`). + * + * @template T - Type of items in the data array + * @param data - Array of items for the current page + * @param meta - Pagination metadata + * @returns Structured list response envelope + * + * @example + * // Offset pagination (no cursor) + * const response = buildListResponse(items, { + * total: 100, + * hasMore: true + * }); + * // Returns: { data: [...], meta: { total: 100, hasMore: true } } + * + * @example + * // Cursor pagination (with cursor) + * const response = buildListResponse(items, { + * total: 100, + * hasMore: true, + * nextCursor: 'eyJpZCI6MTIzfQ==' + * }); + * // Returns: { data: [...], meta: { total: 100, hasMore: true, nextCursor: '...' } } + * + * @example + * // Empty result + * const response = buildListResponse([], { + * total: 0, + * hasMore: false + * }); + * // Returns: { data: [], meta: { total: 0, hasMore: false } } + */ +export function buildListResponse( + data: T[], + meta: ListResponseMeta +): ListResponse { + const response: ListResponse = { + data, + meta: { + total: meta.total, + hasMore: meta.hasMore, + }, + }; + + // Only include nextCursor when it's defined + if (meta.nextCursor !== undefined) { + response.meta.nextCursor = meta.nextCursor; + } + + return response; +}