JavaRush /Курсы /Java Server /URI: path, query par...

URI: path, query params, headers

Java Server
23 уровень , 1 лекция
Открыта

1. URI и заголовки после маршрутизации

После того как вы научились выбирать ветку обработки по method + path, появляется ощущение: “ну всё, сервер почти готов”. И тут реальность мягко кладёт руку на плечо и говорит: “А теперь достань из запроса id, фильтры, и ещё проверь Content-Type”. Это отдельная задача, и она должна происходить после выбора маршрута, иначе вы начнёте парсить id даже там, где id не существует (например, в /health).

В HTTP‑запросе данные живут в разных местах, и это не прихоть стандарта, а способ сделать контракт предсказуемым. Путь обычно отвечает за “какой ресурс” (например, конкретный элемент списка чтения), query params — за “какими условиями его читать” (фильтр/поиск), headers — за “служебную информацию” (формат тела, ожидаемый формат ответа и т.д.). Если вы смешаете всё в одну “строку запроса” и начнёте выдёргивать значения через contains(...) и магические substring(...), вы получите код, который ломается от лишнего слэша или пробела — то есть от обычной жизни.

Чтобы зафиксировать картину, полезно держать в голове вот такую мини‑таблицу (она поможет не путать “куда что класть” и “откуда что читать”):

Где лежит значение Пример Обычно для чего Как читаем в HttpExchange
Path /api/v1/reading-list/42 идентичность ресурса (id) exchange.getRequestURI().getPath()
Query string ?status=PLANNED&title=clean фильтры, параметры чтения exchange.getRequestURI().getQuery() / getRawQuery()
Headers Content-Type: application/json метаданные запроса/ответа exchange.getRequestHeaders().getFirst(...)

И ещё один важный момент: в этом курсе мы сознательно не пишем “универсальный роутер” и не строим мини‑Spring. Мы делаем маленькие, понятные утилиты, которые убирают дублирование, но не скрывают механику. То есть ровно столько удобства, чтобы не страдать — и ровно столько прозрачности, чтобы понимать, что происходит.

2. URI в HttpExchange: path и query

Когда вы впервые видите exchange.getRequestURI(), хочется взять toString() и дальше жить с этим как с обычной строкой. Это примерно как взять паспорт, отсканировать в JPEG и потом пытаться “прочитать возраст” через поиск по пикселям. Можно, но зачем? URI уже умеет отдавать части адреса отдельно — и этим стоит пользоваться, чтобы не перепутать путь и query‑часть.

Минимальный “скелет” чтения URI выглядит так: берём URI из HttpExchange, отдельно читаем path, отдельно читаем query. И сразу закладываемся на то, что query может быть null (если ? в запросе не было). Это не ошибка клиента, это нормальная ситуация: фильтр не задан — значит, фильтровать не нужно.

Небольшой пример “разделили и не перепутали”:


import com.sun.net.httpserver.HttpExchange;
import java.net.URI;

// URI содержит структуру адреса: path, query и т.д.
URI uri = exchange.getRequestURI();

// path — то, по чему мы маршрутизируем (без query)
String path = uri.getPath();

// query — параметры после '?', может быть null
String query = uri.getQuery();

Чтобы почувствовать разницу, представьте два запроса:

1) GET /api/v1/reading-list/42
2) GET /api/v1/reading-list/42?debug=true

У них одинаковый path, но второй несёт дополнительный параметр в query string. Если вы будете работать с “полным URI как строкой”, вы легко случайно начнёте сравнивать маршрут с "/api/v1/reading-list/42?debug=true" и получите “маршрут не найден” там, где всё корректно.

Иногда полезно использовать getRawQuery() вместо getQuery(). В реальной жизни query‑часть может содержать URL‑кодирование (%20 и т.п.), и “raw” вариант честнее показывает то, что реально пришло по HTTP. Для простого учебного сервера это не критично, но полезно знать, что такой метод есть.


import java.net.URI;

// rawQuery отдаёт query как пришло по сети (с %20 и т.п.), может быть null
String rawQuery = uri.getRawQuery();

С этого места начинается дисциплина: маршрут выбираем по path (без query), а query разбираем отдельно, только когда нам реально нужны фильтры.

3. Сегменты пути и извлечение id

Путь в URI — это строка вида /api/v1/reading-list/42. Логически она состоит из сегментов (кусочков между /). Это очень удобно, потому что многие маршруты строятся по простому правилу: “последний сегмент — идентификатор”. Но тут есть коварная мелочь: из‑за ведущего слэша первый элемент после split("/") часто оказывается пустым. А ещё бывает хвостовой слэш, и тогда последний элемент может быть пустым. Такие “пустые сегменты” — классическая ловушка для новичка.

