diff --git a/package-lock.json b/package-lock.json index 6392ca6..72b4a9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1178,7 +1178,6 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1603,7 +1602,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2914,7 +2912,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4788,7 +4785,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4937,7 +4933,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/index.ts b/src/index.ts index dd2fd8f..d530d8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,64 @@ import express, { Request, Response } from 'express'; +import { getPaginationOptions, getPaginationMetadata } from './utils/pagination'; +import { getFilterOptions, sanitizeFilters } from './utils/filtering'; const app = express(); const PORT = process.env.PORT || 3001; app.use(express.json()); +// Mock data for demonstration purposes +const contracts = Array.from({ length: 50 }, (_, i) => ({ + id: i + 1, + name: `Contract ${i + 1}`, + status: i % 2 === 0 ? 'active' : 'inactive', + type: i % 3 === 0 ? 'full-time' : 'part-time', +})); + app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', service: 'talenttrust-backend' }); }); -app.get('/api/v1/contracts', (_req: Request, res: Response) => { - res.json({ contracts: [] }); +/** + * @notice List all contracts with pagination and filtering support. + * @dev This endpoint uses the newly added reusable pagination and filtering utilities. + */ +app.get('/api/v1/contracts', (req: Request, res: Response) => { + // 1. Get and sanitize filtering options + const allowedFilters = ['status', 'type']; + const rawFilters = getFilterOptions(req.query, allowedFilters); + const filters = sanitizeFilters(rawFilters); + + // 2. Filter data + let filteredContracts = contracts; + if (Object.keys(filters).length > 0) { + filteredContracts = contracts.filter((contract) => { + return Object.entries(filters).every(([key, value]) => { + return contract[key as keyof typeof contract] === value; + }); + }); + } + + // 3. Get pagination options + const paginationOptions = getPaginationOptions(req.query); + + // 4. Paginate data + const paginatedData = filteredContracts.slice( + paginationOptions.offset, + paginationOptions.offset + paginationOptions.limit + ); + + // 5. Generate pagination metadata + const meta = getPaginationMetadata( + filteredContracts.length, + paginationOptions, + paginatedData.length + ); + + res.json({ + contracts: paginatedData, + meta, + }); }); app.listen(PORT, () => { diff --git a/src/utils/filtering.test.ts b/src/utils/filtering.test.ts new file mode 100644 index 0000000..70e2c76 --- /dev/null +++ b/src/utils/filtering.test.ts @@ -0,0 +1,56 @@ +import { getFilterOptions, sanitizeFilters } from './filtering'; + +describe('Filtering Utility', () => { + describe('getFilterOptions', () => { + it('should extract allowed filters from query', () => { + const query = { status: 'active', type: 'full-time', unknown: 'value' }; + const allowedFilters = ['status', 'type']; + const filters = getFilterOptions(query, allowedFilters); + + expect(filters).toEqual({ status: 'active', type: 'full-time' }); + expect(filters.unknown).toBeUndefined(); + }); + + it('should handle missing filter parameters', () => { + const query = { status: 'active' }; + const allowedFilters = ['status', 'type']; + const filters = getFilterOptions(query, allowedFilters); + + expect(filters).toEqual({ status: 'active' }); + }); + + it('should return empty object if no allowed filters are present', () => { + const query = { unknown: 'value' }; + const allowedFilters = ['status', 'type']; + const filters = getFilterOptions(query, allowedFilters); + + expect(filters).toEqual({}); + }); + }); + + describe('sanitizeFilters', () => { + it('should only allow primitive types', () => { + const filters = { + status: 'active', + limit: 10, + active: true, + nested: { key: 'value' }, + array: [1, 2, 3], + }; + const sanitized = sanitizeFilters(filters); + + expect(sanitized).toEqual({ + status: 'active', + limit: 10, + active: true, + }); + expect(sanitized.nested).toBeUndefined(); + expect(sanitized.array).toBeUndefined(); + }); + + it('should handle empty filters', () => { + const sanitized = sanitizeFilters({}); + expect(sanitized).toEqual({}); + }); + }); +}); diff --git a/src/utils/filtering.ts b/src/utils/filtering.ts new file mode 100644 index 0000000..3dcfc0f --- /dev/null +++ b/src/utils/filtering.ts @@ -0,0 +1,48 @@ +/** + * @title Filtering Utility + * @notice Provides reusable functions and interfaces for handling filtering in API endpoints. + * @dev This module includes logic for parsing filtering parameters from query strings. + */ + +export interface FilterOptions { + [key: string]: any; +} + +/** + * @notice Parses filtering parameters from query string and returns a structured object. + * @param query The query parameters from the request. + * @param allowedFilters An array of allowed filter keys. + * @returns An object containing the filtered keys and their values. + */ +export function getFilterOptions( + query: { [key: string]: any }, + allowedFilters: string[] +): FilterOptions { + const filters: FilterOptions = {}; + + allowedFilters.forEach((key) => { + if (query[key] !== undefined) { + filters[key] = query[key]; + } + }); + + return filters; +} + +/** + * @notice Sanitizes filtering parameters to prevent injection or invalid queries. + * @param filters The filtering options to sanitize. + * @returns A sanitized object containing only allowed types. + */ +export function sanitizeFilters(filters: FilterOptions): FilterOptions { + const sanitized: FilterOptions = {}; + + Object.entries(filters).forEach(([key, value]) => { + // Basic sanitization: only allow primitive types for now + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + sanitized[key] = value; + } + }); + + return sanitized; +} diff --git a/src/utils/pagination.test.ts b/src/utils/pagination.test.ts new file mode 100644 index 0000000..2ac3d36 --- /dev/null +++ b/src/utils/pagination.test.ts @@ -0,0 +1,57 @@ +import { getPaginationOptions, getPaginationMetadata } from './pagination'; + +describe('Pagination Utility', () => { + describe('getPaginationOptions', () => { + it('should return default options when query is empty', () => { + const options = getPaginationOptions({}); + expect(options).toEqual({ page: 1, limit: 10, offset: 0 }); + }); + + it('should parse page and limit from query', () => { + const options = getPaginationOptions({ page: '2', limit: '20' }); + expect(options).toEqual({ page: 2, limit: 20, offset: 20 }); + }); + + it('should handle invalid page and limit values', () => { + const options = getPaginationOptions({ page: 'abc', limit: '-5' }); + expect(options).toEqual({ page: 1, limit: 1, offset: 0 }); + }); + + it('should cap the limit at 100', () => { + const options = getPaginationOptions({ limit: '200' }); + expect(options.limit).toBe(100); + }); + + it('should use custom default limit', () => { + const options = getPaginationOptions({}, 50); + expect(options.limit).toBe(50); + }); + }); + + describe('getPaginationMetadata', () => { + it('should generate correct metadata', () => { + const totalItems = 100; + const options = { page: 1, limit: 10, offset: 0 }; + const itemCount = 10; + const meta = getPaginationMetadata(totalItems, options, itemCount); + + expect(meta).toEqual({ + totalItems: 100, + itemCount: 10, + itemsPerPage: 10, + totalPages: 10, + currentPage: 1, + }); + }); + + it('should handle cases with partial pages', () => { + const totalItems = 105; + const options = { page: 11, limit: 10, offset: 100 }; + const itemCount = 5; + const meta = getPaginationMetadata(totalItems, options, itemCount); + + expect(meta.totalPages).toBe(11); + expect(meta.currentPage).toBe(11); + }); + }); +}); diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts new file mode 100644 index 0000000..2a3b875 --- /dev/null +++ b/src/utils/pagination.ts @@ -0,0 +1,64 @@ +/** + * @title Pagination Utility + * @notice Provides reusable functions and interfaces for handling pagination in API endpoints. + * @dev This module includes logic for parsing pagination parameters and generating pagination metadata. + */ + +export interface PaginationOptions { + page: number; + limit: number; + offset: number; +} + +export interface PaginationMetadata { + totalItems: number; + itemCount: number; + itemsPerPage: number; + totalPages: number; + currentPage: number; +} + +export interface PaginatedResult { + data: T[]; + meta: PaginationMetadata; +} + +/** + * @notice Parses pagination query parameters and returns structured pagination options. + * @param query The query parameters from the request. + * @param defaultLimit The default number of items per page. + * @returns An object containing the page, limit, and offset. + */ +export function getPaginationOptions( + query: { page?: any; limit?: any }, + defaultLimit: number = 10 +): PaginationOptions { + const page = Math.max(1, parseInt(query.page as string, 10) || 1); + const limit = Math.max(1, Math.min(100, parseInt(query.limit as string, 10) || defaultLimit)); + const offset = (page - 1) * limit; + + return { page, limit, offset }; +} + +/** + * @notice Generates pagination metadata based on total items and pagination options. + * @param totalItems The total number of items available. + * @param options The pagination options used for the query. + * @param itemCount The number of items returned in the current page. + * @returns A structured pagination metadata object. + */ +export function getPaginationMetadata( + totalItems: number, + options: PaginationOptions, + itemCount: number +): PaginationMetadata { + const totalPages = Math.ceil(totalItems / options.limit); + + return { + totalItems, + itemCount, + itemsPerPage: options.limit, + totalPages, + currentPage: options.page, + }; +}