1. Путь и метод: 404 vs 405
Очень легко перепутать 404 и 405, потому что с точки зрения новичка обе ситуации выглядят одинаково: «ну запрос же всё равно не обработался». Но в HTTP это два разных вопроса, и сервер обязан отвечать на них по-разному. Представьте, что path — это адрес квартиры, а method — действие: позвонить, принести посылку, снести стену перфоратором.
Если клиент пришёл по адресу, которого нет, это 404 Not Found: «по такому адресу мы никого не знаем». Если адрес есть, но действие не разрешено, это 405 Method Not Allowed: «квартира есть, но перфоратором здесь работать нельзя, вот список разрешённых действий». И вот этот список как раз и выражается заголовком Allow.
Чтобы это стало совсем «на пальцах», возьмём наш ReadLater API. Путь /api/v1/reading-list у нас существует, и для него уместны GET (получить список) и POST (создать элемент). Если клиент отправляет PUT /api/v1/reading-list, это не «путь не найден», это «метод не поддерживается для этого пути», то есть 405, а не 404.
2. Польза 405 для клиента
На учебном уровне можно было бы сказать: «Да какая разница, вернём 404, и всё». Но в реальном мире 405 — это один из тех маленьких деталей, которые делают API предсказуемым и удобным для использования. Клиент, увидев 405, понимает: «я ошибся не адресом, а действием». Это сильно ускоряет отладку — особенно когда запросы строятся программно, а не руками в Postman.
Есть ещё один бонус: когда вы возвращаете 405 вместе с Allow, вы буквально даёте клиенту подсказку «как правильно». Для человека это похоже на табличку на двери: «Приёмная работает: ПН–ПТ 10:00–18:00». Даже если вы всё равно не пускаете, вы делаете поведение сервера дружелюбным и объяснимым (а это редкость, но приятно).
А теперь важный практический момент из нашего дня: 405 возникает раньше, чем чтение body и валидация. Если метод не разрешён, нет смысла читать JSON, не нужно звать ObjectMapper, не нужно гонять валидатор. Иначе вы легко получите странную ситуацию: клиент отправил неправильный метод, а сервер ответил ему «JSON сломан» или «title обязателен». Формально это может быть правдой, но по смыслу — это ответ не на тот вопрос.
3. Allow и связь с 405
Allow — это HTTP-заголовок, который сообщает клиенту список методов, допустимых для конкретного ресурса/пути. В рамках нашего курса нам важна очень простая связка: если вы отдаёте 405 Method Not Allowed, то почти всегда стоит добавить Allow, и перечислить там методы, которые этот путь действительно поддерживает.
Дисциплина тут такая: Allow относится к конкретному пути, а не «ко всему приложению». Если /health поддерживает только GET, то Allow должен быть GET. Если /api/v1/reading-list/{id} поддерживает GET, PUT, DELETE, то Allow должен содержать именно их, даже если где-то ещё в приложении есть PATCH для статуса.
В нашем API мы также сохраним единый error contract: даже для 405 мы вернём JSON ErrorResponse, чтобы клиенту не приходилось писать разные парсеры ошибок. Allow при этом остаётся «транспортной подсказкой» в заголовках — это как вывеска на входе, а JSON — как понятное объяснение словами.
Мини-кусочек кода, который обычно выглядит максимально скучно (а значит, максимально правильно), такой:
import java.util.Set;
String allowHeaderValue(Set<String> allowedMethods) {
// В HTTP методы в Allow перечисляются через запятую и пробел: "GET, POST"
return String.join(", ", allowedMethods);
}
Здесь нет магии: по стандарту методы перечисляются через запятую. Да, это тот редкий случай, когда «склеить строки» — действительно то, что нужно.
4. Маршруты ReadLater и методы
Чтобы корректно отвечать 405, серверу нужно знать: «если путь распознан, какие методы для него допустимы?». В больших системах это хранится в роутере/фреймворке, но у нас — plain Java и ручная маршрутизация, поэтому мы делаем это явно и просто. Для учебного API вполне нормально иметь маленькую «табличку истин» прямо в коде.
Ниже — наша целевая карта путей. Заметьте, что мы описываем паттерны путей: {id} — это «какой-то один сегмент после reading-list/», и он может оказаться нечисловым. Это важно: мы хотим отличать 405 от 400, а 400 за невалидный id будет решаться позже.
| Путь (pattern) | Разрешённые методы | Короткий смысл |
|---|---|---|
| /health | GET | Проверка «жив ли сервер» |
| /api/v1/reading-list | GET, POST | Список и создание |
| /api/v1/reading-list/{id} | GET, PUT, DELETE | Чтение/обновление/удаление по id |
| /api/v1/reading-list/{id}/status | PATCH | Частичное обновление статуса |
С этой таблицей можно мысленно проверить несколько ситуаций. Например, GET /api/v1/reading-list/1/status должен быть 405 (путь существует), а GET /api/v1/reading-list/1/status/extra — 404 (путь уже другой).
5. allowedMethods(path): распознавание пути
Сейчас будет важный «взрослый» нюанс. Нам хочется распознать путь так, чтобы "/api/v1/reading-list/abc" всё ещё считался «путём элемента», иначе мы никогда не сможем вернуть корректный 400 за нечисловой id. Поэтому для маршрутизации по path мы проверяем структуру сегментов, но не валидируем их смысл (например, что id — число). Смысловые проверки оставим на шаг «parsing/validation».
Ниже пример максимально учебного, но полезного резолвера. Обратите внимание на паттерн [^/]+: это «один сегмент, не содержащий /». То есть «что угодно, лишь бы не пусто и не с дроблением на два сегмента».
import java.util.Set;
Set<String> allowedMethods(String path) {
// Сначала — самые простые и точные совпадения
if ("/health".equals(path)) return Set.of("GET");
if ("/api/v1/reading-list".equals(path)) return Set.of("GET", "POST");
// "{id}" — это ровно один сегмент, а не обязательно число (смысл проверим позже)
if (path.matches("/api/v1/reading-list/[^/]+")) return Set.of("GET", "PUT", "DELETE");
// Статусный endpoint — отдельный маршрут
if (path.matches("/api/v1/reading-list/[^/]+/status")) return Set.of("PATCH");
// Путь не распознан — значит, это "маршрут не найден" (потом это станет 404)
return Set.of();
}
Почему это не «самописный фреймворк»? Потому что мы не строим универсальную систему маршрутов, не делаем аннотации, не тащим отражение, не придумываем DSL. Мы просто честно кодируем четыре правила для четырёх путей нашего учебного API. Это ровно тот уровень простоты, который ещё полезен, а не опасен.
6. В handler: 404/405 до body и валидации
Самый важный итог лекции — порядок проверок. Сначала мы решаем, существует ли путь, затем разрешён ли метод, и только потом начинаем «дорогие» операции: чтение body, ObjectMapper.readValue(...), валидация DTO, вызов service и repository. Этот порядок делает API логически правильным и избавляет от странных ошибок «мы возвращаем валидацию, хотя метод вообще не поддержан».
Ниже — компактный фрагмент того, как обычно выглядит начало handle(...) в нашем ReadingListHttpHandler. Тут уже чувствуется «backend-ритм»: быстро выяснили transport-условия, и только если они ок — идём в бизнес-шаги.
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.Set;
void handleRoutingGuard(HttpExchange exchange, String path) throws IOException {
// Метод запроса (GET/POST/PUT/...)
String method = exchange.getRequestMethod();
// Список допустимых методов для конкретного path (если пусто — path не распознан)
Set<String> allowed = allowedMethods(path);
// Сначала отвечаем на вопрос "существует ли маршрут?"
if (allowed.isEmpty()) {
writeNotFound(exchange);
return;
}
// Потом — "разрешён ли метод для этого маршрута?"
if (!allowed.contains(method)) {
writeMethodNotAllowed(exchange, allowed);
return;
}
// Важно: здесь мы всё ещё не читали body и не валидировали DTO
}
Заметьте две вещи. Во‑первых, мы не читаем exchange.getRequestBody() вообще. Во‑вторых, writeNotFound(...) и writeMethodNotAllowed(...) — это как раз те места, где вы используете единый ErrorResponse из прошлой лекции, а в случае 405 ещё добавляете Allow.
И вот пример writeMethodNotAllowed(...) в минимальном виде. Он короткий и «не умный», но делает ровно то, что нужно по контракту.
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.List;
import java.util.Set;
void writeMethodNotAllowed(HttpExchange exchange, Set<String> allowed) throws IOException {
// Транспортная подсказка клиенту: какие методы допустимы именно для этого path
exchange.getResponseHeaders().set("Allow", String.join(", ", allowed));
// При этом тело ошибки — в нашем едином JSON-контракте
writeJson(exchange, 405, new ErrorResponse(
"METHOD_NOT_ALLOWED", "Метод не поддерживается для этого пути", List.of()
));
}
Здесь writeJson(...) — ваша уже существующая утилита из предыдущих дней (которая выставляет Content-Type: application/json; charset=utf-8, шлёт статус и сериализует body). Если вы в проекте централизовали error mapping, вы можете формировать ErrorResponse не прямо тут, а через общий фабричный метод, но сама идея не меняется.
7. Проверка 405 через Postman/curl
Когда вы реализовали 405, очень полезно сделать короткую ручную проверку, потому что это один из тех случаев, где «код вроде компилируется», но контракт легко перепутать. Главное, что вы должны увидеть: статус 405, заголовок Allow, и JSON-ошибка в едином формате.
Вот пример запроса, который часто случается по ошибке: клиент пытается сделать POST на item-путь, потому что перепутал «создание» и «обновление».
# Неправильный метод для item endpoint
curl -i -X POST http://localhost:8080/api/v1/reading-list/1
# HTTP/1.1 405 Method Not Allowed
# Allow: GET, PUT, DELETE
Если вместо этого вы увидите 404, значит вы проверяете method+path «в одной куче» и не умеете отличать существующий путь от неверного метода. Если увидите 400 с сообщением про JSON — значит вы читаете body и валидируете DTO раньше, чем проверяете разрешённость метода (что по смыслу неправильно).
А вот ещё один показательный пример: GET на статусный endpoint. Он существует, но поддерживает только PATCH.
# Endpoint существует, но GET для него запрещён
curl -i http://localhost:8080/api/v1/reading-list/1/status
# HTTP/1.1 405 Method Not Allowed
# Allow: PATCH
И, наконец, пример, где должен быть именно 404: путь не похож ни на один из наших маршрутов, даже структурно.
# Маршрут не распознан даже структурно — это 404
curl -i http://localhost:8080/api/v1/reading-list/1/status/extra
# HTTP/1.1 404 Not Found
Эти три проверки дают очень хорошее чувство: вы не просто «что-то возвращаете», а реально отвечаете на правильный вопрос правильным статусом.
8. Типичные ошибки при работе с 405 и Allow
Ошибка №1: возвращать 404 для существующего пути с неподдерживаемым методом.
Такое почти всегда получается, когда роутинг написан в стиле «ищем совпадение method+path сразу». Например, вы проверяете if (method.equals("GET") && path.equals("/api/v1/reading-list")), а в else отдаёте 404. В результате PUT /api/v1/reading-list превращается в «маршрут не найден», хотя маршрут есть. Лечится это не «добавим ещё if-ов», а сменой порядка мышления: сначала распознаём путь, потом проверяем метод.
Ошибка №2: формировать Allow как “методы всего приложения”.
Это типичная логическая ошибка: «ну у нас же вообще есть GET/POST/PUT/PATCH/DELETE, давайте их и напишем». Но Allow должен отражать только то, что допустимо для данного пути. Если клиенту написать лишние методы, это не подсказка, а дезинформация: он попробует PATCH /api/v1/reading-list, получит снова 405 и начнёт сомневаться в вашей адекватности (и будет прав).
Ошибка №3: читать body и валидировать DTO до проверки метода.
Это не просто «лишняя работа», это ещё и источник неправильных ответов. Например, клиент сделал GET с body (да, такое иногда встречается), вы попытались распарсить JSON и выдали 400 за сломанный payload, хотя правильнее было бы сказать 405 (если GET не поддерживается для этого пути) или спокойно обработать GET без чтения body. В рамках нашего курса правило простое: проверка 404/405 должна быть максимально ранней.
Ошибка №4: пытаться определить существование пути через “валидность id”.
Если вы решите: «маршрут /reading-list/{id} существует только если {id} — число», то запрос GET /api/v1/reading-list/abc станет 404, а не 400. Это ломает ту самую аккуратную семантику, которую мы строили на Дне 24: нечисловой id — это плохой запрос (400), а не «маршрут исчез». Для этого path-распознавание должно быть структурным (сегменты и константные части пути), а смысловые проверки — отдельным шагом.
Ошибка №5: отвечать на 405 plain text-ом, когда всё остальное — JSON.
Технически сервер может вернуть что угодно, но с точки зрения клиента это резко ухудшает жизнь: он должен уметь парсить ошибки в двух форматах. В нашем проекте цель прямо противоположная: все ошибки — ErrorResponse. Поэтому для 405 мы тоже возвращаем JSON в единой форме, просто добавляя Allow как транспортную подсказку.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