Пошук уроків, статей та іншого контенту
Спроєктуєте пошук за текстовими полями, його параметри, обмеження та узгоджений формат результатів.
Пошук за текстом — це endpoint, який приймає текстовий запит, перевіряє його параметри та повертає знайдені ресурси в узгодженому форматі.
Для статей можна спроєктувати endpoint:
GET /articles/search?q=nestjsПошук може перевіряти кілька текстових полів:
title — заголовок статті;
description — короткий опис;
content — основний текст.
Клієнт не повинен знати внутрішню реалізацію пошуку. Для нього важливі:
назва параметрів;
допустимі значення;
максимальна довжина запиту;
формат успішної відповіді;
поведінка, якщо нічого не знайдено.
Мінімальний набір параметрів:
| Параметр | Тип | Обов’язковий | Призначення | |---|---|---:|---| | q | string | так | Текст для пошуку | | limit | number | ні | Кількість результатів | | offset | number | ні | Кількість пропущених результатів |
Приклади запитів:
GET /articles/search?q=nestjs
GET /articles/search?q=rest%20api&limit=10
GET /articles/search?q=typescript&limit=20&offset=40Пробіли та спеціальні символи в параметрі q потрібно кодувати в URL. Наприклад, пробіл може бути представлений як %20.
Зручно встановити значення за замовчуванням:
limit = 20;
offset = 0.
Так клієнту не потрібно щоразу передавати всі параметри.
Пошуковий endpoint повинен захищати API від некоректних або надто дорогих запитів:
q не може бути порожнім;
мінімальна довжина q — 2 символи;
максимальна довжина q — 100 символів;
limit має бути додатним;
limit не може перевищувати 100;
offset не може бути від’ємним.
Обмеження limit важливе, навіть якщо клієнт контролюється вами. Будь-який HTTP-клієнт може вручну надіслати запит із limit=1000000.
У NestJS параметри запиту зручно описувати через DTO. DTO централізує правила валідації та не дає контролеру перетворитися на набір ручних перевірок.
// src/articles/dto/search-articles.dto.ts
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUrl,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class SearchArticlesDto {
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
q: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset = 0;
}@Type(() => Number) перетворює значення з URL у число. Без цього limit і offset надходили б у DTO як рядки, адже всі query-параметри HTTP спочатку є текстовими.
Наприклад:
limit=10після трансформації стане числом 10.
У main.ts потрібно увімкнути глобальний ValidationPipe.
// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(3000);
}
bootstrap();Основні опції:
transform: true — перетворює значення відповідно до типів DTO;
whitelist: true — видаляє властивості, яких немає в DTO;
forbidNonWhitelisted: true — замість мовчазного видалення повертає помилку для невідомих параметрів.
Наприклад, запит:
GET /articles/search?q=nest&unknown=valueбуде відхилено, якщо unknown не описаний у DTO.
За невалідних параметрів NestJS зазвичай повертає статус 400 Bad Request.
Для списку результатів варто повертати не просто масив, а об’єкт із результатами та метаданими:
{
"items": [
{
"id": 1,
"title": "Пошук у NestJS",
"description": "Створення пошукового endpoint"
}
],
"meta": {
"query": "nestjs",
"offset": 0,
"limit": 20,
"total": 1,
"hasMore": false
}
}Такий формат дає клієнту всю необхідну інформацію:
items — поточна сторінка результатів;
query — нормалізований пошуковий запит;
offset — позиція початку поточної сторінки;
limit — запитаний максимальний розмір сторінки;
total — загальна кількість збігів;
hasMore — чи існують наступні результати.
Якщо збігів немає, успішною відповіддю буде 200 OK з порожнім масивом:
{
"items": [],
"meta": {
"query": "unknown",
"offset": 0,
"limit": 20,
"total": 0,
"hasMore": false
}
}Пошук не повинен повертати 404 Not Found, якщо просто не знайдено жодного ресурсу. 404 означає, що сам endpoint або конкретний ресурс не існує.
Нижче наведено повний приклад із даними в пам’яті. У реальному застосунку масив буде замінено на запит до бази даних, але контракт endpoint залишиться таким самим.
// src/articles/articles.service.ts
import { Injectable } from '@nestjs/common';
import { SearchArticlesDto } from './dto/search-articles.dto';
interface Article {
id: number;
title: string;
description: string;
content: string;
}
@Injectable()
export class ArticlesService {
private readonly articles: Article[] = [
{
id: 1,
title: 'Пошук у NestJS',
description: 'Створення пошукового endpoint у REST API',
content: 'У цьому матеріалі розглядається валідація параметрів пошуку.',
},
{
id: 2,
title: 'Валідація DTO',
description: 'Перевірка параметрів запиту за допомогою class-validator',
content: 'DTO допомагають описати структуру вхідних даних.',
},
{
id: 3,
title: 'Основи REST API',
description: 'Проєктування HTTP endpoint',
content: 'REST API використовує стандартні HTTP-методи та статус-коди.',
},
];
search(query: SearchArticlesDto) {
const normalizedQuery = query.q.trim().toLocaleLowerCase();
const matchingArticles = this.articles.filter((article) => {
const searchableText = [
article.title,
article.description,
article.content,
]
.join(' ')
.toLocaleLowerCase();
return searchableText.includes(normalizedQuery);
});
const items = matchingArticles
.slice(query.offset, query.offset + query.limit)
.map(({ id, title, description }) => ({
id,
title,
description,
}));
return {
items,
meta: {
query: normalizedQuery,
offset: query.offset,
limit: query.limit,
total: matchingArticles.length,
hasMore: query.offset + query.limit < matchingArticles.length,
},
};
}
}У прикладі:
q очищається за допомогою trim().
Пошук виконується без урахування регістру.
Перевіряються три текстові поля.
Спочатку визначається загальна кількість збігів.
Потім застосовуються offset і limit.
У відповідь повертаються лише потрібні поля статті.
Повернення лише потрібних полів також називають формуванням проєкції відповіді. Не варто без потреби повертати весь content, внутрішні поля або службову інформацію.
// src/articles/articles.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { SearchArticlesDto } from './dto/search-articles.dto';
@Controller('articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Get('search')
search(@Query() query: SearchArticlesDto) {
return this.articlesService.search(query);
}
}Тепер запит:
GET http://localhost:3000/articles/search?q=rest&limit=10може повернути:
{
"items": [
{
"id": 3,
"title": "Основи REST API",
"description": "Проєктування HTTP endpoint"
}
],
"meta": {
"query": "rest",
"offset": 0,
"limit": 10,
"total": 1,
"hasMore": false
}
}// src/articles/articles.module.ts
import { Module } from '@nestjs/common';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';
@Module({
controllers: [ArticlesController],
providers: [ArticlesService],
})
export class ArticlesModule {}// src/app.module.ts
import { Module } from '@nestjs/common';
import { ArticlesModule } from './articles/articles.module';
@Module({
imports: [ArticlesModule],
})
export class AppModule {}Є два поширені варіанти проєктування.
Endpoint завжди шукає в одних і тих самих полях:
GET /articles/search?q=typescriptПереваги:
простий контракт;
короткі URL;
клієнт не залежить від структури бази даних;
менше можливостей для некоректних запитів.
Цей варіант добре підходить для загального пошуку по статтях.
Можна дозволити клієнту вибирати поле:
GET /articles/search?q=nestjs&field=titleАле в такому випадку потрібно мати чіткий список дозволених значень, наприклад:
title
description
contentНе можна безпосередньо використовувати значення field для побудови SQL-запиту. Назви колонок не повинні надходити до запиту без перевірки. Якщо така можливість необхідна, параметр потрібно зіставляти з наперед визначеним списком дозволених полів.
Для загального пошуку фіксований набір полів зазвичай є простішим і безпечнішим рішенням.
Пошуковий текст варто нормалізувати однаково в усіх частинах застосунку:
прибирати пробіли на початку та в кінці;
порівнювати текст без урахування регістру;
відхиляти порожній рядок;
обмежувати довжину значення.
Наприклад, значення:
" NestJS "перетворюється на:
"nestjs"У прикладі нормалізація виконується в сервісі. Якщо пошук реалізується через базу даних, важливо, щоб порівняння та індекси були узгоджені з потрібною чутливістю до регістру.
Пошук по масиву підходить для демонстрації, але для реального застосунку дані потрібно фільтрувати на рівні бази даних.
Важливі правила:
не завантажуйте всі записи в пам’ять, щоб відфільтрувати їх у JavaScript;
передавайте значення пошуку як параметризований параметр;
застосовуйте пагінацію на рівні SQL-запиту;
окремо отримуйте кількість збігів, якщо вона потрібна в meta;
перевірте індексацію текстових полів для очікуваного навантаження.
Небезпечно будувати SQL через конкатенацію рядків:
// Неправильно: значення користувача вставляється в SQL без параметризації
const sql = `SELECT * FROM articles WHERE title LIKE '%${query.q}%'`;Потрібно використовувати параметри, які підтримує конкретний драйвер або ORM:
// Правильний підхід залежить від ORM або драйвера
const sql = `
SELECT id, title, description
FROM articles
WHERE title LIKE :search
`;
const parameters = {
search: `%${query.q}%`,
};Конкретний синтаксис параметрів відрізняється між бібліотеками, але принцип залишається незмінним: значення користувача не повинно ставати частиною SQL-коду.
Приклади некоректних запитів:
GET /articles/search
GET /articles/search?q=a
GET /articles/search?q=nestjs&limit=0
GET /articles/search?q=nestjs&limit=1000
GET /articles/search?q=nestjs&offset=-1Для них API має повернути 400 Bad Request.
Клієнт може отримати відповідь такого типу:
{
"statusCode": 400,
"message": [
"q must be longer than or equal to 2 characters",
"limit must not be greater than 100"
],
"error": "Bad Request"
}Формат повідомлення про помилки може відрізнятися залежно від налаштувань NestJS. Важливо, щоб клієнт стабільно отримував помилку 4xx, а не необроблену помилку сервера 500.
Розглянемо 55 знайдених статей:
GET /articles/search?q=api&limit=20&offset=0Повертає перші 20 результатів.
GET /articles/search?q=api&limit=20&offset=20Повертає результати з 21-го по 40-й.
GET /articles/search?q=api&limit=20&offset=40Повертає останні 15 результатів.
Для останньої відповіді:
{
"items": [],
"meta": {
"query": "api",
"offset": 60,
"limit": 20,
"total": 55,
"hasMore": false
}
}Порожній items є нормальною відповіддю, якщо offset вийшов за межі результатів.
Для стабільної пагінації результати також повинні мати визначений порядок. Якщо порядок не заданий, база даних не зобов’язана повертати рядки в однаковій послідовності. Зазвичай для пошуку задають сортування за релевантністю, датою або ідентифікатором.
404, якщо результатів немаєВідсутність збігів не означає відсутність endpoint. Повертайте 200 OK і порожній масив items.
limitЗапит із великим limit може створити надмірне навантаження на базу даних і мережу. Завжди встановлюйте максимальне значення.
Якщо клієнт очікує пошук у заголовку, а API перевіряє тільки опис, поведінка буде неочевидною. Явно визначте набір текстових полів.
Запити NestJS, nestjs і NESTJS зазвичай повинні поводитися однаково. Правило порівняння потрібно визначити під час проєктування endpoint.
Завантаження всієї таблиці в пам’ять погано масштабується. Фільтрація, обмеження кількості та пропуск записів мають виконуватися якомога ближче до джерела даних.
Не передавайте довільні назви полів, оператори або фрагменти SQL без перевірки. Параметри пошуку повинні мати чіткі типи та дозволені значення.
Не повертайте інколи масив, а інколи об’єкт із items. Клієнт повинен отримувати однакову структуру навіть тоді, коли результатів немає.
Пошук у REST API можна представити окремим endpoint GET /articles/search.
Параметр q має бути обов’язковим і проходити перевірку довжини.
Для пагінації зручно використовувати limit і offset.
Значення limit потрібно обмежувати, щоб захистити API від надмірних запитів.
Пошук за кількома текстовими полями треба описати явно.
Відсутність збігів — це 200 OK із порожнім items, а не 404.
Відповідь варто повертати у стабільному форматі з items і meta.
У NestJS правила параметрів найкраще описувати через DTO та ValidationPipe.
У реальному застосунку фільтрація й пагінація мають виконуватися на рівні бази даних.