Пошук уроків, статей та іншого контенту
Визначите єдиний формат успішних відповідей, колекцій, метаданих і пагінованих результатів.
Клієнт API не повинен щоразу вгадувати структуру відповіді. Якщо один endpoint повертає об’єкт безпосередньо, інший — об’єкт у полі data, а третій додає пагінацію на верхньому рівні, клієнтський код стає складнішим.
Замість цього домовимося про єдиний формат успішної відповіді:
{
"data": {},
"meta": {}
}Поле data містить основний результат, а
metaДля одного ресурсу:
{
"data": {
"id": 42,
"name": "Ada Lovelace"
}
}Для колекції:
{
"data": [
{
"id": 42,
"name": "Ada Lovelace"
},
{
"id": 43,
"name": "Grace Hopper"
}
],
"meta": {
"total": 2
}
}Для пагінованої колекції:
{
"data": [
{
"id": 42,
"name": "Ada Lovelace"
}
],
"meta": {
"pagination": {
"page": 2,
"limit": 1,
"total": 3,
"totalPages": 3
}
}
}Такий підхід дає клієнту передбачувану структуру незалежно від endpoint.
Для endpoint, який повертає один ресурс, достатньо поля data.
У NestJS контролер може повертати звичайний об’єкт:
return {
data: user,
};NestJS автоматично серіалізує цей об’єкт у JSON.
Для опису структури відповіді зручно використовувати TypeScript-тип:
export interface ApiResponse<T> {
data: T;
}Тоді метод контролера явно повідомляє, що саме він повертає:
@Get(':id')
findOne(@Param('id') id: string): ApiResponse<UserResponseDto> {
const user = this.usersService.findOne(Number(id));
return {
data: user,
};
}Тип ApiResponse<T> є узагальненим. Замість T можна передати будь-який тип:
UserResponseDto для одного користувача;
UserResponseDto[] для колекції;
інший DTO для іншого ресурсу.
Для списку ресурсів поле data містить масив:
export interface ApiCollectionResponse<T, M extends object = object> {
data: T[];
meta?: M;
}Наприклад:
return {
data: users,
meta: {
total: users.length,
},
};Навіть якщо колекція порожня, структура залишається такою самою:
{
"data": [],
"meta": {
"total": 0
}
}Не варто повертати null або змінювати форму відповіді залежно від кількості елементів. Порожня колекція — це нормальний результат запиту, тому для неї використовується саме порожній масив.
Метадані не є основними даними ресурсу. Вони описують результат запиту або містять службову інформацію.
Приклади метаданих:
загальна кількість елементів;
параметри пагінації;
інформація про застосовані фільтри;
ознаки, необхідні клієнту для навігації між сторінками.
Метадані варто групувати всередині meta, а не змішувати з елементами data.
Наприклад, для пагінації краще використовувати:
{
"data": [],
"meta": {
"pagination": {
"page": 1,
"limit": 20,
"total": 0,
"totalPages": 0
}
}
}А не:
{
"data": [],
"page": 1,
"limit": 20,
"total": 0
}Вкладена структура meta.pagination залишає місце для інших видів метаданих у майбутньому.
Пагінація зазвичай використовує такі значення:
page — номер поточної сторінки;
limit — максимальна кількість елементів на сторінці;
total — загальна кількість елементів;
totalPages — загальна кількість сторінок.
Кількість сторінок обчислюється так:
const totalPages = Math.ceil(total / limit);Якщо total дорівнює нулю, totalPages також дорівнює нулю.
Опис типів для пагінації може виглядати так:
export interface PaginationMeta {
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
export interface ApiPaginatedResponse<T> {
data: T[];
meta: PaginationMeta;
}Нижче наведено повний приклад контролера та сервісу. Він демонструє:
відповідь одного ресурсу;
відповідь колекції;
пагінацію;
обмеження максимального limit;
стабільну структуру JSON-відповідей.
import {
Controller,
Get,
Injectable,
NotFoundException,
Param,
Query,
} from '@nestjs/common';
interface ApiResponse<T> {
data: T;
}
interface ApiCollectionResponse<T, M extends object = object> {
data: T[];
meta?: M;
}
interface PaginationMeta {
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
interface UserResponseDto {
id: number;
name: string;
email: string;
}
interface UserEntity {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private readonly users: UserEntity[] = [
{
id: 1,
name: 'Ada Lovelace',
email: 'ada@example.com',
},
{
id: 2,
name: 'Grace Hopper',
email: 'grace@example.com',
},
{
id: 3,
name: 'Margaret Hamilton',
email: 'margaret@example.com',
},
];
findOne(id: number): UserResponseDto {
const user = this.users.find((item) => item.id === id);
if (!user) {
throw new NotFoundException('Користувача не знайдено');
}
return {
id: user.id,
name: user.name,
email: user.email,
};
}
findAll(page: number, limit: number): {
users: UserResponseDto[];
total: number;
} {
const start = (page - 1) * limit;
const paginatedUsers = this.users.slice(start, start + limit);
return {
users: paginatedUsers.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
})),
total: this.users.length,
};
}
}
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll(
@Query('page') pageQuery?: string,
@Query('limit') limitQuery?: string,
): ApiCollectionResponse<UserResponseDto, PaginationMeta> {
const parsedPage = Number.parseInt(pageQuery ?? '1', 10);
const parsedLimit = Number.parseInt(limitQuery ?? '10', 10);
// Неправильні значення замінюємо безпечними значеннями за замовчуванням.
const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
const requestedLimit =
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 10;
// Не дозволяємо клієнту запитувати надто велику сторінку.
const limit = Math.min(requestedLimit, 100);
const result = this.usersService.findAll(page, limit);
const totalPages = Math.ceil(result.total / limit);
return {
data: result.users,
meta: {
pagination: {
page,
limit,
total: result.total,
totalPages,
},
},
};
}
@Get(':id')
findOne(@Param('id') idParam: string): ApiResponse<UserResponseDto> {
const id = Number.parseInt(idParam, 10);
const user = this.usersService.findOne(id);
return {
data: user,
};
}
}Запит:
GET /users?page=2&limit=1може повернути:
{
"data": [
{
"id": 2,
"name": "Grace Hopper",
"email": "grace@example.com"
}
],
"meta": {
"pagination": {
"page": 2,
"limit": 1,
"total": 3,
"totalPages": 3
}
}
}А запит:
GET /users/2поверне:
{
"data": {
"id": 2,
"name": "Grace Hopper",
"email": "grace@example.com"
}
}Не обов’язково повертати з API об’єкт, який використовується всередині сервісу або зберігається в базі даних.
Внутрішня сутність може містити поля, які не повинні потрапляти до клієнта:
interface UserEntity {
id: number;
name: string;
email: string;
passwordHash: string;
}Публічний DTO може містити лише дозволені поля:
interface UserResponseDto {
id: number;
name: string;
email: string;
}Перетворення сутності на DTO захищає API від випадкової публікації внутрішніх даних і дає змогу змінювати внутрішню модель без зміни контракту відповіді.
Для невеликих проєктів таке перетворення можна виконувати безпосередньо в сервісі. У більших проєктах його варто винести в окремий мапер або статичний метод DTO.
Під час проєктування відповідей дотримуйтеся кількох правил:
Використовуйте data для основного результату.
Використовуйте meta для додаткової інформації.
Для одного ресурсу повертайте об’єкт у data.
Для колекції повертайте масив у data.
Для порожньої колекції повертайте data: [].
Для пагінації зберігайте дані пагінації в meta.pagination.
Не змінюйте структуру відповіді залежно від кількості результатів.
Не повертайте внутрішні сутності безпосередньо, якщо вони містять службові поля.
Обмежуйте максимальне значення limit.
Фіксуйте обраний формат як частину контракту API.
Погано:
{
"id": 1,
"name": "Ada Lovelace"
}і для іншого endpoint:
{
"data": {
"id": 2,
"name": "Grace Hopper"
}
}Клієнту доводиться обробляти два різні формати. Краще завжди використовувати data.
Погано:
{
"data": [],
"total": 0,
"page": 1
}Краще:
{
"data": [],
"meta": {
"pagination": {
"page": 1,
"limit": 10,
"total": 0,
"totalPages": 0
}
}
}null для порожньої колекціїnull означає відсутність значення, а порожній масив — коректну колекцію без елементів. Для списків використовуйте [].
limitБез обмеження клієнт може запитати десятки тисяч записів одним HTTP-запитом. Це збільшує навантаження на базу даних, сервер і мережу.
Встановлюйте максимальне значення, наприклад 100, і замінюйте більші значення на допустимий максимум або повертайте помилку валідації.
total і кількістю елементів на сторінціtotal має позначати загальну кількість доступних елементів, а не кількість елементів у поточній відповіді.
Наприклад, якщо на сторінці 2 елементи, але всього в системі 57 записів:
{
"meta": {
"pagination": {
"page": 1,
"limit": 2,
"total": 57,
"totalPages": 29
}
}
}Єдиний формат спрощує роботу клієнтів API.
Основні дані розміщуйте в полі data.
Додаткові відомості розміщуйте в полі meta.
Один ресурс повертайте як об’єкт у data.
Колекцію повертайте як масив у data.
Пагінацію описуйте через meta.pagination.
Для порожньої колекції використовуйте data: [].
Відокремлюйте DTO відповіді від внутрішніх сутностей.
Завжди перевіряйте та обмежуйте параметри page і limit.