JavaRush /Курси /Spring REST & MVC /@RequestHeader і cook...

@RequestHeader і cookies: контракт і шум

Spring REST & MVC
Рівень 6 , Лекція 2
Відкрита

1. Роль headers і cookies поруч із path та query

З адресацією та query-параметрами все більш-менш зрозуміло: path відповідає за конкретний ресурс, query — за умову відбору. Але запит не обмежується URL. У нього є ще два менш помітні канали — headers і cookies.

І ось тут легко наробити шуму. Headers і cookies корисні, коли потрібно передати технічний контекст запиту, але вони майже завжди поганий вибір для основної бізнес-семантики. Якщо ховати туди фільтри, режими пошуку або вибір конкретної задачі, контракт перестає читатися з першого погляду.

2. Заголовки як частина контракту: що передавати в headers

Заголовки часто сприймають як «якийсь технічний дріб’язок, який можна ігнорувати». Так триває рівно до моменту, коли у вас раптово перестає працювати JSON, бо клієнт надіслав дивний Accept, або ви пів дня шукаєте, чому запит «іноді інший», а в ньому з’явився кастомний X-... заголовок. Заголовки — частина контракту, просто менш очевидна, ніж URL, тож вона потребує дисципліни.

Найважливіша думка цієї лекції така: headers — це хороший канал для технічного контексту запиту, але поганий канал для основної бізнес-семантики. Наприклад, заголовки чудово підходять для опису формату, який ми приймаємо і віддаємо, для кешування, для авторизації та для діагностичних речей на кшталт кореляційного id. Але якщо ви починаєте ховати в заголовках «фільтр за статусом задачі» або «яку задачу відкрити», ви перетворюєте API на квест.

Чому так? Тому що URL читається очима і легко копіюється в документацію та .http-файли. А заголовки — ні. Їх треба пам’ятати, їх треба прописувати окремо, і якщо ви забули один заголовок, сервер може поводитися інакше. Контракт стає «невидимим».

Найпоказовіший заголовок: Accept

Accept говорить серверу: «у якому форматі клієнт хоче отримати відповідь». Для нашого проєкту робочий і очікуваний формат один — application/json: на ньому тримаються DTO, .http-запити та подальші приклади. Але Accept усе одно важливий, тому що формат відповіді — це частина контракту, а не декоративний рядок у заголовках.

Вплив Accept на формат відповіді

Spring MVC вибирає спосіб перетворення відповіді через механізм message converters. У документації це описується прямим текстом: Spring MVC використовує HttpMessageConverter для конвертації запитів і відповідей.

Для цього курсу важливо тримати просту й точну картину: формат відповіді визначається не самою анотацією @RestController, а тим, які converters взагалі доступні застосунку. У нашій базовій конфігурації підключена JSON-конвертація через Jackson, тому нормальний сценарій тут — application/json. Якби застосунок підтримував і інші media types, Accept брав би участь і в їх виборі, тому ігнорувати його як «службовий шум» не можна.

3. @RequestHeader у Spring MVC: читання заголовків у методі

До цього моменту ми діставали вхідні дані з path і query, і це виглядало доволі прозоро: параметр є в URL, отже він є в сигнатурі. Із заголовками все трохи менш інтуїтивно, тому що вони не на виду. Але в Spring MVC читати заголовки так само просто: ви явно вказуєте, який саме header вам потрібен, і отримуєте його значення в аргумент методу.

У найпростішому вигляді @RequestHeader зв’язує назву заголовка й Java-параметр методу. Тут важливо звикнути до двох речей. По-перше, якщо ви робите заголовок обов’язковим, то ця кінцева точка починає вимагати ключ у заголовку, і це потрібно робити тільки тоді, коли без нього справді ніяк. По-друге, якщо заголовок необов’язковий, то ваш код має бути готовий до null (або до значення за замовчуванням), інакше ви самі собі влаштуєте міні-DDoS із NullPointerException.

Мініприклад: читаємо Accept