Самый приятный способ — сделать маленькую функцию, которая превращает path в список сегментов, фильтруя пустые строки. Тогда и ведущий /, и случайный хвостовой / перестают быть проблемой.


import java.util.Arrays;
import java.util.List;

private List<String> pathSegments(String path) {
    // Делим путь по '/', а потом выбрасываем пустые элементы
    // (они появляются из-за ведущего '/' или двойных/хвостовых слэшей)
    return Arrays.stream(path.split("/"))
            .filter(s -> !s.isBlank())
            .toList();
}

Теперь можно легко получить последний сегмент, не думая, что там “в начале пусто, в конце пусто, а где вообще мои данные”.


import java.util.List;

private String lastSegment(String path) {
    List<String> segments = pathSegments(path);

    // Пустой список сегментов — это, по сути, некорректный путь для нашего кейса
    if (segments.isEmpty()) {
        throw new IllegalArgumentException("Пустой путь");
    }

    // Последний сегмент часто и есть id (например, /reading-list/42)
    return segments.get(segments.size() - 1);
}

И уже поверх этого — извлечь id как long. Обратите внимание: Long.parseLong(...) бросит NumberFormatException, и это нормально. На уровне HTTP‑границы мы часто хотим превратить такие ошибки в “понятное” сообщение вроде “id должен быть числом”. Технически это всё ещё “ошибка запроса клиента”, а не “сломался сервер”.


private long parseIdFromPath(String path) {
    try {
        // Берём последний сегмент и ожидаем, что это число
        return Long.parseLong(lastSegment(path));
    } catch (NumberFormatException e) {
        // Это не ошибка сервера: клиент прислал нечисловой id
        throw new IllegalArgumentException("id должен быть числом");
    }
}

Ключевой дисциплинарный момент: parseIdFromPath(...) вызывается только в той ветке, где маршрут действительно ожидает id. То есть сначала маршрутизация сказала “это похоже на /api/v1/reading-list/{id}”, и только потом мы полезли извлекать id. Если вы начнёте парсить id “на входе вообще для всех запросов”, то запрос GET /health внезапно начнёт падать с ошибкой “id должен быть числом”. И это будет не клиент виноват, а вы, потому что решили, что любой путь обязан заканчиваться числом. (Да, это звучит глупо — но на практике именно так и появляются странные баги.)

Чтобы связать это с нашим проектом, представим, что в лекции 1 у нас уже есть ветка маршрутизации:


// Сначала — выбор ветки по method + path (без query)
if (method.equals("GET") && path.startsWith("/api/v1/reading-list/")) {
    // Только тут мы ожидаем, что в конце пути есть id
    handleGetByIdRoute(exchange);
    return;
}

Внутри handleGetByIdRoute(...) можно сделать аккуратный разбор:


// В этой ветке маршрут уже выбран, поэтому безопасно доставать id из path
String path = exchange.getRequestURI().getPath();
long id = parseIdFromPath(path);

// дальше — пока просто печатаем/логируем, бизнес-логики ещё нет

Мы сознательно не лезем дальше в прикладную логику и не делаем “получить объект из репозитория” — это будет позже. Сейчас цель — научиться без истерики доставать из path то, что там лежит.

4. Query string и параметры в Map

Query string — это кусок URI после вопросительного знака. Там обычно живут параметры чтения: фильтры, поиск, лимиты. В нашем ReadLater API это пригодится очень скоро, потому что список можно читать не только целиком, но и, например, с фильтрами. Но сегодня наша задача не “как фильтровать”, а “как прочитать параметры в Java, чтобы дальше можно было с ними работать”.

Первое, что нужно принять: uri.getQuery() может вернуть null. Это не “ошибка запроса”, просто параметров не было. Второе: query string — это формат вида key=value&key2=value2, но в значениях могут быть спецсимволы и пробелы, поэтому часто используется URL‑кодирование (%20 вместо пробела и т.п.). Мы не будем уходить в сложные кейсы, но базовую декодировку сделать полезно — иначе title=clean%20code останется “как есть” и будет выглядеть странно.

Сначала сделаем маленький decode(...) с URLDecoder. Он не идеален для всех сценариев мира, но для учебного API более чем окей.


import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

private String decode(String value) {
    // Декодируем %XX-последовательности (например, %20 -> пробел)
    return URLDecoder.decode(value, StandardCharsets.UTF_8);
}

Теперь парсер query string в Map<String, String>. Мы специально делим pair.split("=", 2), потому что значение может содержать =. Если делить без ограничения, вы получите “кусочки токена” вместо значения — и будете грустить, не понимая, почему у вас “пропали символы”.


import java.util.HashMap;
import java.util.Map;

