Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 50 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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, () => {
Expand Down
56 changes: 56 additions & 0 deletions src/utils/filtering.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
});
48 changes: 48 additions & 0 deletions src/utils/filtering.ts
Original file line number Diff line number Diff line change
@@ -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;
}
57 changes: 57 additions & 0 deletions src/utils/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
64 changes: 64 additions & 0 deletions src/utils/pagination.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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,
};
}
Loading