Skip to content

Commit 1af848a

Browse files
committed
feat: add request utils
1 parent b188425 commit 1af848a

2 files changed

Lines changed: 371 additions & 0 deletions

File tree

src/utils/request.utils.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Options for parsing and validating request parameters.
3+
*/
4+
export interface ValidationOptions {
5+
/** Whether to throw an error on validation failure */
6+
throwOnError?: boolean;
7+
8+
/** Custom error message for validation failures */
9+
errorMessage?: string;
10+
11+
/** Default value to use if parameter is missing */
12+
defaultValue?: any;
13+
14+
/** Whether the parameter is required */
15+
required?: boolean;
16+
}
17+
18+
/**
19+
* Result of a parameter validation operation.
20+
*/
21+
export interface ValidationResult<T> {
22+
/** Whether validation was successful */
23+
isValid: boolean;
24+
25+
/** The validated and potentially transformed value */
26+
value: T | null;
27+
28+
/** Error message if validation failed */
29+
error?: string;
30+
}
31+
32+
/**
33+
* Safely parses a string parameter to a number.
34+
*
35+
* @param value - String value to parse
36+
* @param options - Validation options
37+
* @returns Validation result containing the parsed number or error
38+
*/
39+
export function parseNumberParam(value: string | undefined, options: ValidationOptions = {}): ValidationResult<number> {
40+
const { throwOnError = false, errorMessage = 'Invalid number parameter', defaultValue, required = false } = options;
41+
42+
// Handle undefined case
43+
if (value === undefined) {
44+
if (required) {
45+
const error = `Required number parameter is missing`;
46+
if (throwOnError) throw new Error(error);
47+
return { isValid: false, value: null, error };
48+
}
49+
50+
return {
51+
isValid: defaultValue !== undefined,
52+
value: defaultValue !== undefined ? defaultValue : null,
53+
error: defaultValue === undefined ? 'Missing parameter with no default' : undefined
54+
};
55+
}
56+
57+
// Parse and validate the number
58+
const num = Number(value);
59+
if (isNaN(num)) {
60+
if (throwOnError) throw new Error(errorMessage);
61+
return { isValid: false, value: null, error: errorMessage };
62+
}
63+
64+
return { isValid: true, value: num };
65+
}
66+
67+
/**
68+
* Safely parses a string parameter to a boolean.
69+
*
70+
* @param value - String value to parse
71+
* @param options - Validation options
72+
* @returns Validation result containing the parsed boolean or error
73+
*/
74+
export function parseBooleanParam(
75+
value: string | undefined,
76+
options: ValidationOptions = {}
77+
): ValidationResult<boolean> {
78+
const { throwOnError = false, errorMessage = 'Invalid boolean parameter', defaultValue, required = false } = options;
79+
80+
// Handle undefined case
81+
if (value === undefined) {
82+
if (required) {
83+
const error = `Required boolean parameter is missing`;
84+
if (throwOnError) throw new Error(error);
85+
return { isValid: false, value: null, error };
86+
}
87+
88+
return {
89+
isValid: defaultValue !== undefined,
90+
value: defaultValue !== undefined ? defaultValue : null,
91+
error: defaultValue === undefined ? 'Missing parameter with no default' : undefined
92+
};
93+
}
94+
95+
// Parse the boolean value with various allowed formats
96+
const lowercaseValue = value.toLowerCase();
97+
if (['true', 't', 'yes', 'y', '1'].includes(lowercaseValue)) {
98+
return { isValid: true, value: true };
99+
}
100+
101+
if (['false', 'f', 'no', 'n', '0'].includes(lowercaseValue)) {
102+
return { isValid: true, value: false };
103+
}
104+
105+
if (throwOnError) throw new Error(errorMessage);
106+
return { isValid: false, value: null, error: errorMessage };
107+
}
108+
109+
/**
110+
* Extracts pagination parameters from request query parameters.
111+
*
112+
* @param query - Query parameters object
113+
* @param defaultPage - Default page number if not specified (defaults to 1)
114+
* @param defaultLimit - Default per page size if not specified (defaults to 20)
115+
* @param maxLimitSize - Maximum allowed per page size (defaults to 100)
116+
* @returns Object containing validated page and limit
117+
*/
118+
export function extractPaginationParams(
119+
query: Record<string, string | string[]>,
120+
defaultPage: number = 1,
121+
defaultLimit: number = 20,
122+
maxLimitSize: number = 100
123+
): { page: number; limit: number } {
124+
// Parse page parameter
125+
const pageParam = typeof query.page === 'string' ? query.page : undefined;
126+
const pageResult = parseNumberParam(pageParam, { defaultValue: defaultPage });
127+
128+
// Parse limit parameter
129+
const limitParam = typeof query.limit === 'string' ? query.limit : undefined;
130+
const limitResult = parseNumberParam(limitParam, { defaultValue: defaultLimit });
131+
132+
// Ensure valid values (handle 0 → fallback to default)
133+
const page = pageResult.value && pageResult.value > 0 ? pageResult.value : defaultPage;
134+
135+
const limit = limitResult.value && limitResult.value > 0 ? Math.min(limitResult.value, maxLimitSize) : defaultLimit;
136+
137+
return { page, limit };
138+
}
139+
140+
/**
141+
* Extracts sorting parameters from request query parameters.
142+
*
143+
* @param query - Query parameters object
144+
* @param allowedFields - Array of field names that are allowed to be sorted
145+
* @param defaultSort - Default sort configuration if not specified
146+
* @returns Object containing sort field and direction
147+
*/
148+
export function extractSortParams(
149+
query: Record<string, string | string[]>,
150+
allowedFields: string[],
151+
defaultSort: { sortBy: string; sortOrder: 'asc' | 'desc' } = { sortBy: 'createdAt', sortOrder: 'desc' }
152+
): { sortBy: string; sortOrder: 'asc' | 'desc' } {
153+
let sortBy = defaultSort.sortBy;
154+
let sortOrder: 'asc' | 'desc' = defaultSort.sortOrder;
155+
156+
// Extract from `sortBy` or `sort`
157+
if (typeof query.sortBy === 'string') {
158+
sortBy = query.sortBy;
159+
} else if (Array.isArray(query.sortBy)) {
160+
sortBy = query.sortBy[0];
161+
} else if (typeof query.sort === 'string') {
162+
sortBy = query.sort.startsWith('-') ? query.sort.slice(1) : query.sort;
163+
sortOrder = query.sort.startsWith('-') ? 'desc' : 'asc';
164+
} else if (Array.isArray(query.sort)) {
165+
sortBy = query.sort[0].startsWith('-') ? query.sort[0].slice(1) : query.sort[0];
166+
sortOrder = query.sort[0].startsWith('-') ? 'desc' : 'asc';
167+
}
168+
169+
// Explicit sortOrder (overrides inferred one if valid)
170+
if (typeof query.sortOrder === 'string') {
171+
const order = query.sortOrder.toLowerCase();
172+
if (order === 'asc' || order === 'desc') {
173+
sortOrder = order;
174+
}
175+
}
176+
177+
// Validate field
178+
if (!allowedFields.includes(sortBy)) {
179+
sortBy = defaultSort.sortBy;
180+
}
181+
182+
return { sortBy, sortOrder };
183+
}
184+
185+
/**
186+
* Extracts filter parameters from query parameters based on allowed filter fields.
187+
*
188+
* @param query - Query parameters object
189+
* @param allowedFilters - Array of field names that are allowed to be used as filters
190+
* @returns Object containing the filters as key-value pairs
191+
*/
192+
export function extractFilterParams(
193+
query: Record<string, string | string[]>,
194+
allowedFilters: string[]
195+
): Record<string, string | string[]> {
196+
const filters: Record<string, string | string[]> = {};
197+
198+
// Find filter parameters in the query
199+
for (const field of allowedFilters) {
200+
if (field in query) {
201+
filters[field] = query[field];
202+
}
203+
}
204+
205+
return filters;
206+
}