Так, у реальному API ми зазвичай не читаємо Accept вручну — Spring сам вирішує, чим відповідати. Але як навчальний приклад Accept ідеальний: він показує, що header — це контекст запиту, а не фільтр за задачами.

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {

    @GetMapping("/api/v1/tasks")
    public List<String> findTasks(
            // Беремо технічний контекст запиту із заголовка (це не бізнес-параметр)
            @RequestHeader(value = "Accept", required = false) String accept
    ) {
        // Для навчальної демонстрації виводимо значення; у реальному проєкті частіше використовують журналювання
        System.out.println("Accept клієнта = " + accept); // Accept клієнта = application/json
        return List.of("task-1", "task-2");
    }
}

Зверніть увагу на дві речі. По-перше, ми позначили заголовок як необов’язковий, тому що Accept може не надійти (або надійти в несподіваному вигляді). По-друге, навіть тут Accept ніяк не впливає на вибір задач — це саме технічний контекст запиту.

Обов’язковий vs необов’язковий заголовок

У @RequestHeader параметр required за замовчуванням true. Це означає: якщо ви напишете @RequestHeader("X-Client-Id") String clientId, а клієнт заголовок не надішле, запит не дійде до вашої логіки так, як ви очікували. З погляду контракту це нормально, але тільки якщо ви справді хочете зробити заголовок частиною обов’язкового протоколу.

Якщо заголовок важливий, але ви готові жити з дефолтом, простіше задати defaultValue. Тоді контракт стає м’якшим: «якщо не надіслав — буде значення за замовчуванням, поведінка передбачувана».

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ClientInfoController {

    @GetMapping("/api/v1/debug/client")
    public String getClient(
            // defaultValue робить контракт м’якшим: заголовок не обов’язковий
            @RequestHeader(value = "X-Client-Name", defaultValue = "unknown") String clientName
    ) {
        // Повертаємо значення для наочності (демонстраційна кінцева точка)
        return "clientName=" + clientName; // clientName=unknown
    }
}

Такі налагоджувальні кінцеві точки в реальному проєкті частіше мають інший вигляд, але як навчальна демонстрація вони хороші: ми бачимо, що header — це ще одне джерело входу, і воно може бути керованим.

Часто корисний заголовок: кореляційний ідентифікатор

Навіть у простому навчальному API іноді корисно прийняти «ідентифікатор запиту», щоб потім шукати конкретний виклик у логах. Ми не будуємо тут повноцінну систему спостережуваності, але сам прийом читання заголовка показати можна.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskDebugController {

    @GetMapping("/api/v1/debug/request")
    public String debugRequest(
            // Кореляційний ідентифікатор: суто технічний контекст, зручний для логів і трасування
            @RequestHeader(value = "X-Request-Id", required = false) String requestId
    ) {
        // Якщо заголовок не надійшов — буде null (і це очікувано, бо required=false)
        return "requestId=" + requestId; // requestId=4f2b...
    }
}

Ключовий зміст у тому, що X-Request-Id — це саме технічний контекст, а не «яку задачу відкрити». Якщо ви почнете обирати задачу через заголовок X-Task-Id, ви самі створите «таємний вхід» в API.

Читання всіх заголовків для налагодження

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

import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HeadersController {

    @GetMapping("/api/v1/debug/headers")
    public String debugHeaders(
            // Отримуємо всі заголовки цілком — це зручно для діагностики, але небезпечно як стиль проєктування API
            @RequestHeader HttpHeaders headers
    ) {
        // Читаємо конкретний цікавий заголовок безпечно (перше значення або null)
        return "Accept=" + headers.getFirst(HttpHeaders.ACCEPT); // Accept=application/json
    }
}

4. Cookies і @CookieValue: слизький канал для REST API

Cookies виглядають як зручний механізм: браузер сам зберігає їх і сам надсилає, тож розробнику менше думати. І саме це робить cookies небезпечними для звичайного REST API. Коли ви проєктуєте контракт, вам важлива явність: щоб запит можна було відтворити, зрозуміти, задокументувати та протестувати. Cookie часто живе сама по собі: один користувач випадково носить стару cookie тижнями, інший — узагалі без cookies, третій — із десятьма розширеннями, які підмішують своє.

