1. HTTP‑сообщение как конверт
Если вы когда-нибудь отправляли бумажное письмо (или хотя бы видели их в кино), вы помните: снаружи есть адрес, марки и служебные отметки, а внутри — содержимое. HTTP‑сообщение работает похожим образом. Новички часто замечают только «красивое» — JSON в body, — а всё остальное воспринимают как скучную обвязку. Но именно эта «скучная обвязка» определяет, что вообще происходит: куда мы обращаемся, что просим сделать, как сервер должен понять формат данных и что клиент обязан прочитать в ответ.
HTTP‑сообщения бывают двух видов: request (запрос) и response (ответ). У них разные первые строки, но дальше логика очень похожа: стартовая строка, заголовки, пустая строка‑разделитель и, опционально, тело. Важно запомнить простую мысль: если вы неправильно «разложили» данные по частям сообщения, контракт API становится мутным. А мутный контракт — это когда клиент, сервер и будущий вы через три недели смотрят на одно и то же, а каждый видит своё.
Ниже — короткая «карта местности». Мы будем к ней возвращаться по ходу лекции.
| Часть | Где находится | Что отвечает на вопрос |
|---|---|---|
| method | первая строка запроса | «Что ты хочешь сделать?» |
| path | внутри URL / request-target | «К чему ты обращаешься?» |
| query | после ? | «Какими параметрами уточнить запрос?» |
| headers | отдельные строки Name: Value | «Какие служебные условия и договорённости у обмена?» |
| body | после пустой строки | «Какие содержательные данные передаются?» |
В первой лекции мы смотрели на HTTP как на законченный цикл: запрос ушёл, ответ пришёл или не пришёл. Теперь нужно вскрыть сам запрос и сам ответ и разложить их на детали. Без этого method, path, headers и body остаются красивыми словами, а не рабочим инструментом для отладки и проектирования. Когда структура станет видна, первая строка ответа перестанет казаться шумом, а headers — скучной обвязкой.
Сразу договоримся, как будем записывать примеры. В .http-файле или Postman вы обычно видите полный URL вроде http://localhost:8080/api/v1/tasks. В сыром HTTP в первой строке обычно остаётся request-target — то есть /api/v1/tasks?status=TODO&page=0. Ниже я чаще буду показывать именно такую форму: без схемы и хоста, чтобы легче разбирать анатомию сообщения.
2. Первая строка запроса
Когда вы отправляете HTTP‑запрос, сервер сначала видит не JSON и не заголовки, а самую первую строку — request line. Она задаёт «скелет» запроса: какой метод используется, куда адресован запрос и какая версия HTTP применяется. Это как первая фраза в переписке: если вы начинаете с «Привет. Дай мне…», дальше уже можно уточнять детали. Если же стартовать с невнятного «эээ…», дальше будет тяжело всем.
Для HTTP/1.1 эта строка выглядит так:
METHOD <пробел> REQUEST-TARGET <пробел> HTTP/1.1
На практике в учебных примерах часто используют именно такую форму, потому что она наглядна и читается буквально глазами. Даже если в реальном мире поверх HTTP/2 и HTTP/3 всё внутри бинарное, смысл остаётся тем же: метод, цель, версия.
Мини‑пример «в лоб», без всякого Spring — просто чтобы увидеть форму:
// Собираем request line руками: метод + target + версия протокола.
String method = "GET";
String target = "/api/v1/tasks?status=TODO&page=0"; // target включает и path, и query
String requestLine = method + " " + target + " HTTP/1.1";
System.out.println(requestLine); // GET /api/v1/tasks?status=TODO&page=0 HTTP/1.1
Обратите внимание на важную деталь: target здесь включает и path, и query. То есть уже по первой строке видно, «куда и с какими уточнениями» мы идём.
Если хочется накидать себе маленький «парсер первой строки» (ради тренировки мозга, а не ради производства), можно сделать так:
// Учебный парсер request line: в реальном коде стоит ещё проверять количество частей.
String requestLine = "GET /api/v1/tasks/42 HTTP/1.1";
String[] parts = requestLine.split(" ");
String method = parts[0]; // метод запроса: GET/POST/...
String target = parts[1]; // request-target: /api/v1/tasks/42 (может содержать и query)
System.out.println(method); // GET
System.out.println(target); // /api/v1/tasks/42
С точки зрения API‑контракта здесь важна не способность делить строку, а понимание: method и target не «где-то там», они всегда живут в первой строке. Именно они задают базовый смысл запроса ещё до того, как мы начнём обсуждать заголовки и тело.
3. Path: адрес ресурса
Path — это адресная часть пути внутри вашего сервера. Если смотреть на URL как на «адрес дома», то path — это, условно, улица и номер дома. Без уточнений вроде «подъезд 2, этаж 7» — это уже query, — и без служебных штук вроде «какой язык предпочитаете» — это чаще заголовки. Path должен быть достаточно стабильным и понятным: увидев его, клиент должен примерно понимать, что именно он адресует.
В HTTP‑запросе path обычно виден внутри request-target. Например:
GET /api/v1/tasks HTTP/1.1
GET /api/v1/tasks/42 HTTP/1.1
GET /api/v1/tasks/42/comments HTTP/1.1
В нашем учебном API у пути уже есть базовый префикс /api/v1, и он тоже является частью path.
Path состоит из сегментов, разделённых /. В большинстве серверов (и в практическом API‑дизайне) он воспринимается как иерархия: сначала «коллекция», потом конкретный объект, потом подраздел. Пока нам важна не «идеология REST», а анатомия: path — это всё, что идёт до ?.
Чтобы увидеть различие между path и query на уровне Java‑типа, удобно использовать URI:
import java.net.URI;
// URI умеет отдельно отдавать path и query — удобно для понимания, что это разные части URL.
URI uri = URI.create("https://example.com/api/v1/tasks/42?status=TODO&page=0");
System.out.println(uri.getPath()); // /api/v1/tasks/42
System.out.println(uri.getQuery()); // status=TODO&page=0
Тут нет магии. Это просто хороший способ «вживую» увидеть, что path и query — разные части адреса, даже если они выглядят как одна строка в браузерной строке.
Иногда полезно разложить path на сегменты — чисто для тренировки:
// Разбиваем path на сегменты (учебный пример: в реальном мире есть нюансы с //, кодированием и т.д.).
String path = "/api/v1/tasks/42/comments";
// Убираем ведущий "/" и делим по "/".
String[] segments = path.substring(1).split("/");
System.out.println(segments[0]); // api
System.out.println(segments[1]); // v1
System.out.println(segments[2]); // tasks
System.out.println(segments[3]); // 42
System.out.println(segments[4]); // comments
Заметьте, что 42 в середине path — это уже намёк на идентификатор конкретного ресурса. В реальном API это часто UUID, но на уровне анатомии нам важнее другое: path чаще всего отвечает на вопрос “к чему обращаемся”, а не “какими параметрами фильтруем” и не “какое именно содержимое передаём”.
4. Query‑параметры
Query string — это часть адреса после ?. Её часто недооценивают, пока не появляется первый реальный endpoint списка и не выясняется, что «покажи мне задачи со статусом TODO, отсортируй по времени и дай первую страницу» не влезает ни в один приличный path без превращения URL в роман Толстого. Query как раз и нужен, чтобы уточнять запрос параметрами.
Форма query обычно такая:
/api/v1/tasks?status=TODO&page=0&size=20
То есть идут пары key=value, разделённые &. Сам факт, что query начинается с ?, уже важная подсказка: это дополнение к запросу, а не «новый ресурс». Если вы уберёте query, вы всё ещё будете обращаться к тому же path, но уже без уточнений.
Очень важно помнить про URL‑encoding: query живёт внутри URL, а значит некоторые символы там должны быть закодированы. Простейший пример — пробел в имени:
/api/v1/tasks?assigneeName=John%20Smith
Если «впихнуть» пробел напрямую, поведение станет непредсказуемым: где-то он закодируется автоматически, где-то всё сломается, а где-то сервер увидит только «John». Лучше считать, что с URL нужно обращаться аккуратно.
Вот маленький пример декодирования значения:
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
// Декодируем percent-encoded значение из query (например, %20 -> пробел).
String pair = "assigneeName=John%20Smith";
String encodedValue = pair.split("=", 2)[1]; // берём правую часть после "="
String value = URLDecoder.decode(encodedValue, StandardCharsets.UTF_8);
System.out.println(value); // John Smith
А вот пример «как собрать query правильно», используя URLEncoder:
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
// Кодируем значение, чтобы безопасно положить его в URL (пробелы и спецсимволы будут закодированы).
String assignee = URLEncoder.encode("John Smith", StandardCharsets.UTF_8);
String url = "/api/v1/tasks?assigneeName=" + assignee;
System.out.println(url); // /api/v1/tasks?assigneeName=John+Smith
Да, тут будет +, а не %20. Это нормальная особенность application/x-www-form-urlencoded‑стиля кодирования, который часто встречается в веб‑мире. На уровне нашей лекции важно не запомнить все тонкости, а унести одну мысль: query‑параметры — это место для уточнений, но они живут в URL и требуют аккуратного обращения со строками.
Иногда хочется просто распарсить query в Map. Сделаем небольшой, нарочито простой код (он не претендует на “идеальный парсер URL”, он учебный):
import java.util.HashMap;
import java.util.Map;
// Очень простой парсер: делит по "&" и "=", не делает URL-decoding и не обрабатывает повторяющиеся ключи.
Map<String, String> parseQuery(String query) {
Map<String, String> result = new HashMap<>();
for (String pair : query.split("&")) { // пары вида key=value
String[] kv = pair.split("=", 2); // ограничение 2: значение может содержать "="
result.put(kv[0], kv.length == 2 ? kv[1] : ""); // если "=" не было — считаем значение пустым
}
return result;
}
В реальном сервисе такие вещи обычно делает инфраструктура (сервер, фреймворк, библиотека), но для понимания анатомии полезно один раз руками увидеть: query — это набор пар, а не «магия», которая внезапно превращается в параметры метода.
5. Headers: служебные договорённости
Заголовки (headers) — это строки формата Name: Value, которые идут сразу после стартовой строки. Они не являются данными домена (не надо складывать туда title задачи, пожалуйста), но при этом составляют огромную часть контракта обмена: формат тела, ожидаемый формат ответа, длина контента, идентификатор запроса, данные аутентификации (в нашем курсе мы это не используем, но мир — да) и так далее.
Самая важная визуальная штука, которую нужно запомнить: headers отделяются от body пустой строкой. Пустая строка — это как «закончили служебную часть, теперь начинается содержимое». Если вы забыли эту пустую строку, сервер может попытаться прочитать тело как заголовки или наоборот. И это тот редкий случай, когда один пустой Enter действительно может испортить вечер.
Пример запроса без body, но с заголовками:
GET /api/v1/tasks HTTP/1.1
Host: localhost:8080
Accept: application/json
User-Agent: IntelliJ HTTP Client
Содержательного тела тут нет, но заголовки уже несут важную информацию. Даже Accept — это сигнал, что клиент ожидает определённый формат ответа (подробно мы в это сегодня не углубляемся, нам сейчас важна именно структура).
Пример запроса с body, где заголовок объясняет, что это за body:
POST /api/v1/tasks HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Accept: application/json
{"title":"Write docs","description":"HTTP lesson"}
Заметьте пустую строку между заголовками и JSON. Без неё сообщение выглядит почти так же, но работать может совсем иначе. HTTP не умеет читать мысли, он читает байты и разделители.
Полезно держать небольшой список «часто встречающихся» заголовков. Не как справочник, а как ориентир — чтобы вы не путали их с query и body.
| Заголовок | Где встречается | Зачем нужен (коротко) |
|---|---|---|
| Host | request (HTTP/1.1) | сообщает, к какому хосту обращается клиент |
| Accept | request | какой формат ответа клиент предпочитает |
| Content-Type | request/response | какой формат у body |
| Content-Length | request/response | размер body (если он есть) |
| Location | response | куда «переехал» созданный/перенаправленный ресурс |
| User-Agent | request | кто клиент (часто для логов/аналитики) |
И ещё один нюанс, который полезен в практическом мышлении: названия заголовков не чувствительны к регистру. Content-Type и content-type — это один и тот же заголовок по смыслу. Но если вы храните их в Map, Java‑мир внезапно становится чувствительным к регистру (потому что это строки). Поэтому часто делают нормализацию ключей.
Мини‑пример «храним заголовки в lower-case»:
import java.util.Map;
// Пример идеи: приводим имя заголовка к lower-case перед поиском (чтобы не зависеть от регистра).
String header(Map<String, String> headers, String name) {
return headers.get(name.toLowerCase());
}
Map<String, String> headers = Map.of(
"content-type", "application/json",
"accept", "application/json"
);
System.out.println(header(headers, "Content-Type")); // application/json
Опять же, вы не обязаны писать так в каждом проекте. Но как ментальная модель это полезно — чтобы понимать, почему в логах может быть content-type, а вы ищете Content-Type, и ничего «не находится».
6. Body: данные запроса
Body (тело сообщения) — это то место, куда обычно попадает «содержимое», которое реально важно бизнесу: JSON с данными, текст, бинарные данные. Но важный момент: body не обязателен. Некоторые запросы (типичный GET) чаще всего идут без body. Некоторые ответы тоже могут не иметь body. Это нормально и не означает «сервер забыл вернуть JSON».
Главная роль body — передать данные, которые не влезают в адресную часть (path/query) и которые по смыслу являются именно payload. Например, когда вы создаёте задачу в Task Tracker API, логично отправить её заголовок и описание в body, а не в заголовках и не в query.
Вот очень компактный пример HTTP‑запроса с body:
POST /api/v1/tasks HTTP/1.1
Content-Type: application/json
{"title":"Write docs","description":"HTTP lesson"}
Ещё раз подчеркну визуальную структуру: стартовая строка, заголовки, пустая строка, тело. Если вы научились видеть это глазами, вы уже на полпути к тому, чтобы не «зависать» над странными ошибками вида “Server returned 400, but JSON вроде нормальный”.
Если хочется представить запрос как структурированный объект, а не как «какие-то строки», можно сделать простейшую модель:
import java.util.Map;
// Учебная модель HTTP-запроса: только то, что важно для понимания анатомии.
public record HttpRequest(
String method, // GET/POST/...
String target, // request-target: path + query
Map<String, String> headers, // заголовки
String body // тело (может быть null/пустым)
) {
boolean hasBody() {
// Простая проверка: тело считается "есть", если оно не null и не пустое.
return body != null && !body.isBlank();
}
}
Это, конечно, не настоящая реализация HTTP‑клиента и не попытка заменить Spring. Это просто учебный «скелет» для головы: в запросе есть method, target, headers и body, и body может отсутствовать.
7. Ответ: status line, headers и body
После того как сервер обработал запрос, он возвращает HTTP‑ответ. Он устроен почти так же: первая строка, заголовки, пустая строка, тело. Только первая строка у ответа другая: там нет method, зато есть версия протокола, статус‑код и короткая фраза.
Форма первой строки ответа в HTTP/1.1 выглядит так:
HTTP/1.1 200 OK
И дальше идут заголовки и (если нужно) тело. Пример ответа со списком задач:
HTTP/1.1 200 OK
Content-Type: application/json
[{"id":"42","title":"Write docs"}]
Даже если вы пока не знаете, что значит 200 или какие бывают статусы, вы уже можете увидеть структуру. И это важно: клиент читает не только body. Он читает первую строку (результат) и заголовки (условия и формат), а body — это уже «содержимое ответа», если оно есть.
Чтобы окончательно «прибить» структуру к памяти, полезна простая схема:
flowchart TD
A["Start line
Request: GET /api/v1/tasks HTTP/1.1
Response: HTTP/1.1 200 OK"] --> B["Headers
Name: Value"]
B --> C["Blank line"]
C --> D["Body (optional)
JSON / text / bytes"]
Если запомнить хотя бы эту картинку, у вас станет сильно меньше ситуаций «почему Postman показывает ошибку, а я смотрю только на JSON и не понимаю».
8. Task Tracker API: три сценария
Теперь соберём всё вместе, чтобы вы увидели: method, path, query, headers и body — это не отдельные «термины из учебника», а детали одного и того же объекта. Представим, что мы руками дёргаем наше учебное API.
Сценарий «получить список задач со статусом TODO» (тут тело не нужно, запрос — «попросить данные», а не «передать данные»):
GET /api/v1/tasks?status=TODO&page=0 HTTP/1.1
Host: localhost:8080
Accept: application/json
Здесь method — GET, path — /api/v1/tasks, query — status=TODO&page=0, headers — Host и Accept, body отсутствует. И это нормально.
Сценарий «создать задачу» (тут уже нужно тело, потому что мы передаём содержательные данные):
POST /api/v1/tasks HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Accept: application/json
{"title":"Write docs","description":"HTTP lesson"}
Смысловая разница здесь в том, что body содержит payload. И без Content-Type серверу было бы сложно (или невозможно) корректно понять, что именно там лежит.
Сценарий «получить одну конкретную задачу» (снова request без body, но с более конкретным path):
GET /api/v1/tasks/42 HTTP/1.1
Host: localhost:8080
Accept: application/json
Даже без Spring и без контроллеров вы уже видите: разница между «списком» и «одной штукой» часто проявляется прямо в path, а уточнения — в query.
9. Типичные ошибки при работе с частями HTTP‑сообщения
Ошибка №1: читать только body и игнорировать первую строку и заголовки.
Очень частая привычка: «если я вижу JSON, значит, всё ок». Но в реальности статус и заголовки могут полностью менять смысл происходящего. Иногда сервер возвращает HTML‑страницу с ошибкой, а вы пытаетесь парсить её как JSON и удивляетесь, почему «Jackson ругается». Полезная дисциплина — всегда смотреть на сообщение целиком: start line, headers, body.
Ошибка №2: путать path и query и превращать URL в кашу.
Когда фильтры и уточнения начинают «ползти» в path, появляются уродливые конструкции, которые сложно читать и поддерживать. И наоборот: когда идентификатор ресурса внезапно оказывается query‑параметром, клиентский код становится менее предсказуемым. Хорошая привычка: path — это «к чему обращаемся», query — это «как уточняем запрос».
Ошибка №3: пытаться передавать бизнес‑данные через headers “потому что так проще”.
Заголовки — не контейнер для title, description и других полей домена. Если вы начнёте складывать туда содержательное, вы создадите контракт, который сложно документировать и ещё сложнее поддерживать. Заголовки должны оставаться служебной частью: формат, ожидания, корреляция, технические условия обмена.
Ошибка №4: забывать пустую строку между headers и body.
Это классика .http‑файлов и ручных запросов. Визуально всё выглядит «почти так же», но протоколу нужен чёткий разделитель. Нет пустой строки — тело может быть прочитано как продолжение заголовков. Результат обычно выражается в том, что сервер «не видит body», а вы сидите и доказываете миру, что «оно там точно есть».
Ошибка №5: отправлять тело без Content-Type или с неправильным Content-Type.
Если вы передаёте JSON, серверу нужно понять, что это JSON. Без правильного заголовка он может попытаться интерпретировать данные иначе или отказать в обработке. Даже если какой-то клиент «угадает», другой клиент не угадает, а контракт станет лотереей.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