Тема 7. Фреймворк NestJS. Контролери та маршрутизація

Робота з заголовками запиту та відповіді

@Headers, @Header, кастомні HTTP-заголовки

Робота з заголовками запиту та відповіді

🎯 Мета лекції

  • Опанувати використання декоратора @Headers для витягування заголовків HTTP-запиту
  • Навчитися встановлювати заголовки HTTP-відповіді через декоратор @Header
  • Зрозуміти призначення стандартних HTTP-заголовків у RESTful API
  • Вивчити роботу з автентифікаційними заголовками (Authorization, Bearer tokens)
  • Практикувати створення кастомних заголовків для метаданих API
  • Засвоїти принципи безпечної роботи з заголовками та уникнення витоків даних

🔑 Ключові терміни

  • HTTP Header (HTTP-заголовок): пара ключ-значення, що передає метадані про запит або відповідь
  • Request Header (заголовок запиту): заголовок, який клієнт надсилає серверу з додатковою інформацією
  • Response Header (заголовок відповіді): заголовок, який сервер повертає клієнту для контролю поведінки
  • Bearer Token (Bearer-токен): схема автентифікації через заголовок Authorization
  • Content Negotiation (узгодження вмісту): процес визначення формату відповіді через заголовки Accept
  • CORS (Cross-Origin Resource Sharing): механізм безпеки браузера для контролю міжсайтових запитів

HTTP-заголовки: метадані протоколу

HTTP-заголовки (headers) є критично важливою частиною протоколу HTTP, що дозволяє клієнту та серверу обмінюватися метаданими про запит та відповідь. На відміну від тіла запиту (request body), яке містить основні дані операції, заголовки передають додаткову контекстну інформацію: тип вмісту, мову, автентифікаційні дані, інструкції кешування, відомості про клієнта тощо.

Розуміння HTTP-заголовків є фундаментальним для розробки професійних API. Правильне використання заголовків дозволяє реалізувати автентифікацію, керувати кешуванням, вести логування запитів, впроваджувати версіонування API та забезпечувати безпеку застосунку. Водночас неправильна робота з заголовками може призвести до витоків чутливої інформації, проблем із продуктивністю та вразливостей безпеки.

Анатомія HTTP-повідомлення з заголовками

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "HTTP Request (Запит)" as REQ {
    rectangle "Request Line" as RL #E2E8F0
    note right of RL: GET /api/users/123 HTTP/1.1
    
    rectangle "Request Headers" as RH #DBEAFE {
        rectangle "Host: api.example.com" as H1 #F1F5F9
        rectangle "Authorization: Bearer eyJhbGc..." as H2 #FEF3C7
        rectangle "Content-Type: application/json" as H3 #F1F5F9
        rectangle "Accept: application/json" as H4 #F1F5F9
        rectangle "User-Agent: Mozilla/5.0..." as H5 #F1F5F9
        rectangle "X-Request-ID: abc-123-def" as H6 #FED7AA
    }
    
    rectangle "Empty Line" as EL1 #E2E8F0
    rectangle "Request Body" as RB #DCFCE7
}

rectangle "HTTP Response (Відповідь)" as RES {
    rectangle "Status Line" as SL #E2E8F0
    note right of SL: HTTP/1.1 200 OK
    
    rectangle "Response Headers" as RSH #DBEAFE {
        rectangle "Content-Type: application/json" as RH1 #F1F5F9
        rectangle "Content-Length: 156" as RH2 #F1F5F9
        rectangle "Cache-Control: max-age=3600" as RH3 #FEF3C7
        rectangle "X-RateLimit-Remaining: 99" as RH4 #FED7AA
        rectangle "X-API-Version: 2.0" as RH5 #FED7AA
    }
    
    rectangle "Empty Line" as EL2 #E2E8F0
    rectangle "Response Body" as RSB #DCFCE7
}

note bottom of H6
  Кастомні заголовки зазвичай
  починаються з префіксу X-
end note

note bottom of RH4
  Response headers контролюють
  поведінку клієнта та кешування
end note

@enduml

HTTP-заголовки діляться на кілька категорій за призначенням:

Загальні заголовки (General Headers): застосовуються як до запитів, так і до відповідей

  • Date — дата та час створення повідомлення
  • Connection — контроль з'єднання (keep-alive, close)
  • Cache-Control — інструкції кешування

Заголовки запиту (Request Headers): надсилаються клієнтом

  • Host — доменне ім'я сервера (обов'язковий у HTTP/1.1)
  • User-Agent — інформація про клієнтський застосунок
  • Accept — підтримувані формати відповіді
  • Authorization — автентифікаційні дані
  • Referer — URL сторінки, з якої надійшов запит

Заголовки відповіді (Response Headers): надсилаються сервером

  • Server — інформація про серверне програмне забезпечення
  • Set-Cookie — встановлення cookies у клієнта
  • Location — URL для перенаправлення
  • WWW-Authenticate — схема автентифікації для 401 Unauthorized

Заголовки представлення (Representation Headers): описують тіло повідомлення

  • Content-Type — MIME-тип вмісту (application/json, text/html)
  • Content-Length — розмір тіла у байтах
  • Content-Encoding — алгоритм стиснення (gzip, deflate)
  • Content-Language — мова вмісту (uk, en)
У HTTP/2 та HTTP/3 заголовки передаються у стиснутому бінарному форматі замість текстового представлення, що значно підвищує продуктивність. Проте на рівні застосунку (NestJS) вони залишаються текстовими парами ключ-значення завдяки абстракції фреймворку.

Чому заголовки важливі для API

Розглянемо типовий сценарій взаємодії клієнта з API:

