1. URI у транспортному шарі
Коли ви лише починаєте писати HTTP-клієнт, дуже хочеться сприймати адресу як декоративний рядок: «ну я ж бачу URL, зараз зберу його через + і поїхали». Але в бекенд-світі адреса — це частина контракту, майже як сигнатура методу. Якщо ви помилилися в URI, то навіть не дійшли до етапу «HTTP як протокол»: запит буде неправильним ще до того, як мережа встигне вас розчарувати.
У ReadLater Starter адреса — це буквально вхід до зовнішнього каталогу. Неправильно зібраний рядок може означати що завгодно: від «404 Not Found» до «400 Bad Request» або просто дивних даних. І найнеприємніше: такі помилки часто виглядають як «API зламаний» або «HttpClient кривий», хоча насправді ви просто надіслали запит за неправильною адресою. Тобто це суто транспортна проблема: не бізнес-логіка і не «провайдер поганий», а ви припустилися помилки в адресі (усі ми через це проходили).
Запам’ятаймо просту думку: коректний URI — це перший шар надійності вашого HTTP-коду. Якщо він зібраний акуратно, у вас одразу менше «магічних» помилок, менше загадкових багів і більше впевненості, що якщо щось не працює, проблема вже далі по ланцюжку, а не в банальному пробілі в рядку.
У транспортному коді все йде ланцюжком: спочатку треба не зламати саму адресу, потім перетворити її на HttpRequest, а потім пережити таймаути та невдалі статуси. Якщо промахнутися вже на URI, далі ви діагностуватимете не ту проблему. Тому почнімо з найпершого шару — як узагалі складати адресу без лотереї.
Небезпеки конкатенації рядків
Рядки в Java — річ зручна, але підступна. Поки у вас адреса має вигляд baseUrl + "/search?q=java", усе виглядає нормально. Але варто з’явитися пробілам, спецсимволам або кільком параметрам — і ви починаєте жити у світі ?, &, = та невдоволених виразів обличчя, коли «ніби все правильно, а сервер чомусь не розуміє».
Уявіть, що користувач шукає книгу за запитом "clean code". Якщо ви зберете адресу так:
// Базова адреса сервісу (зазвичай береться з конфігурації)
String baseUrl = "https://catalog.example";
// Вхід користувача: потенційно містить пробіли та спецсимволи
String query = "clean code";
// Склеюємо "як є" — це і є джерело проблем
String rawUrl = baseUrl + "/search?q=" + query;
System.out.println(rawUrl); // https://catalog.example/search?q=clean code
На око — нормально. Але для URI пробіл усередині query-рядка — це не «просто пробіл». Це символ, який треба закодувати. У кращому разі бібліотека або сервер спробує вас «врятувати». У гіршому — ви отримаєте помилку, яку лікуватимете шаманським бубном і фразою: «Ну воно ж працювало вчора…».
Ще болючіший випадок — коли в запиті є &. Наприклад "java & spring". Якщо ви не закодуєте значення параметра, сервер побачить & як роздільник параметрів і вирішить, що ви надіслали два параметри, а не один. І все: запит змінить зміст.
І, нарешті, дрібниця, але з характером: слеші. Один зайвий / може перетворити https://catalog.example//search на дивну адресу. Багато серверів це переживуть, але «багато» — це не те саме, що «завжди». А ми хочемо передбачуваності, а не лотереї.
2. URI у Java — це тип
У Java є два популярні типи, які новачки часто плутають: URL і URI. Для нашого завдання — складання адреси запиту та передавання її в HttpRequest — нам потрібен саме URI. Це об’єктне представлення адреси, яке допомагає пам’ятати, що в неї є структура, а не просто набір символів.
У JDK HttpRequest.newBuilder(...) працює з URI, і це не випадковість. URI — нейтральніший і безпечніший тип. Він не намагається сам «піти в мережу», а просто описує ідентифікатор ресурсу. Це як «паспорт адреси»: він має бути валідним, інакше ви ловите помилку на ранній стадії й не вдаєте, що все нормально.
У нашій навчальній реальності нам найчастіше достатньо URI.create(...). Це найпряміший шлях: ви зібрали коректний рядок і перетворили його на URI.
import java.net.URI;
// Тут ми прямо кажемо: "очікую коректний URI, інакше падай одразу"
URI uri = URI.create("https://catalog.example/search?q=java");
// Друкуємо, щоб побачити, що вийшло (поки без логера)
System.out.println(uri); // https://catalog.example/search?q=java
URI.create(...) добрий тим, що він кидає IllegalArgumentException, якщо рядок узагалі не схожий на URI. Це корисно: ви ловите проблему раніше, ніж вона піде в «чому send() не працює?».
Але важливо пам’ятати: URI не «полагодить» вам неправильно зібраний query-рядок. Якщо ви поклали туди пробіли й дивні символи — ви самі відповідаєте за те, щоб їх було коректно закодовано. І ось тут нам потрібен наступний герой.
3. URLEncoder: кодуємо значення query
Коли чуєш слово «кодування», хочеться закодувати все й одразу, щоб напевно. Це природне бажання, але воно веде до дуже смішних — і дуже сумних — результатів. Правило просте: кодувати потрібно значення query-параметрів, а не весь URL.
У Java для цього є URLEncoder.encode(..., StandardCharsets.UTF_8). Він перетворює небезпечні символи на безпечну форму. Для пробілу, наприклад, вийде + (так, це історична форма зі світу HTML-форм; багато API нормально її розуміють).
Ось правильний мінімальний приклад:
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
// Базова адреса сервісу (зазвичай береться з конфігурації)
String baseUrl = "https://catalog.example";
// Запит користувача, який потрібно безпечно вкласти в query
String query = "clean code";
// Кодуємо ЛИШЕ значення параметра q
String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
// Збираємо підсумковий URI
URI uri = URI.create(baseUrl + "/search?q=" + encodedQuery);
// Перевіряємо на око, що пробіл перетворився на +
System.out.println(uri); // https://catalog.example/search?q=clean+code
А тепер — приклад «як робити не треба», бо це типова помилка:
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
// Це ВЖЕ цілий URL зі схемою, хостом, шляхом і query
String rawUrl = "https://catalog.example/search?q=clean code";
// Помилка: кодуємо весь URL цілком, ламаючи його структуру
String wrong = URLEncoder.encode(rawUrl, StandardCharsets.UTF_8);
System.out.println(wrong);
// https%3A%2F%2Fcatalog.example%2Fsearch%3Fq%3Dclean+code
На перший погляд — «ну ж закодувалося». Але це вже не URL у людському розумінні. Ви закодували : і /, тобто зламали структуру адреси. Сервер такий запит, найімовірніше, не зрозуміє. Ми хотіли закодувати лише значення q, а не перетворити всю адресу на «шифрограму».
Ще один важливий нюанс: кодувати краще саме значення параметрів. Ключі (q, limit) зазвичай безпечні й у вас фіксовані в коді. Якщо у вас ключі динамічні (що вже звучить підозріло для простого курсу) — тоді треба думати глибше, але в нашому проєкті ключі статичні.
4. baseUrl, path, query: складання
Добре зібрана адреса починається з поваги до структури URL. Навіть якщо ви зрештою використовуєте URI.create(baseUrl + "..."), корисно думати про адресу як про три частини: baseUrl (де живе сервіс), path (який ресурс або endpoint ви хочете) і query (які критерії, фільтри чи параметри ви передаєте).
У ReadLater Starter це дуже наочно. У нас є щонайменше два сценарії звернення до зовнішнього каталогу: пошук і деталі книги. Пошук зазвичай має вигляд GET /search із query-параметрами (наприклад, q і limit). Деталі книги зазвичай мають вигляд GET /books/{externalId}, тобто ідентифікатор ресурсу живе в path, а не в query.
Цю різницю корисно фіксувати прямо в коді, інакше ви легко почнете змішувати все докупи. А потім з’явиться третій сценарій, четвертий, і ваш main() перетвориться на роман на 300 сторінок, де головна інтрига — «де ж я загубив амперсанд».
Щоб спростити життя, вже сьогодні почнімо робити те, що відрізняє бекенд-підхід від «пограюся з HttpClient»: складатимемо URI в одному місці й за однаковими правилами. Навіть якщо поки це будуть просто приватні методи в ReadLaterApplication.
Невелика пам’ятка у вигляді таблиці допомагає втримати дисципліну:
| Що це | Де живе в URL | Приклад | Коли використовувати |
|---|---|---|---|
| Ідентифікатор ресурсу | path | /books/OL12345M | коли звертаємося до конкретної сутності |
| Критерії пошуку або фільтрації | query | ?q=clean+code&limit=5 | коли уточнюємо, що хочемо отримати |
| Базова адреса сервісу | scheme + host (+port) | https://catalog.example | це «де живе API» |
Ця таблиця не про «правильно/неправильно назавжди», а про дисципліну для простого й зрозумілого API-клієнта. На практиці вона робить ваш код помітно спокійнішим.
5. Допоміжні методи складання URI
Зараз ми зробимо маленький, але дуже вигідний крок: винесемо складання URI в методи. Вони будуть невеликими й прямолінійними. Їхнє завдання — не «побудувати універсальний URI builder для всього інтернету», а просто зафіксувати правила саме нашого клієнтського сценарію: пошук і деталі.
На початку можна розмістити методи прямо в ReadLaterApplication. Так, це не фінальна архітектура, але методично це нормально: ми вчимося виділяти відповідальність поступово. Головне — щоб складання адреси не копіювалося по всьому коду.
Приклад для пошуку, де query треба обов’язково кодувати, а limit — число:
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
private static URI buildSearchUri(String baseUrl, String query, int limit) {
// Кодуємо введення користувача, щоб пробіли, &, = тощо не ламали рядок query
String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
// limit — число, воно безпечне (і зазвичай іде як є)
return URI.create(baseUrl + "/search?q=" + encodedQuery + "&limit=" + limit);
}
І приклад для деталей. Тут ми кладемо externalId у path. У нашому навчальному контракті externalId має вигляд OL12345M, тобто є безпечним. Тому ми не ускладнюємо тему окремим «кодуванням сегмента path».
import java.net.URI;
private static URI buildDetailsUri(String baseUrl, String externalId) {
// externalId — частина шляху (path), а не query
return URI.create(baseUrl + "/books/" + externalId);
}
Зверніть увагу на важливу річ: тепер «різна форма адреси» оформлена не в голові й не в коментарях, а в коді. Це дуже допомагає не переплутати сценарії, особливо коли ви дописуєте проєкт за тиждень і вже не пам’ятаєте, чому тоді вирішили зробити саме так.
6. baseUrl: нормалізація
Є одна дрібна вада, яка живе майже в кожному навчальному проєкті, поки хтось не втомиться й не виправить її. Це trailing slash. Тобто baseUrl може бути "https://catalog.example" або "https://catalog.example/". Обидва значення візуально схожі. Але якщо ви сліпо робите baseUrl + "/search", у другому випадку вийде //search.
Частина серверів і проксі на це реагують спокійно, частина — не дуже. І ось ви сидите й думаєте: «Чому в одному середовищі працює, а в іншому 404?». А причина — два слеші поспіль, яких ви навіть не помітили.
Давайте додамо просту нормалізацію. Вона не ідеальна для всіх випадків світу, але для нашого курсу чудово підходить: якщо рядок закінчується /, приберемо його.
private static String normalizeBaseUrl(String baseUrl) {
// Прибираємо кінцевий слеш, щоб далі не отримати //search або //books/...
if (baseUrl.endsWith("/")) {
return baseUrl.substring(0, baseUrl.length() - 1);
}
return baseUrl;
}
Тепер складання URI виглядатиме трохи акуратніше:
// Навіть якщо конфіг приніс baseUrl зі слешем наприкінці — приводимо до нормальної форми
String baseUrl = normalizeBaseUrl("https://catalog.example/");
// Далі збираємо URI одним правилом (через helper), а не "як вийде"
URI uri = buildSearchUri(baseUrl, "clean code", 5);
System.out.println(uri); // https://catalog.example/search?q=clean+code&limit=5
Так, це всього кілька рядків. Але це той випадок, коли кілька рядків економлять кілька годин виснажливого дебагу. Такі дрібниці згодом дуже відрізняють «код, який живе», від «коду, який був написаний учора вночі на натхненні».
7. Самоперевірка: друкуємо URI
До того як ми почнемо ускладнювати клієнт (таймаути, обробка помилок, виділення транспортного шару), дуже корисно навчитися робити маленьку самоперевірку: «я взагалі туди йду?». Бо якщо ви зібралися не туди, далі ви будете виправляти все, окрім причини. Це як лікувати нежить перепрошивкою роутера: технічно можна, але виглядає дивно.
Зробімо невеликий фрагмент коду, який просто друкує підсумкові URI. Так, потім ми замінимо «друк» на логування. Але зараз наша мета — побачити на око, що складання адреси працює так, як задумано.
// У реальному проєкті baseUrl зазвичай приходить із конфігурації, тому нормалізація — корисна звичка
String baseUrl = normalizeBaseUrl("https://catalog.example");
// Пошук: query кодуємо, limit додаємо як число
URI searchUri = buildSearchUri(baseUrl, "java & spring", 3);
// Деталі: ідентифікатор кладемо в path
URI detailsUri = buildDetailsUri(baseUrl, "OL12345M");
// Друкуємо результат: так найпростіше спіймати "куди саме пішов запит"
System.out.println(searchUri); // https://catalog.example/search?q=java+%26+spring&limit=3
System.out.println(detailsUri); // https://catalog.example/books/OL12345M
Тут важливо зауважити, що & у запиті перетворився на %26. Це саме те, чого ми хотіли: значення параметра залишилося одним значенням, а не зламало рядок на «ще один параметр».
Якщо у вас десь у коді досі є склейка URL у стилі baseUrl + "/search?q=" + query, це хороший момент замінити її на виклик допоміжного методу. Не заради «краси», а заради того, щоб правило складання адреси було єдиним і не роз’їжджалося по проєкту.
8. Типові помилки складання URI
Помилка №1: «Я склею все рядками — що може піти не так?»
Це зазвичай працює рівно до першого пробілу або & у query. Помилка тут не в тому, що рядки — зло, а в тому, що ви починаєте змішувати структуру URI (path/query) і дані користувача в один «бульйон». Правильна думка: дані користувача майже завжди потрібно кодувати.
Помилка №2: кодувати весь URL замість значення параметра.
Найчастіше це стається з добрих намірів: «мені сказали, що треба encode, от я й encode». У підсумку ви закодували https:// і слеші, і адреса перестала бути адресою. Кодувати потрібно те, що потенційно містить спецсимволи, тобто значення query-параметрів.
Помилка №3: забути нормалізувати baseUrl і отримати подвійний //.
Це не завжди ламає запит, тому помилка підступна. Вона проявляється «іноді» і «не всюди». А такі баги — найнеприємніші, бо мозок одразу починає підозрювати мережу, провайдера, Java або місяць у фазі ретроградного Меркурія. Краще просто привести baseUrl до єдиного вигляду.
Помилка №4: намагатися запхати ідентифікатор ресурсу в query-параметри без причини.
Наприклад, робити /books?id=OL12345M, коли контракт передбачає /books/OL12345M. Це не «смертельно», але в більшості API ідентифікатор — частина шляху (path), а query — фільтри та критерії. Якщо ви дотримуєтеся цієї дисципліни, код стає передбачуванішим.
Помилка №5: дублювати правила складання URI у кількох місцях.
Сьогодні ви додали &limit=5 в одному місці, завтра — забули в іншому, післязавтра — змінили path /search на /books/search і половина коду продовжила ходити за старою адресою. Це типова причина «воно працює через раз». Один допоміжний метод або один невеликий клас для складання адрес одразу вирішує цю проблему.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