Технічно cookie — це заголовок Cookie у запиті (і Set-Cookie у відповіді). Але змістовно це «автоматично передаваний стан клієнта». А в нас стиль взаємодії (і весь настрій курсу) про stateless-мислення: запит має бути зрозумілим сам по собі, а не залежати від прихованого стану, який ви навіть не побачите, просто дивлячись на URL.

Як читати cookie в Spring MVC

У Spring MVC це робиться через @CookieValue. І тут теж важливі два режими: обов’язкова cookie (майже завжди погана ідея для простого API) і необов’язкова cookie, коли ви готові до її відсутності.

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CookieDemoController {

    @GetMapping("/api/v1/debug/theme")
    public String debugTheme(
            // Cookie — це автоматично принесений стан клієнта (часто з браузера)
            @CookieValue(name = "theme", required = false) String theme
    ) {
        // Демонстрація: cookie читаємо, але бізнес-логіку на ній не будуємо
        return "theme=" + theme; // theme=dark
    }
}

У цьому прикладі cookie не впливає на бізнес-сценарій. Це важливо: cookie тут просто демонструє канал входу.

Антиприклад: фільтр за задачами через cookie

Це той самий момент, коли API починає «шуміти» і ставати непередбачуваним. Людина дивиться на URL і думає: «GET /api/v1/tasks — отже отримаю список задач». А насправді вона отримує «список задач у статусі TODO», тому що десь у браузері лежить cookie status=TODO. Це не контракт, це фокус із зниклим кроликом.

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AntiPatternController {

    @GetMapping("/api/v1/tasks")
    public String findTasks(
            // Погана ідея: бізнес-фільтр захований у cookie, контракт стає невидимим
            @CookieValue(name = "status") String status
    ) {
        // Погана ідея: фільтр захований у cookie, контракт стає невидимим
        return "Фільтрація за статусом=" + status;
    }
}

Якщо вам потрібен фільтр — він має бути в query (?status=TODO). Тоді запит читається, повторюється, документується, і тести не залежать від того, які cookies хтось носить із собою.

5. Вибір каналу: path vs query vs header vs cookie

Коли ви починаєте проєктувати API, дуже хочеться отримати «правило на всі випадки життя». У реальності це радше набір запитань до самого себе, ніж залізобетонний закон. Але є й добра новина: ці запитання швидко перетворюються на звичку, і ви перестаєте пхати параметри куди заманеться. Вважайте це навичкою складати речі по шухлядах, щоб потім не шукати ключі від квартири в холодильнику, хоча холодильник теж, на перший погляд, просто коробка.

Нижче — компактна таблиця-орієнтир. Вона спеціально не про всі нюанси світу, а про те, що важливо саме зараз, на рівні проєктування зрозумілого контракту.

Канал входу Що означає за змістом Що туди класти в нормальному REST API Приклад Чому це читабельно
path «Який саме ресурс?» Ідентифікатор ресурсу, підресурс /api/v1/tasks/{taskId} URL прямо відповідає на запитання «яку задачу?»
query «Який режим вибірки?» Фільтри, пагінація, сортування /api/v1/tasks?status=TODO&page=0 Параметри видно та легко відтворити
headers «Який технічний контекст?» Формат (Accept), тип тіла (Content-Type), кореляція (X-Request-Id) Accept: application/json Не засмічує URI, але керує протоколом
cookies «Що браузер приносить автоматично?» Зазвичай — налаштування інтерфейсу та сесії у браузерному світі Cookie: theme=dark Працює в браузері, але погано підходить для явного контракту API

Головний прихований критерій тут такий: чи зможе інший розробник зрозуміти запит, просто побачивши його в документації або у файлі .http. Path і query зазвичай — «так». Headers — іноді так, якщо вони стандартні й очікувані. Cookies — частіше «ні», бо вони приходять ніби з повітря, точніше, із браузера.