1. Клієнт надсилає запит з заголовком Authorization для автентифікації
2. Заголовок Accept: application/json вказує бажаний формат відповіді
3. Заголовок X-Request-ID дозволяє відстежити запит у логах
4. Сервер перевіряє токен з Authorization
5. Сервер встановлює Cache-Control для контролю кешування
6. Сервер додає X-RateLimit-* заголовки для інформування про ліміти
7. Сервер повертає відповідь з Content-Type: application/json

Без правильної роботи з заголовками неможливо реалізувати:

  • Автентифікацію та авторизацію — токени передаються через Authorization
  • Узгодження вмісту (Content Negotiation) — клієнт вказує бажаний формат через Accept
  • Керування кешуванням — Cache-Control та ETag оптимізують продуктивність
  • Rate Limiting — інформування клієнта про ліміти запитів
  • Версіонування API — X-API-Version дозволяє підтримувати сумісність
  • Трейсинг та моніторинг — X-Request-ID зв'язує запити у розподіленій системі
  • CORS — Access-Control-* заголовки забезпечують міжсайтову безпеку

Декоратор @Headers: витягування заголовків запиту

NestJS надає декоратор @Headers() для доступу до HTTP-заголовків, що надсилаються клієнтом у запиті. Подібно до декораторів @Param(), @Query() та @Body(), @Headers() має два режими роботи: витягування всіх заголовків як об'єкта або окремого заголовка за ключем.

Режим 1: Витягування всіх заголовків (@Headers())

Коли декоратор використовується без аргументів, він повертає об'єкт з усіма заголовками запиту:

import { Controller, Get, Headers } from '@nestjs/common';

@Controller('debug')
export class DebugController {
  @Get('headers')
  inspectHeaders(@Headers() headers: Record<string, string>) {
    console.log(headers);
    // {
    //   'host': 'localhost:3000',
    //   'user-agent': 'Mozilla/5.0...',
    //   'accept': 'application/json',
    //   'authorization': 'Bearer eyJhbGc...',
    //   'content-type': 'application/json',
    //   'x-request-id': 'abc-123-def'
    // }
    
    return {
      message: 'Request headers received',
      headers,
    };
  }
}
Імена заголовків у HTTP є регістронезалежними згідно зі специфікацією RFC 7230. Проте у Node.js (та Express/Fastify) всі імена заголовків автоматично перетворюються на малі літери (lowercase). Тому Authorization, AUTHORIZATION та authorization у запиті будуть доступні як headers['authorization'].

Режим 2: Витягування окремого заголовка (@Headers('key'))

Частіше використовується підхід з витягуванням конкретних заголовків за ключем:

import { Controller, Get, Headers } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Get('profile')
  getProfile(
    @Headers('authorization') authHeader: string,
    @Headers('user-agent') userAgent: string,
  ) {
    console.log('Authorization:', authHeader);
    // → "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
    
    console.log('User-Agent:', userAgent);
    // → "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..."
    
    return {
      message: 'Profile data',
      client: userAgent,
    };
  }
}

Якщо заголовок не було передано у запиті, значення буде undefined:

@Get('optional-header')
checkHeader(@Headers('x-custom-header') customHeader?: string) {
  if (!customHeader) {
    return { message: 'Custom header not provided' };
  }
  
  return { message: 'Custom header received', value: customHeader };
}
Типізуйте параметри заголовків як опціональні (header?: string) або додавайте валідацію через pipes, щоб обробляти випадки, коли заголовок відсутній:
@Get('secure')
secureEndpoint(
  @Headers('authorization') authHeader?: string,
) {
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    throw new UnauthorizedException('Missing or invalid Authorization header');
  }
  
  const token = authHeader.substring(7); // Видалити "Bearer "
  // Валідація токена...
}

Стандартні HTTP-заголовки запиту

Розглянемо найпоширеніші заголовки, з якими працюють серверні застосунки, та їх практичне застосування у NestJS контролерах.

Authorization: автентифікація користувача

Заголовок Authorization використовується для передачі автентифікаційних даних від клієнта до сервера. Найпопулярніші схеми автентифікації:

Bearer Token (JWT, OAuth 2.0):

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Basic Authentication (застаріле, небезпечне):

Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=

Приклад витягування токена:

import { Controller, Get, Headers, UnauthorizedException } from '@nestjs/common';

@Controller('api')
export class ApiController {
  @Get('protected')
  protectedRoute(@Headers('authorization') authHeader?: string) {
    if (!authHeader) {
      throw new UnauthorizedException('Authorization header is required');
    }

    // Bearer токен: "Bearer <token>"
    const [scheme, token] = authHeader.split(' ');

    if (scheme !== 'Bearer' || !token) {
      throw new UnauthorizedException('Invalid Authorization format. Expected: Bearer <token>');
    }

    // Валідація токена (делегується сервісу)
    const user = this.authService.validateToken(token);

    return {
      message: 'Access granted',
      user,
    };
  }
}
У реальних застосунках витягування та валідація токена з заголовка Authorization виконується автоматично через Guards (захисники), які ми розглянемо у наступних лекціях. Приклад вище демонструє низькорівневу роботу для розуміння механізму.

Content-Type: формат тіла запиту

Заголовок Content-Type вказує MIME-тип даних у тілі запиту. Для API найпоширеніші значення:

Content-Type: application/json          # JSON-дані
Content-Type: application/x-www-form-urlencoded  # Форма HTML
Content-Type: multipart/form-data       # Завантаження файлів
Content-Type: text/plain                # Простий текст
Content-Type: application/xml           # XML-дані

NestJS автоматично парсить тіло запиту на основі Content-Type, тому зазвичай не потрібно витягувати цей заголовок вручну. Проте іноді потрібна перевірка:

import { Controller, Post, Headers, Body, BadRequestException } from '@nestjs/common';

@Controller('webhooks')
export class WebhooksController {
  @Post('github')
  handleGitHubWebhook(
    @Headers('content-type') contentType: string,
    @Body() payload: any,
  ) {
    // GitHub надсилає вебхуки як application/json
    if (!contentType.includes('application/json')) {
      throw new BadRequestException('Content-Type must be application/json');
    }

    return this.webhooksService.processGitHubEvent(payload);
  }
}

Accept: узгодження формату відповіді

Заголовок Accept дозволяє клієнту вказати, які формати відповіді він підтримує. Сервер може використовувати цю інформацію для узгодження вмісту (content negotiation):

Accept: application/json               # Тільки JSON
Accept: application/json, application/xml  # JSON або XML
Accept: */*                            # Будь-який формат
Accept: application/json;q=0.9, text/plain;q=0.5  # З пріоритетами

Приклад узгодження формату:

import { Controller, Get, Headers, NotAcceptableException } from '@nestjs/common';

@Controller('data')
export class DataController {
  @Get('export')
  exportData(@Headers('accept') accept: string = '*/*') {
    const data = { name: 'Example', value: 123 };

    // Клієнт хоче JSON
    if (accept.includes('application/json')) {
      return data; // NestJS автоматично серіалізує у JSON
    }

    // Клієнт хоче XML
    if (accept.includes('application/xml')) {
      return `<data><name>${data.name}</name><value>${data.value}</value></data>`;
    }

    // Клієнт хоче CSV
    if (accept.includes('text/csv')) {
      return `name,value\n${data.name},${data.value}`;
    }

    // Непідтримуваний формат
    throw new NotAcceptableException('Supported formats: JSON, XML, CSV');
  }
}
Для складного узгодження вмісту використовуйте бібліотеку negotiator:
npm install negotiator @types/negotiator
import * as Negotiator from 'negotiator';

@Get('flexible')
flexibleEndpoint(@Headers() headers: Record<string, string>) {
  const negotiator = new Negotiator({ headers });
  const mediaType = negotiator.mediaType(['application/json', 'application/xml', 'text/csv']);
  
  // Повернути дані у обраному форматі
}

User-Agent: інформація про клієнта

Заголовок User-Agent містить інформацію про клієнтське програмне забезпечення (браузер, мобільний додаток, HTTP-клієнт):

User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
User-Agent: PostmanRuntime/7.29.2
User-Agent: MyMobileApp/2.1.0 (iOS 16.0; iPhone14,2)

Практичне застосування:

@Controller('analytics')
export class AnalyticsController {
  @Post('track')
  trackEvent(
    @Headers('user-agent') userAgent: string,
    @Body() event: any,
  ) {
    // Логування подій з інформацією про клієнта
    this.analyticsService.log({
      ...event,
      client: {
        userAgent,
        isMobile: this.isMobileDevice(userAgent),
        isBot: this.isBot(userAgent),
      },
    });

    return { success: true };
  }

  private isMobileDevice(userAgent: string): boolean {
    return /Mobile|Android|iPhone|iPad/i.test(userAgent);
  }

  private isBot(userAgent: string): boolean {
    return /bot|crawler|spider|crawling/i.test(userAgent);
  }
}

Парсинг User-Agent за допомогою бібліотеки ua-parser-js:

npm install ua-parser-js @types/ua-parser-js
import { UAParser } from 'ua-parser-js';

@Get('client-info')
getClientInfo(@Headers('user-agent') userAgent: string) {
  const parser = new UAParser(userAgent);
  const result = parser.getResult();

  return {
    browser: result.browser,  // { name: 'Chrome', version: '120.0.0.0' }
    os: result.os,            // { name: 'Mac OS', version: '10.15.7' }
    device: result.device,    // { type: 'mobile', vendor: 'Apple', model: 'iPhone' }
  };
}

Referer: джерело запиту

Заголовок Referer (помилкове написання Referrer) містить URL сторінки, з якої був ініційований запит:

Referer: https://example.com/page
З міркувань приватності багато браузерів обмежують або взагалі не передають заголовок Referer у певних ситуаціях (HTTPS → HTTP, політика Referrer-Policy). Не покладайтеся на цей заголовок для критичної бізнес-логіки або безпеки.

Приклад логування джерел трафіку:

@Post('signup')
async createUser(
  @Body() createUserDto: CreateUserDto,
  @Headers('referer') referer?: string,
) {
  const user = await this.usersService.create(createUserDto);

  // Логування джерела реєстрації для аналітики
  await this.analyticsService.trackSignup({
    userId: user.id,
    referrer: referer || 'direct',
    timestamp: new Date(),
  });

  return user;
}

Декоратор @Header: встановлення заголовків відповіді

Декоратор @Header() дозволяє встановлювати HTTP-заголовки у відповідях сервера. На відміну від @Headers(), який витягує заголовки з запиту, @Header() встановлює заголовки для відповіді. Цей декоратор застосовується на рівні методу контролера і додає заголовки до всіх відповідей цього методу.

Базовий синтаксис

import { Controller, Get, Header } from '@nestjs/common';

@Controller('data')
export class DataController {
  @Get('version')
  @Header('X-API-Version', '2.0')
  @Header('X-Custom-Header', 'CustomValue')
  getVersion() {
    return { version: '2.0', status: 'active' };
  }
}

Відповідь сервера:

GET /data/version - Відповідь з кастомними заголовками
$ curl -i http://localhost:3000/data/version
HTTP/1.1 200 OK
X-API-Version: 2.0
X-Custom-Header: CustomValue
Content-Type: application/json; charset=utf-8
Content-Length: 35
{"version":"2.0","status":"active"}