private Map<String, String> parseQuery(String query) {
    Map<String, String> result = new HashMap<>();

    // Если query отсутствует — это нормально, просто вернём пустую map
    if (query == null || query.isBlank()) return result;

    // Формат: key=value&key2=value2
    for (String pair : query.split("&")) {
        // Делим на ключ и значение только один раз
        String[] parts = pair.split("=", 2);

        // Ключ всегда есть (хотя может быть пустым — это уже странный запрос)
        String key = decode(parts[0]);

        // Значение может отсутствовать: ?title= -> parts.length == 2, value == ""
        // или даже ?title -> parts.length == 1, тогда считаем значение пустой строкой
        String value = parts.length == 2 ? decode(parts[1]) : "";

        // Для учебного кейса достаточно Map<String, String> (последнее значение победит)
        result.put(key, value);
    }
    return result;
}

И как это выглядит “на месте” в handler‑коде:


URI uri = exchange.getRequestURI();

// Читаем rawQuery, чтобы видеть реальную строку (с %XX), а не "уже декодированную"
Map<String, String> queryParams = parseQuery(uri.getRawQuery());

// Параметры могут отсутствовать — это нормальная ситуация
String status = queryParams.get("status"); // может быть null
String title = queryParams.get("title");   // может быть null

Обратите внимание на философию: мы считаем нормой, что ключ может отсутствовать. status == null означает “фильтр по статусу не задан”, а не “ой, всё пропало, срочно 500”. Реальная жизнь HTTP‑API почти всегда начинается именно с таких “может быть null”.

Иногда удобно иметь маленький безопасный геттер: “верни параметр, но только если он реально непустой”. Это помогает не путать ?title= и отсутствие параметра.


import java.util.Map;
import java.util.Optional;

private Optional<String> queryParam(Map<String, String> params, String name) {
    // ofNullable — чтобы отсутствие ключа не приводило к NPE
    return Optional.ofNullable(params.get(name))
            // filter — чтобы отсечь пустую строку (например, ?title=)
            .filter(v -> !v.isBlank());
}

Тогда использование становится понятнее, даже если вы пока не фанат Optional:


// Если параметр не задан или пустой — получим Optional.empty()
Optional<String> title = queryParam(queryParams, "title");

И ещё один честный нюанс: в реальных API может быть несколько одинаковых query параметров (tag=java&tag=backend). Наш простой Map такой сценарий “схлопнет” (оставит последнее значение). Для курса это нормально: мы не строим поисковый движок. Главное — научиться базово разбирать query, не утонув в строковых операциях.

5. Headers: Content-Type, Accept и null

После URI у нас остаётся ещё один источник входных данных: заголовки. Для новичка headers часто выглядят как “какая-то служебная пыль”, но в HTTP они реально меняют поведение. Самый полезный для нас сейчас — Content-Type: он говорит, что лежит в request body. От этого зависит, можно ли вообще пытаться читать тело как JSON и маппить его в request DTO.

В HttpExchange заголовки читаются через exchange.getRequestHeaders(). Это объект типа Headers, который ведёт себя как map “имя заголовка → список значений”. Но для простого учебного API чаще всего достаточно getFirst(...), чтобы взять первое значение.


import com.sun.net.httpserver.HttpExchange;

// Заголовки запроса; getFirst(...) вернёт первое значение или null, если заголовка нет
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
String accept = exchange.getRequestHeaders().getFirst("Accept");

И вот здесь важно психологически принять: contentType может быть null. Например, в GET /health обычно нет тела запроса, и клиент не обязан отправлять Content-Type. Если ваш код будет думать “заголовок обязателен всегда”, вы начнёте “ломать” вполне корректные запросы.

Когда мы дойдём до JSON‑тела, нам понадобится мягкая проверка: “если endpoint ожидает JSON, то Content-Type должен быть application/json”. Для учебного уровня достаточно проверки через startsWith(...), потому что реальные заголовки часто выглядят так: application/json; charset=UTF-8.


private boolean isJsonContentType(String contentType) {
    // null — нормальный вариант для запросов без body
    return contentType != null && contentType.startsWith("application/json");
}

Если вам хочется сразу делать “строже”, можно выбрасывать IllegalArgumentException (а потом переводить её в 400 Bad Request рядом с handler‑кодом). Мы пока не строим идеальную систему ошибок, но уже учимся отделять “плохой запрос” от “плохого сервера”.


private void requireJsonContentType(String contentType) {
    // Проверка нужна только там, где реально ожидается JSON-тело
    if (!isJsonContentType(contentType)) {
        throw new IllegalArgumentException("Content-Type должен быть application/json");
    }
}

И пример использования в конкретной ветке маршрута (не на входе вообще, а там, где есть body):


// Content-Type имеет смысл проверять только в эндпоинтах с body (например, POST/PUT)
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
requireJsonContentType(contentType);

// дальше уже будем читать body

В headers можно читать и любые “пользовательские” заголовки, если вы захотите. Но важное правило для новичка простое: пока вы не уверены, что заголовок обязателен по контракту, относитесь к его отсутствию как к нормальному варианту, а не как к катастрофе.