6. Приклад запиту з headers і cookies

Теорія стає значно зрозумілішою, коли ви бачите «сирий» HTTP. Не як магію Spring, а як звичайний текст запиту, який можна надіслати з Postman або з файлу .http. Зараз важливо саме побачити: query — у рядку URL, headers — окремими рядками, cookies — теж окремим рядком, як заголовок.

Ось приклад запиту на список задач, де ми явно показуємо Accept і додаємо діагностичний X-Request-Id:

# Query-параметр видно прямо в URL
GET http://localhost:8080/api/v1/tasks?status=TODO
Accept: application/json
# Технічний контекст для логів/трасування
X-Request-Id: 4f2b1c9a-3fb1-4a5a-8d8c-6b8c5d0c2d10

А ось приклад, де до запиту додали cookie. Зверніть увагу: URL від cookie взагалі не змінюється. Це і є головна проблема cookie як вхідного каналу для бізнес-сенсів: зовні запит виглядає однаково, а «всередині» може виявитися різним.

GET http://localhost:8080/api/v1/tasks
Accept: application/json
# Cookie може автоматично прийти з браузера — URL при цьому не змінюється
Cookie: theme=dark

Якщо на цьому етапі у вас з’являється легка параноя в стилі «так можна заховати що завгодно» — вітаю, ви починаєте мислити як людина, яка проєктує контракт, а не просто пише контролери.

У нашому проєкті очікуваний робочий формат — JSON: на це розраховані DTO, .http-запити та подальші приклади. Але Accept усе одно не декоративний: Spring вибирає формат відповіді через доступні message converters, тому домовлятися про media type краще явно, а не сподіватися, що клієнт і сервер «якось самі зрозуміють одне одного».

7. Типові помилки під час роботи з headers і cookies

Помилка №1: ховати бізнес-фільтри в заголовках.
Іноді здається, що «заголовок — це красиво, не засмічуємо URL». Але якщо це фільтр, наприклад status, assigneeName або tag, він має жити в query. Інакше ви робите приховану частину контракту: клієнту потрібно знати не лише URI, а й «секретний набір headers», без якого кінцева точка працює не так.

Помилка №2: ховати бізнес-фільтри в cookies.
Ця помилка ще неприємніша, тому що cookies часто ставить браузер автоматично. У підсумку два користувачі роблять один і той самий запит GET /api/v1/tasks, а отримують різні результати. Такі API дуже складно налагоджувати, тому що проблема не в параметрах запиту, яких начебто немає, а в «невидимому стані».

Помилка №3: робити нестандартний заголовок обов’язковим «про всяк випадок».
required=true за замовчуванням може легко перетворити вашу кінцеву точку на таку, що працює тільки для тих, хто знає секрет. Обов’язкові кастомні заголовки допустимі, але це майже завжди означає, що ви вигадали власний протокол поверх HTTP. У навчальному проєкті та у звичайному CRUD API це частіше зайве ускладнення.

Помилка №4: читати «всі заголовки підряд» і будувати на цьому логіку.
Технічно ви можете отримати HttpHeaders і аналізувати все, що надійшло. Але якщо в контролері з’являється логіка «якщо надійшов такий header — робимо так, якщо інший — інакше», ви дуже швидко починаєте програмувати приховані режими API. Тримайте цей прийом як налагоджувальний, а не як контрактний.

Помилка №5: змішувати роль headers і query, підміняючи одне іншим.
Наприклад, Accept і Content-Type — це технічна частина обміну, і їхнє місце в headers природне. Але статус задачі, пріоритет і тег — це бізнес-семантика, і їй місце в query. Коли ці зони змішуються, API починає виглядати як набір випадкових домовленостей, а не як передбачуваний контракт.

1
Задача
Spring REST & MVC, 6 рівень, 2 лекція
Недоступна
Читання заголовка `X-Request-Id`
Читання заголовка `X-Request-Id`
1
Задача
Spring REST & MVC, 6 рівень, 2 лекція
Недоступна
Читання cookie `theme`
Читання cookie `theme`
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