Множинні заголовки відповіді

Декоратор @Header() можна застосовувати кілька разів для встановлення різних заголовків:

@Controller('downloads')
export class DownloadsController {
  @Get('report.csv')
  @Header('Content-Type', 'text/csv')
  @Header('Content-Disposition', 'attachment; filename="report.csv"')
  @Header('Cache-Control', 'no-store, no-cache, must-revalidate')
  downloadReport() {
    const csvData = `Name,Age,Email\nAlice,28,alice@example.com\nBob,32,bob@example.com`;
    return csvData;
  }
}

У цьому прикладі:

  • Content-Type: text/csv вказує формат файлу
  • Content-Disposition: attachment змушує браузер завантажити файл замість відображення
  • Cache-Control забороняє кешування чутливих даних
Заголовок Content-Disposition: attachment; filename="..." є стандартним способом повідомити браузеру, що відповідь має бути збережена як файл. Параметр filename визначає ім'я файлу за замовчуванням у діалозі збереження.

Динамічні заголовки через @Res

Для встановлення заголовків динамічно (на основі логіки виконання) використовуйте об'єкт відповіді Express/Fastify:

import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller('dynamic')
export class DynamicController {
  @Get('headers')
  setDynamicHeaders(@Res({ passthrough: true }) res: Response) {
    // Встановлення заголовків на основі логіки
    const apiVersion = process.env.API_VERSION || '1.0';
    res.header('X-API-Version', apiVersion);
    
    const requestId = this.generateRequestId();
    res.header('X-Request-ID', requestId);
    
    // Встановлення кількох заголовків одночасно
    res.set({
      'X-Server-Time': new Date().toISOString(),
      'X-Environment': process.env.NODE_ENV,
    });

    return { message: 'Dynamic headers set', requestId };
  }

  private generateRequestId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
}
Параметр { passthrough: true } у декораторі @Res() є критично важливим. Без нього NestJS передає повний контроль над відповіддю Express/Fastify, і ви маєте вручну викликати res.send() або res.json(). З passthrough: true ви можете модифікувати заголовки, але NestJS автоматично відправить дані, повернуті з методу.

Практичні сценарії використання заголовків

Cache-Control: керування кешуванням

Заголовок Cache-Control контролює, як клієнти (браузери, проксі-сервери, CDN) мають кешувати відповіді:

@Controller('articles')
export class ArticlesController {
  @Get(':id')
  @Header('Cache-Control', 'public, max-age=3600') // Кешувати на 1 годину
  async getArticle(@Param('id') id: string) {
    return this.articlesService.findById(id);
  }

  @Get(':id/preview')
  @Header('Cache-Control', 'private, no-cache, no-store, must-revalidate') // Не кешувати
  async getPreview(@Param('id') id: string) {
    return this.articlesService.getPreview(id);
  }
}

Директиви Cache-Control:

Cache-Control: public, max-age=3600
# public - може кешуватися проксі та CDN
# max-age=3600 - дійсно 3600 секунд (1 година)

Динамічне встановлення Cache-Control на основі даних:

@Get(':id')
async getArticle(
  @Param('id') id: string,
  @Res({ passthrough: true }) res: Response,
) {
  const article = await this.articlesService.findById(id);

  // Опубліковані статті кешуються, чернетки — ні
  if (article.published) {
    res.header('Cache-Control', 'public, max-age=3600');
  } else {
    res.header('Cache-Control', 'private, no-cache');
  }

  return article;
}

X-RateLimit-*: інформування про ліміти

Заголовки X-RateLimit-* інформують клієнта про залишок доступних запитів та час скидання лічильника:

import { Controller, Get, Headers, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller('api')
export class ApiController {
  @Get('data')
  async getData(
    @Headers('x-api-key') apiKey: string,
    @Res({ passthrough: true }) res: Response,
  ) {
    // Перевірка лімітів для даного API-ключа
    const rateLimitInfo = await this.rateLimitService.checkLimit(apiKey);

    // Встановлення заголовків про ліміти
    res.set({
      'X-RateLimit-Limit': rateLimitInfo.limit.toString(),          // Загальний ліміт
      'X-RateLimit-Remaining': rateLimitInfo.remaining.toString(),  // Залишок запитів
      'X-RateLimit-Reset': rateLimitInfo.resetTime.toString(),      // Unix timestamp скидання
    });

    // Якщо ліміт вичерпано
    if (rateLimitInfo.remaining <= 0) {
      res.status(429); // Too Many Requests
      return {
        message: 'Rate limit exceeded',
        retryAfter: rateLimitInfo.resetTime - Date.now(),
      };
    }

    return this.dataService.getData();
  }
}

Приклад відповіді:

GET /api/data - Rate Limit заголовки
$ curl -i -H "X-API-Key: abc123" http://localhost:3000/api/data
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1725456000
Content-Type: application/json
{"data":[...]}
Для автоматичного Rate Limiting використовуйте пакет @nestjs/throttler:
npm install @nestjs/throttler
Він автоматично додає заголовки X-RateLimit-* та обробляє логіку лімітування без ручного коду у кожному контролері.

X-Request-ID: трейсинг розподілених запитів

Заголовок X-Request-ID (або X-Correlation-ID) дозволяє відстежувати запит через усі сервіси у розподіленій системі:

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // Використовуємо ID від клієнта або генеруємо новий
    const requestId = req.headers['x-request-id'] as string || uuidv4();
    
    // Зберігаємо у req для використання у контролерах
    req['requestId'] = requestId;
    
    // Додаємо у відповідь
    res.setHeader('X-Request-ID', requestId);
    
    next();
  }
}