tests/utils/request.utils.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import {
2+
parseNumberParam,
3+
parseBooleanParam,
4+
extractPaginationParams,
5+
extractSortParams,
6+
extractFilterParams
7+
} from '../../src/utils/request.utils';
8+
9+
describe('parseNumberParam', () => {
10+
it('parses valid number', () => {
11+
expect(parseNumberParam('42')).toEqual({ isValid: true, value: 42 });
12+
});
13+
14+
it('returns defaultValue if missing', () => {
15+
expect(parseNumberParam(undefined, { defaultValue: 5 })).toEqual({
16+
isValid: true,
17+
value: 5
18+
});
19+
});
20+
21+
it('fails if required and missing', () => {
22+
expect(parseNumberParam(undefined, { required: true })).toEqual({
23+
isValid: false,
24+
value: null,
25+
error: 'Required number parameter is missing'
26+
});
27+
});
28+
29+
it('throws if required and throwOnError', () => {
30+
expect(() => parseNumberParam(undefined, { required: true, throwOnError: true })).toThrow(
31+
'Required number parameter is missing'
32+
);
33+
});
34+
35+
it('returns error for NaN', () => {
36+
expect(parseNumberParam('abc')).toEqual({
37+
isValid: false,
38+
value: null,
39+
error: 'Invalid number parameter'
40+
});
41+
});
42+
});
43+
44+
describe('parseBooleanParam', () => {
45+
it('parses true values', () => {
46+
['true', 't', 'yes', 'y', '1'].forEach(v => {
47+
expect(parseBooleanParam(v)).toEqual({ isValid: true, value: true });
48+
});
49+
});
50+
51+
it('parses false values', () => {
52+
['false', 'f', 'no', 'n', '0'].forEach(v => {
53+
expect(parseBooleanParam(v)).toEqual({ isValid: true, value: false });
54+
});
55+
});
56+
57+
it('returns defaultValue if missing', () => {
58+
expect(parseBooleanParam(undefined, { defaultValue: false })).toEqual({
59+
isValid: true,
60+
value: false
61+
});
62+
});
63+
64+
it('fails if required and missing', () => {
65+
expect(parseBooleanParam(undefined, { required: true })).toEqual({
66+
isValid: false,
67+
value: null,
68+
error: 'Required boolean parameter is missing'
69+
});
70+
});
71+
72+
it('throws on invalid input when throwOnError', () => {
73+
expect(() => parseBooleanParam('maybe', { throwOnError: true })).toThrow('Invalid boolean parameter');
74+
});
75+
76+
it('returns error on invalid input', () => {
77+
expect(parseBooleanParam('maybe')).toEqual({
78+
isValid: false,
79+
value: null,
80+
error: 'Invalid boolean parameter'
81+
});
82+
});
83+
});
84+
85+
describe('extractPaginationParams', () => {
86+
it('uses defaults if no params', () => {
87+
expect(extractPaginationParams({})).toEqual({ page: 1, limit: 20 });
88+
});
89+
90+
it('parses valid page and limit', () => {
91+
expect(extractPaginationParams({ page: '3', limit: '15' })).toEqual({
92+
page: 3,
93+
limit: 15
94+
});
95+
});
96+
97+
it('enforces min page and limit', () => {
98+
expect(extractPaginationParams({ page: '0', limit: '0' })).toEqual({
99+
page: 1,
100+
limit: 20
101+
});
102+
});
103+
104+
it('caps limit at maxLimitSize', () => {
105+
expect(extractPaginationParams({ limit: '999' }, 1, 20, 100)).toEqual({
106+
page: 1,
107+
limit: 100
108+
});
109+
});
110+
});
111+
112+
describe('extractSortParams', () => {
113+
const allowedFields = ['name', 'createdAt', 'updatedAt'];
114+
115+
it('uses defaults when no params', () => {
116+
expect(extractSortParams({}, allowedFields)).toEqual({
117+
sortBy: 'createdAt',
118+
sortOrder: 'desc'
119+
});
120+
});
121+
122+
it('parses sortBy field', () => {
123+
expect(extractSortParams({ sortBy: 'name' }, allowedFields)).toEqual({
124+
sortBy: 'name',
125+
sortOrder: 'desc'
126+
});
127+
});
128+
129+
it('parses sort with prefix "-"', () => {
130+
expect(extractSortParams({ sort: '-updatedAt' }, allowedFields)).toEqual({
131+
sortBy: 'updatedAt',
132+
sortOrder: 'desc'
133+
});
134+
});
135+
136+
it('overrides with explicit sortOrder', () => {
137+
expect(extractSortParams({ sort: '-name', sortOrder: 'asc' }, allowedFields)).toEqual({
138+
sortBy: 'name',
139+
sortOrder: 'asc'
140+
});
141+
});
142+
143+
it('falls back to default if field not allowed', () => {
144+
expect(extractSortParams({ sortBy: 'invalid' }, allowedFields)).toEqual({
145+
sortBy: 'createdAt',
146+
sortOrder: 'desc'
147+
});
148+
});
149+
});
150+
151+
describe('extractFilterParams', () => {
152+
const allowedFilters = ['status', 'type'];
153+
154+
it('extracts allowed filters', () => {
155+
expect(extractFilterParams({ status: 'active', foo: 'bar' }, allowedFilters)).toEqual({ status: 'active' });
156+
});
157+
158+
it('returns empty object if no allowed filters', () => {
159+
expect(extractFilterParams({ foo: 'bar' }, allowedFilters)).toEqual({});
160+
});
161+
162+
it('handles array values in filters', () => {
163+
expect(extractFilterParams({ type: ['a', 'b'] }, allowedFilters)).toEqual({ type: ['a', 'b'] });
164+
});
165+
});

0 commit comments

Comments
 (0)