6. Утилиты в readinglist.http

Когда вы в одном методе handle(...) и маршрутизируете, и режете путь на сегменты, и разбираете query string, и читаете заголовки — метод начинает распухать. Потом вы добавляете второй endpoint и копируете те же parseQuery(...) и lastSegment(...) в другой класс. Потом третий. И внезапно вы сами себе построили “фреймворк на копипасте”. У фреймворков хотя бы документация бывает — у копипасты нет даже этого.

На масштабе одного handler-а такие вещи спокойно живут как private helper-методы рядом с route(...). Это нормальный baseline дня. Отдельный utility‑класс нужен только тогда, когда path/query/header‑шум начал реально повторяться в нескольких местах. То есть это не вторая обязательная архитектура, а обычная extraction, когда код сам попросил об этом.

Если захотите вынести самые механические операции, это может выглядеть так:


import com.sun.net.httpserver.HttpExchange;
import java.net.URI;

final class HttpRequestUtils {

    private HttpRequestUtils() {
        // Утилитный класс: экземпляры не нужны
    }

    static URI uri(HttpExchange exchange) {
        // Централизуем доступ к URI — дальше можно расширять (rawQuery, host и т.д.)
        return exchange.getRequestURI();
    }

    static String path(HttpExchange exchange) {
        // path используем для маршрутизации (без query)
        return uri(exchange).getPath();
    }
}

Такие helper-ы чаще всего подключаются в body-less GET-ветках: роутер выбрал маршрут, дальше вы достали id из path, фильтры из query, нужный header — и пошли дальше. В ветках с body к этому добавляется ещё один шаг: проверить Content-Type, потом читать тело.

Вот как это может упростить код в обработчике маршрута:


// handler-код читает "сценарием", а не россыпью технических деталей
String path = HttpRequestUtils.path(exchange);

// Query и заголовки — отдельные источники данных (и их читаем отдельно)
Map<String, String> queryParams = parseQuery(exchange.getRequestURI().getRawQuery());
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");

// дальше — уже логика конкретного маршрута

Смысл этих утилит не в “красоте ради красоты”, а в том, чтобы ваш ReadingListHandler читался как сценарий: “принял запрос → разобрал нужные части → передал дальше”. А технические детали были рядом, но не мешали понимать поток.

И да, это тот самый момент, где у вас начинает появляться уважение к Spring MVC: он делает эти шаги за вас, но не отменяет их существование. Он просто автоматизирует рутину. А мы сейчас честно видим эту рутину вживую — без лишней магии.

7. Типичные ошибки при разборе запроса

Ошибка №1: сравнивать маршрут с полным URI, включая query string.
Если вы сравниваете строку вида "/api/v1/reading-list?status=PLANNED" с "/api/v1/reading-list", вы случайно превращаете фильтрацию в “другой маршрут”. Правильный подход — маршрутизировать по uri.getPath(), а query разбирать отдельно. Тогда GET /api/v1/reading-list и GET /api/v1/reading-list?status=... попадут в один и тот же handler, как и должно быть.

Ошибка №2: split("/") и неожиданная пустая строка в начале.
Путь начинается с /, поэтому "/health".split("/") даст ["", "health"]. Если не фильтровать пустые сегменты, вы начинаете писать странные индексы и условия. Гораздо проще сделать pathSegments(...), который выбрасывает пустые строки — и дальше работать с нормальными сегментами.

Ошибка №3: пытаться парсить id до выбора маршрута.
Это классика: вы решили, что “мне нужен id”, и начинаете сразу на входе вызывать Long.parseLong(lastSegment(path)). В результате запрос /health превращается в “id должен быть числом”. Поэтому порядок такой: сначала маршрут, потом парсинг именно тех данных, которые нужны этому маршруту.

Ошибка №4: разбор query string без проверки на null.
uri.getQuery()getRawQuery()) возвращает null, если ? отсутствует. Это не повод для 500. Это повод вернуть пустую карту параметров и спокойно жить дальше. На уровне API отсутствие фильтра — нормальная ситуация, а не “ошибка клиента”.

Ошибка №5: split("=") без ограничения, из‑за чего ломаются значения.
Значение query‑параметра может содержать =. Даже если в нашем учебном проекте это редко, привычку лучше поставить правильную: split("=", 2). Тогда вы гарантированно отделяете ключ от значения один раз, а не “режете” значение на куски.

1
Задача
Java Server, 23 уровень, 1 лекция
Недоступна
Чтение числового `id` из последнего сегмента пути
Чтение числового `id` из последнего сегмента пути
1
Задача
Java Server, 23 уровень, 1 лекция
Недоступна
Query-параметры и заголовок запроса
Query-параметры и заголовок запроса
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