Реєстрація middleware у модулі:

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { RequestIdMiddleware } from './middleware/request-id.middleware';

@Module({
  // ...
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(RequestIdMiddleware).forRoutes('*');
  }
}

Використання у контролері:

@Controller('orders')
export class OrdersController {
  constructor(
    private readonly ordersService: OrdersService,
    private readonly logger: Logger,
  ) {}

  @Post()
  async createOrder(
    @Body() createOrderDto: CreateOrderDto,
    @Req() req: Request,
  ) {
    const requestId = req['requestId'];
    
    this.logger.log(`Creating order - Request ID: ${requestId}`, 'OrdersController');
    
    const order = await this.ordersService.create(createOrderDto, requestId);
    
    return order;
  }
}

Тепер кожен запит має унікальний ідентифікатор, який передається між сервісами та логується, що дозволяє відстежити весь шлях запиту у розподіленій системі.

Location: перенаправлення на новий ресурс

Заголовок Location використовується зі статусами 201 Created та 3xx Redirect для вказівки URL новоствореного або цільового ресурсу:

import { Controller, Post, Body, Res, HttpStatus } from '@nestjs/common';
import { Response } from 'express';

@Controller('users')
export class UsersController {
  @Post()
  async create(
    @Body() createUserDto: CreateUserDto,
    @Res({ passthrough: true }) res: Response,
  ) {
    const user = await this.usersService.create(createUserDto);
    
    // Встановлюємо статус 201 Created та заголовок Location
    res.status(HttpStatus.CREATED);
    res.header('Location', `/users/${user.id}`);
    
    return user;
  }
}

Відповідь:

POST /users - Створення з Location
$ curl -i -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com"}'
HTTP/1.1 201 Created
Location: /users/42
Content-Type: application/json
{"id":42,"name":"Alice",...}

Для перенаправлень використовуйте метод redirect():

@Get('old-path')
redirectToNew(@Res() res: Response) {
  // 301 Moved Permanently - постійне перенаправлення
  res.redirect(301, '/new-path');
}

@Get('temporary')
temporaryRedirect(@Res() res: Response) {
  // 302 Found - тимчасове перенаправлення
  res.redirect(302, '/temp-location');
}

Кастомні заголовки: X-префікс та конвенції

Кастомні заголовки (custom headers) дозволяють передавати додаткові метадані, специфічні для вашого API, які не покриваються стандартними HTTP-заголовками. Історично кастомні заголовки використовували префікс X- (наприклад, X-Custom-Header), проте згідно з RFC 6648 цей префікс є застарілим і більше не рекомендується для нових застосунків.

Історична довідка: Префікс X- (означає "eXperimental" або "eXtension") використовувався для позначення нестандартних заголовків. Проте з часом багато таких заголовків стали фактичними стандартами (X-Forwarded-For, X-Content-Type-Options), що створило плутанину. RFC 6648 (2012) офіційно не рекомендує використовувати X- для нових заголовків, радячи натомість використовувати описові імена без префіксів.Незважаючи на це, багато API продовжують використовувати X- через зворотну сумісність та усталені практики.

Загальні рекомендації для кастомних заголовків

Сучасний підхід (без X-):

API-Version: 2.0
Request-ID: abc-123-def
Client-ID: mobile-app-v1.2.3
Correlation-ID: trace-456
Rate-Limit-Remaining: 99

Застарілий, але поширений підхід (з X-):

X-API-Version: 2.0
X-Request-ID: abc-123-def
X-Client-ID: mobile-app-v1.2.3
X-Correlation-ID: trace-456
X-Rate-Limit-Remaining: 99
Для консистентності у вашому API виберіть один підхід і дотримуйтеся його у всіх заголовках. Якщо ви працюєте з існуючим API, який використовує X- префікс, продовжуйте його використовувати для зворотної сумісності.

Приклад: Версіонування API через заголовки

@Controller('api')
export class ApiController {
  @Get('data')
  @Header('X-API-Version', '2.0')
  @Header('X-Deprecated-In', '3.0')
  getData(@Headers('x-api-version') requestedVersion?: string) {
    // Клієнт може вказати бажану версію у запиті
    const version = requestedVersion || '2.0';

    if (version === '1.0') {
      return this.dataService.getDataV1(); // Застаріла версія
    }

    return this.dataService.getDataV2(); // Поточна версія
  }

  @Get('new-data')
  @Header('X-API-Version', '3.0')
  @Header('X-Introduced-In', '3.0')
  getNewData() {
    return this.dataService.getDataV3(); // Нова версія з додатковими можливостями
  }
}

Приклад: Метадані про процес обробки

@Controller('processing')
export class ProcessingController {
  @Post('job')
  async createJob(
    @Body() jobDto: CreateJobDto,
    @Res({ passthrough: true }) res: Response,
  ) {
    const startTime = Date.now();
    const job = await this.jobsService.create(jobDto);
    const processingTime = Date.now() - startTime;

    // Додаємо метадані про обробку
    res.set({
      'X-Job-ID': job.id,
      'X-Processing-Time-Ms': processingTime.toString(),
      'X-Queue-Position': job.queuePosition.toString(),
      'X-Estimated-Wait-Seconds': job.estimatedWait.toString(),
    });

    return job;
  }
}

Відповідь:

POST /processing/job - Метадані обробки
HTTP/1.1 201 Created
X-Job-ID: job_7f8a9b2c
X-Processing-Time-Ms: 45
X-Queue-Position: 3
X-Estimated-Wait-Seconds: 120
{"id":"job_7f8a9b2c","status":"queued"}

Приклад: Ідентифікація клієнтського застосунку

@Controller('mobile-api')
export class MobileApiController {
  @Get('features')
  getFeatures(
    @Headers('x-client-id') clientId?: string,
    @Headers('x-client-version') clientVersion?: string,
    @Headers('x-platform') platform?: string,
  ) {
    // Повернути features на основі версії клієнта
    const features = this.featuresService.getForClient({
      clientId,
      version: clientVersion,
      platform, // "ios", "android", "web"
    });

    // Перевірити чи потрібне оновлення
    const updateRequired = this.versionService.isUpdateRequired(clientVersion);

    return {
      features,
      updateRequired,
      minimumVersion: '1.5.0',
    };
  }
}

Приклад запиту від мобільного додатку:

curl -H "X-Client-ID: my-mobile-app" \
     -H "X-Client-Version: 1.4.2" \
     -H "X-Platform: ios" \
     http://localhost:3000/mobile-api/features

CORS-заголовки: міжсайтова безпека

CORS (Cross-Origin Resource Sharing) — це механізм безпеки браузера, що контролює, які домени можуть виконувати запити до вашого API. Без правильного налаштування CORS браузери блокуватимуть запити від клієнтів на інших доменах.

Що таке Same-Origin Policy

За замовчуванням браузери дозволяють JavaScript-коду виконувати HTTP-запити лише до того ж походження (origin), з якого завантажено сторінку. Походження складається з трьох компонентів:

https://example.com:443/path
└─┬──┘ └────┬─────┘ └┬┘
схема    домен      порт

Запити між різними походженнями блокуються:

Loading diagram...
graph LR
    subgraph "Same Origin ✅"
        SO1["https://example.com/page"] --> SO2["https://example.com/api"]
    end
    
    subgraph "Cross Origin ❌"
        CO1["https://frontend.com"] -.блокується.-> CO2["https://api.backend.com"]
    end
    
    style SO1 fill:#22c55e,stroke:#15803d,color:#ffffff
    style SO2 fill:#22c55e,stroke:#15803d,color:#ffffff
    style CO1 fill:#ef4444,stroke:#b91c1c,color:#ffffff
    style CO2 fill:#ef4444,stroke:#b91c1c,color:#ffffff

Приклади перевірки походження:

Запит відДоРезультат
https://example.comhttps://example.com/api✅ Same Origin
https://example.comhttps://api.example.com❌ Інший субдомен
https://example.comhttp://example.com❌ Інша схема
https://example.com:443https://example.com:8080❌ Інший порт
https://example.comhttps://example.org❌ Інший домен

Увімкнення CORS у NestJS

NestJS надає вбудовану підтримку CORS через метод enableCors():

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Базове увімкнення CORS для всіх походжень (небезпечно для продакшену!)
  app.enableCors();

  await app.listen(3000);
}
bootstrap();

Налаштування CORS з обмеженнями

Для продакшену завжди обмежуйте дозволені походження:

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableCors({
    origin: [
      'https://frontend.example.com',  // Продакшен фронтенд
      'http://localhost:3001',         // Локальна розробка
    ],
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
    exposedHeaders: ['X-Total-Count', 'X-Page-Count'], // Доступні у JS
    credentials: true, // Дозволити cookies
    maxAge: 3600,      // Кешування preflight запитів (1 година)
  });

  await app.listen(3000);
}

Динамічна перевірка походження

Для складніших сценаріїв використовуйте функцію-callback:

