Пошук уроків, статей та іншого контенту
Реалізуєте фільтрування й сортування колекцій через безпечні та передбачувані параметри запиту.
Колекції зазвичай не варто повертати клієнту в довільному порядку та без можливості вибрати потрібні елементи. Параметри запиту дають змогу описати, які дані потрібні клієнту:
GET /products?category=books&minPrice=200&sort=price&order=ascУ цьому запиті:
category=books — фільтрує товари за категорією;
minPrice=200 — залишає товари з ціною від 200;
sort=price — визначає поле сортування;
order=asc — визначає напрямок сортування.
Параметри запиту є зовнішніми даними, тому їх не можна безпосередньо використовувати як назви властивостей або умови сортування. Сервер має:
перевірити параметри;
перетворити рядкові значення на потрібні типи;
дозволити лише відомі поля сортування;
визначити передбачувані значення за замовчуванням;
повернути зрозумілу помилку для некоректного запиту.
У Node.js для роботи з URL зручно використовувати вбудований клас URL:
const requestUrl = new URL(
req.url,
`http://${req.headers.host}`
);
const category = requestUrl.searchParams.get('category');
const sort = requestUrl.searchParams.get('sort');Метод get() повертає рядок або null, якщо параметр відсутній.
Наприклад, для URL:
/products?category=books&sort=price&order=descотримаємо:
category === 'books';
sort === 'price';Важливо, що всі значення параметрів спочатку є рядками. Навіть minPrice=200 потрібно явно перетворити на число та перевірити результат.
Для кожного параметра потрібно визначити:
чи є він обов’язковим;
який тип він має;
які значення дозволені;
яке значення використовувати за замовчуванням.
Наприклад, поле sort не повинно напряму надходити в доступ до властивості:
// Небажано: клієнт сам визначає будь-яку властивість
products.sort((a, b) => a[userSortField] - b[userSortField]);Натомість слід використати список дозволених полів:
const sortableFields = new Set(['price', 'rating', 'name']);
if (!sortableFields.has(sortField)) {
// Некоректне або заборонене поле сортування
}Так сервер контролює поведінку запиту, а клієнт не може передати довільну назву властивості.
Фільтрування створює нову колекцію, до якої потрапляють лише елементи, що відповідають умовам. У JavaScript для цього використовується filter().
const filteredProducts = products.filter((product) => {
return product.category === category;
});Для кількох незалежних умов їх можна об’єднати:
const filteredProducts = products.filter((product) => {
const matchesCategory =
!category || product.category === category;
const matchesMinPrice =
minPrice === null || product.price >= minPrice;
const matchesMaxPrice =
maxPrice === null || product.price <= maxPrice;
return matchesCategory && matchesMinPrice && matchesMaxPrice;
});Перевірка !category означає, що фільтр за категорією не застосовується, якщо параметр не передали.
Пошук за текстом зазвичай роблять без врахування регістру:
const normalizedQuery = query.toLowerCase();
const matchesQuery = product.name
.toLowerCase()
.includes(normalizedQuery);Метод sort() змінює масив на місці. Якщо початкову колекцію потрібно зберегти, перед сортуванням слід зробити копію:
const sortedProducts = [...filteredProducts].sort(compareProducts);Функція порівняння має повертати:
від’ємне число, якщо a має бути перед b;
додатне число, якщо a має бути після b;
0, якщо порядок між елементами однаковий.
Напрямок сортування можна змінювати множником:
const direction = order === 'desc' ? -1 : 1;
return compareValues(a, b) * direction;Щоб порядок був передбачуваним, корисно додати друге поле порівняння. Наприклад, якщо ціни однакові, сортувати за id:
if (a.price === b.price) {
return a.id - b.id;
}Нижче наведено самодостатній приклад на вбудованому модулі node:http. Сервер підтримує:
пошук за назвою через q;
фільтрування за категорією;
мінімальну та максимальну ціну;
сортування за price, rating або name;
напрямок asc або desc;
перевірку невідомих і повторних параметрів.
const http = require('node:http');
const products = [
{ id: 1, name: 'JavaScript для початківців', category: 'books', price: 450, rating: 4.8 },
{ id: 2, name: 'Механічна клавіатура', category: 'devices', price: 2200, rating: 4.5 },
{ id: 3, name: 'Node.js у прикладах', category: 'books', price: 700, rating: 4.9 },
{ id: 4, name: 'USB-мікрофон', category: 'devices', price: 1800, rating: 4.2 },
{ id: 5, name: 'Блокнот розробника', category: 'stationery', price: 180, rating: 4.1 }
];
const allowedParameters = new Set([
'q',
'category',
'minPrice',
'maxPrice',
'sort',
'order'
]);
const sortableFields = new Set(['price', 'rating', 'name']);
function parseOptionalNumber(value, parameterName) {
if (value === null || value === '') {
return null;
}
const number = Number(value);
if (!Number.isFinite(number) || number < 0) {
throw new Error(
`Параметр "${parameterName}" має бути невід'ємним числом`
);
}
return number;
}
function readQueryParameters(requestUrl) {
for (const parameterName of requestUrl.searchParams.keys()) {
if (!allowedParameters.has(parameterName)) {
throw new Error(`Невідомий параметр "${parameterName}"`);
}
if (requestUrl.searchParams.getAll(parameterName).length > 1) {
throw new Error(
`Параметр "${parameterName}" не можна передавати більше одного разу`
);
}
}
const query = requestUrl.searchParams.get('q') ?? '';
const category = requestUrl.searchParams.get('category') ?? '';
const sort = requestUrl.searchParams.get('sort') ?? 'id';
const order = requestUrl.searchParams.get('order') ?? 'asc';
if (query.length > 100) {
throw new Error('Параметр "q" не може містити більше 100 символів');
}
if (!sortableFields.has(sort) && sort !== 'id') {
throw new Error(
'Параметр "sort" може мати значення: id, price, rating або name'
);
}
if (order !== 'asc' && order !== 'desc') {
throw new Error('Параметр "order" може мати значення asc або desc');
}
const minPrice = parseOptionalNumber(
requestUrl.searchParams.get('minPrice'),
'minPrice'
);
const maxPrice = parseOptionalNumber(
requestUrl.searchParams.get('maxPrice'),
'maxPrice'
);
if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
throw new Error('Параметр "minPrice" не може бути більшим за "maxPrice"');
}
return {
query: query.toLowerCase(),
category,
minPrice,
maxPrice,
sort,
order
};
}
function compareValues(first, second) {
if (first < second) {
return -1;
}
if (first > second) {
return 1;
}
return 0;
}
function getSortedProducts(parameters) {
const filteredProducts = products.filter((product) => {
const matchesQuery =
parameters.query === '' ||
product.name.toLowerCase().includes(parameters.query);
const matchesCategory =
parameters.category === '' ||
product.category === parameters.category;
const matchesMinPrice =
parameters.minPrice === null ||
product.price >= parameters.minPrice;
const matchesMaxPrice =
parameters.maxPrice === null ||
product.price <= parameters.maxPrice;
return (
matchesQuery &&
matchesCategory &&
matchesMinPrice &&
matchesMaxPrice
);
});
const direction = parameters.order === 'desc' ? -1 : 1;
return [...filteredProducts].sort((first, second) => {
const firstValue = first[parameters.sort];
const secondValue = second[parameters.sort];
const primaryResult = compareValues(firstValue, secondValue);
if (primaryResult !== 0) {
return primaryResult * direction;
}
// Додаткове поле робить результат стабільним для однакових значень.
return first.id - second.id;
});
}
function sendJson(response, statusCode, data) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8'
});
response.end(JSON.stringify(data));
}
const server = http.createServer((request, response) => {
const requestUrl = new URL(
request.url,
`http://${request.headers.host || 'localhost'}`
);
if (request.method !== 'GET' || requestUrl.pathname !== '/products') {
sendJson(response, 404, {
error: 'Маршрут не знайдено'
});
return;
}
try {
const parameters = readQueryParameters(requestUrl);
const result = getSortedProducts(parameters);
sendJson(response, 200, {
data: result,
count: result.length
});
} catch (error) {
sendJson(response, 400, {
error: error.message
});
}
});
server.listen(3000, () => {
console.log('Сервер запущено на http://localhost:3000');
});Запустіть файл командою:
node server.jsПриклади запитів:
GET /products
GET /products?category=books
GET /products?minPrice=300&maxPrice=1000
GET /products?q=node&sort=rating&order=desc
GET /products?category=devices&sort=price&order=ascЗапит:
/products?category=books&minPrice=300&sort=price&order=descповерне лише книги з ціною від 300, відсортовані за ціною від більшої до меншої.
Для передбачуваного результату операції варто виконувати в такому порядку:
Прочитати та перевірити параметри.
Відфільтрувати колекцію.
Створити копію результату.
Відсортувати копію.
Повернути відповідь.
Сортування до фільтрування зазвичай виконує зайву роботу: сервер сортує елементи, які все одно не потраплять у відповідь.
У прикладі колекція зберігається в пам’яті, тому фільтрування виконується методами JavaScript. Якщо дані зберігаються в базі даних, значення фільтрів потрібно передавати через параметризовані запити або механізм запитів конкретного драйвера.
Назви полів сортування все одно потрібно брати лише з allowlist:
const sortColumns = {
price: 'price',
rating: 'rating',
name: 'name'
};
const column = sortColumns[sortParameter];
if (!column) {
throw new Error('Недозволене поле сортування');
}Значення фільтрів і назви полів мають різну природу:
значення фільтрів можна передавати як параметри запиту;
назви колонок або напрямок сортування потрібно вибирати з наперед визначеного списку.
// Небезпечно: клієнт передає довільне поле
const field = requestUrl.searchParams.get('sort');
products.sort((a, b) => a[field] - b[field]);Потрібно перевіряти field через allowlist і мати явну обробку кожного дозволеного поля.
const minPrice = Number(requestUrl.searchParams.get('minPrice'));Якщо параметр відсутній або містить текст, результат може бути NaN. Потрібно окремо обробити відсутнє значення, перевірити Number.isFinite() та допустимий діапазон.
products.sort(compareProducts);Такий виклик змінює масив products. Якщо він використовується для наступних запитів, порядок може залежати від попередніх операцій. Сортуйте копію:
const sortedProducts = [...products].sort(compareProducts);orderНе слід вважати будь-яке значення, крім desc, правильним спадним сортуванням:
const direction = order === 'desc' ? -1 : 1;У такому варіанті помилка на кшталт order=down непомітно перетвориться на asc. Краще явно дозволяти лише asc і desc, а для інших значень повертати помилку.
Якщо багато елементів мають однакову ціну або рейтинг, результат може бути складним для пояснення користувачу. Додаткове порівняння за id забезпечує стабільний порядок.
Якщо сервер мовчки ігнорує sort=unknown, клієнту складно зрозуміти, чому відповідь не відповідає очікуванню. Некоректні параметри краще відхиляти зі статусом 400 Bad Request.
Параметри запиту потрібно читати як неперевірені зовнішні дані.
Рядкові значення слід явно перетворювати на числа та перевіряти.
Для сортування використовуйте allowlist дозволених полів.
Фільтруйте колекцію перед сортуванням.
Не змінюйте початковий масив, якщо він використовується повторно.
Перевіряйте напрямок сортування та невідомі параметри.
Додавайте другий критерій порівняння для стабільного результату.
Для помилкових параметрів повертайте зрозумілу відповідь зі статусом 400.