app.enableCors({
  origin: (origin, callback) => {
    // Дозволити запити без origin (наприклад, Postman, мобільні додатки)
    if (!origin) {
      return callback(null, true);
    }

    // Дозволені домени
    const allowedOrigins = [
      'https://frontend.example.com',
      'https://admin.example.com',
    ];

    // Дозволити localhost з будь-яким портом у розробці
    if (process.env.NODE_ENV === 'development' && origin.includes('localhost')) {
      return callback(null, true);
    }

    // Перевірити чи origin у дозволених
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
});

CORS-заголовки у відповіді

Коли CORS увімкнено, NestJS автоматично додає відповідні заголовки:

OPTIONS /api/users - CORS Preflight
$ curl -i -X OPTIONS http://localhost:3000/api/users \
-H "Origin: https://frontend.example.com" \
-H "Access-Control-Request-Method: POST"
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
Ніколи не використовуйте origin: '*' з credentials: true! Це є заборонено специфікацією CORS і браузери відхилять такі запити. Якщо потрібні credentials (cookies, Authorization), завжди явно вказуйте дозволені домени.

Ручне встановлення CORS-заголовків

У рідкісних випадках може знадобитися ручне встановлення CORS-заголовків для окремих ендпоінтів:

@Controller('public-api')
export class PublicApiController {
  @Get('data')
  @Header('Access-Control-Allow-Origin', '*')
  @Header('Access-Control-Allow-Methods', 'GET, OPTIONS')
  @Header('Access-Control-Max-Age', '86400')
  getPublicData() {
    // Публічний ендпоінт, доступний з будь-якого джерела
    return this.dataService.getPublicData();
  }
}

Best Practices: безпечна робота з заголовками

Не передавайте чутливі дані у заголовках

Хоча HTTP-заголовки передаються через HTTPS у зашифрованому вигляді, вони часто логуються проксі-серверами, балансувальниками навантаження та системами моніторингу. Ніколи не передавайте чутливі дані у кастомних заголовках:

@Post('transfer')
async transferMoney(
  @Headers('x-account-password') password: string, // ❌ Небезпечно!
  @Headers('x-credit-card') creditCard: string,    // ❌ Небезпечно!
  @Body() transferDto: TransferDto,
) {
  // ...
}

Валідація заголовків через Pipes

Використовуйте pipes для валідації обов'язкових заголовків:

import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseAuthHeaderPipe implements PipeTransform {
  transform(value: string) {
    if (!value) {
      throw new BadRequestException('Authorization header is required');
    }

    const [scheme, token] = value.split(' ');

    if (scheme !== 'Bearer' || !token) {
      throw new BadRequestException('Invalid Authorization format. Expected: Bearer <token>');
    }

    return token;
  }
}

Використання:

@Get('protected')
getProtectedData(
  @Headers('authorization', ParseAuthHeaderPipe) token: string,
) {
  // token вже витягнутий та провалідований
  return this.dataService.getProtectedData(token);
}

Обмеження розміру заголовків

HTTP-сервери мають ліміт на загальний розмір заголовків (зазвичай 8KB). Не передавайте великі обсяги даних у заголовках:

// ❌ Погано: передача великого JSON у заголовку
@Post('data')
processData(@Headers('x-metadata') metadata: string) {
  // metadata може бути кілька кілобайт — це перевантажує заголовки
}

// ✅ Добре: великі дані у тілі запиту
@Post('data')
processData(@Body() data: { metadata: object; payload: any }) {
  // metadata у тілі, не обмежена розміром заголовків
}

Документування кастомних заголовків

Завжди документуйте кастомні заголовки через Swagger/OpenAPI:

import { ApiHeader, ApiOperation } from '@nestjs/swagger';

@Controller('api')
export class ApiController {
  @Get('data')
  @ApiOperation({ summary: 'Get data with optional client identification' })
  @ApiHeader({
    name: 'X-Client-ID',
    description: 'Unique identifier of the client application',
    required: false,
    example: 'mobile-app-v1.2.3',
  })
  @ApiHeader({
    name: 'X-API-Version',
    description: 'Preferred API version',
    required: false,
    example: '2.0',
  })
  getData(
    @Headers('x-client-id') clientId?: string,
    @Headers('x-api-version') apiVersion?: string,
  ) {
    return this.dataService.getData(clientId, apiVersion);
  }
}

Уникайте дублювання інформації

Не дублюйте інформацію між заголовками та тілом запиту:

// ❌ Погано: ID користувача і у заголовку, і у тілі
@Post('profile')
updateProfile(
  @Headers('x-user-id') userId: string,
  @Body() body: { userId: string; name: string },
) {
  // Яке userId використовувати, якщо вони різні?
}

// ✅ Добре: ID користувача тільки в одному місці
@Post('profile')
updateProfile(
  @Headers('authorization') token: string,
  @Body() body: { name: string },
) {
  const userId = this.authService.getUserIdFromToken(token);
  // Єдине джерело правди
}

Робота з заголовками: практичний приклад

Розглянемо повноцінний приклад контролера, що демонструє різні аспекти роботи з заголовками — автентифікацію, кешування, rate limiting, трейсинг та версіонування:

// articles.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Param,
  Headers,
  Res,
  HttpStatus,
  UnauthorizedException,
  NotFoundException,
  Header,
} from '@nestjs/common';
import { Response } from 'express';
import { ArticlesService } from './articles.service';
import { AuthService } from '../auth/auth.service';
import { RateLimitService } from '../rate-limit/rate-limit.service';
import { CreateArticleDto } from './dto/create-article.dto';

@Controller('articles')
@Header('X-API-Version', '2.0')
@Header('X-Powered-By', 'NestJS')
export class ArticlesController {
  constructor(
    private readonly articlesService: ArticlesService,
    private readonly authService: AuthService,
    private readonly rateLimitService: RateLimitService,
  ) {}

  /**
   * Отримання списку статей з кешуванням та пагінацією
   */
  @Get()
  @Header('Cache-Control', 'public, max-age=300') // Кешувати на 5 хвилин
  async findAll(
    @Headers('x-request-id') requestId: string,
    @Headers('accept') accept: string = 'application/json',
    @Res({ passthrough: true }) res: Response,
  ) {
    console.log(`[${requestId}] Fetching all articles`);

    // Перевірка підтримуваного формату
    if (!accept.includes('application/json')) {
      res.status(HttpStatus.NOT_ACCEPTABLE);
      return { error: 'Only application/json is supported' };
    }

    const articles = await this.articlesService.findAll();

    // Додавання метаданих у заголовки
    res.set({
      'X-Total-Count': articles.length.toString(),
      'X-Page-Size': '20',
      'X-Current-Page': '1',
    });

    return articles;
  }

  /**
   * Отримання окремої статті з умовним кешуванням через ETag
   */
  @Get(':id')
  async findOne(
    @Param('id') id: string,
    @Headers('if-none-match') ifNoneMatch: string,
    @Headers('x-request-id') requestId: string,
    @Res({ passthrough: true }) res: Response,
  ) {
    console.log(`[${requestId}] Fetching article ${id}`);

    const article = await this.articlesService.findById(id);

    if (!article) {
      throw new NotFoundException(`Article with ID ${id} not found`);
    }

    // Генерація ETag на основі lastModified
    const etag = `"${article.lastModified.getTime()}"`;

    // Клієнт має актуальну версію?
    if (ifNoneMatch === etag) {
      res.status(HttpStatus.NOT_MODIFIED);
      res.header('ETag', etag);
      return; // Не повертаємо тіло, тільки 304 Not Modified
    }

    // Встановлення заголовків кешування
    res.set({
      'ETag': etag,
      'Cache-Control': 'private, must-revalidate',
      'Last-Modified': article.lastModified.toUTCString(),
    });

    return article;
  }

  /**
   * Створення статті з автентифікацією та rate limiting
   */
  @Post()
  async create(
    @Headers('authorization') authHeader: string,
    @Headers('x-request-id') requestId: string,
    @Headers('user-agent') userAgent: string,
    @Body() createArticleDto: CreateArticleDto,
    @Res({ passthrough: true }) res: Response,
  ) {
    console.log(`[${requestId}] Creating article from ${userAgent}`);

    // Автентифікація користувача
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new UnauthorizedException('Authorization header is required');
    }

    const token = authHeader.substring(7);
    const user = await this.authService.validateToken(token);

    if (!user) {
      throw new UnauthorizedException('Invalid or expired token');
    }

    // Перевірка rate limits
    const rateLimitInfo = await this.rateLimitService.checkLimit(user.id);

    res.set({
      'X-RateLimit-Limit': rateLimitInfo.limit.toString(),
      'X-RateLimit-Remaining': rateLimitInfo.remaining.toString(),
      'X-RateLimit-Reset': rateLimitInfo.resetTime.toString(),
    });

    if (rateLimitInfo.remaining <= 0) {
      res.status(HttpStatus.TOO_MANY_REQUESTS);
      return {
        error: 'Rate limit exceeded',
        retryAfter: Math.ceil((rateLimitInfo.resetTime - Date.now()) / 1000),
      };
    }

    // Створення статті
    const article = await this.articlesService.create(createArticleDto, user.id);

    // Встановлення статусу та Location
    res.status(HttpStatus.CREATED);
    res.header('Location', `/articles/${article.id}`);

    return article;
  }

  /**
   * Експорт статті у різних форматах (Content Negotiation)
   */
  @Get(':id/export')
  async exportArticle(
    @Param('id') id: string,
    @Headers('accept') accept: string = 'application/json',
    @Res({ passthrough: true }) res: Response,
  ) {
    const article = await this.articlesService.findById(id);

    if (!article) {
      throw new NotFoundException(`Article with ID ${id} not found`);
    }

    // JSON (за замовчуванням)
    if (accept.includes('application/json')) {
      res.header('Content-Type', 'application/json');
      return article;
    }

    // Markdown
    if (accept.includes('text/markdown')) {
      const markdown = this.articlesService.convertToMarkdown(article);
      res.set({
        'Content-Type': 'text/markdown; charset=utf-8',
        'Content-Disposition': `attachment; filename="article-${id}.md"`,
      });
      return markdown;
    }

    // HTML
    if (accept.includes('text/html')) {
      const html = this.articlesService.convertToHtml(article);
      res.header('Content-Type', 'text/html; charset=utf-8');
      return html;
    }

    // PDF
    if (accept.includes('application/pdf')) {
      const pdf = await this.articlesService.convertToPdf(article);
      res.set({
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment; filename="article-${id}.pdf"`,
        'Content-Length': pdf.length.toString(),
      });
      return pdf;
    }

    // Непідтримуваний формат
    res.status(HttpStatus.NOT_ACCEPTABLE);
    return {
      error: 'Unsupported media type',
      supportedFormats: [
        'application/json',
        'text/markdown',
        'text/html',
        'application/pdf',
      ],
    };
  }
}

Цей приклад демонструє:

Читання заголовків запиту:

  • Authorization — автентифікація через Bearer токен
  • Accept — узгодження формату відповіді
  • If-None-Match — умовне кешування через ETag
  • X-Request-ID — трейсинг запитів
  • User-Agent — логування інформації про клієнта

Встановлення заголовків відповіді:

  • X-API-Version — версіонування API (рівень контролера)
  • Cache-Control — інструкції кешування
  • ETag / Last-Modified — умовне кешування
  • X-RateLimit-* — інформація про ліміти
  • Location — URL створеного ресурсу
  • Content-Type / Content-Disposition — контроль завантаження файлів

Безпека та Best Practices:

  • Валідація формату Authorization
  • Перевірка підтримуваних форматів через Accept
  • Rate limiting з інформуванням клієнта
  • Трейсинг запитів через унікальні ID
  • Статуси HTTP: 201 Created, 304 Not Modified, 406 Not Acceptable, 429 Too Many Requests

Перевірка знань

Підсумок

У цій лекції ми детально розглянули роботу з HTTP-заголовками у NestJS — від базових декораторів @Headers() та @Header() до складних сценаріїв автентифікації, кешування, CORS та трейсингу розподілених систем.

Ключові висновки:

Декоратори для заголовків:

  • @Headers() — витягування всіх заголовків запиту
  • @Headers('key') — витягування окремого заголовка запиту
  • @Header('name', 'value') — встановлення заголовків відповіді

Стандартні заголовки:

  • Authorization — автентифікація через Bearer токени
  • Content-Type / Accept — узгодження формату даних
  • User-Agent — інформація про клієнта
  • Cache-Control — керування кешуванням
  • ETag / If-None-Match — умовне кешування

Кастомні заголовки:

  • X-Request-ID — трейсинг розподілених запитів
  • X-API-Version — версіонування API
  • X-RateLimit-* — інформування про ліміти запитів
  • Префікс X- є застарілим, проте широко використовується

CORS:

  • Access-Control-Allow-Origin — дозволені походження
  • Access-Control-Allow-Methods — дозволені HTTP-методи
  • Access-Control-Allow-Headers — дозволені заголовки
  • NestJS надає app.enableCors() для автоматичного налаштування

Best Practices:

  • Використовуйте HTTPS для захисту заголовків
  • Не передавайте чутливі дані у кастомних заголовках
  • Валідуйте обов'язкові заголовки через pipes
  • Документуйте кастомні заголовки через Swagger
  • Дотримуйтесь стандартів HTTP для сумісності з інфраструктурою

У наступній лекції ми перейдемо до вивчення Data Transfer Objects (DTO) — фундаментального інструменту для типізації, валідації та документування даних, що передаються між клієнтом та сервером.

Copyright © 2026