JavaRush /Курси /Swift SELF /URL і URLRequest: збираємо запит без !

URL і URLRequest: збираємо запит без !

Swift SELF
Рівень 63 , Лекція 0
Відкрита

1. Навіщо URL(string:) повертає URL?

Коли ви вперше бачите URL(string:), може здатися, що Swift просто кепкує: «Я ж дав рядок, чого тобі ще треба?» Але URL — це не просто рядок. Це вже перевірена структура, у якій Foundation гарантує базову коректність формату. Якщо рядок пошкоджений — наприклад, у ньому є пробіли, дивні символи або відсутня схема, — URL може не побудуватися, і Swift чесно повідомляє про це через Optional.

Важливо зрозуміти: Optional — це не «заважає жити», а «вмикає фари в темряві». Особливо це помітно в мережевому коді, де рядок URL часто надходить ззовні: з конфігурації, від користувача, із файла або з аргументів CLI. Якщо поставити !, ви не розв’язуєте проблему — ви переносите її в середовище виконання, де вона вибухне в найневдаліший момент.

Погляньмо на контраст.

import Foundation

let s = "https://example.com/api"
let url = URL(string: s)!          // ❌ якщо s раптом зіпсується — усе впаде
print(url)

А ось доросліший варіант — не нудний, а просто з меншим числом сюрпризів:

import Foundation

enum RequestBuildError: Error {
    case invalidURLString(String)
}

func makeURL(from s: String) throws -> URL {
    guard let url = URL(string: s) else {
        throw RequestBuildError.invalidURLString(s)
    }
    return url
}

Тут ми зробили ключовий крок: помилка побудови URL стала звичайною керованою ситуацією, а не падінням процесу.

Мініконтракт: збирання запиту теж може не вдатися

Коли люди починають писати мережевий код, вони часто думають так: «Ну, запит я точно зберу, а от мережа може впасти». На практиці ламається і те, і те. Неправильний URL, хибний метод, забутий Content-Type, порожнє тіло, яке «чомусь» має бути JSON… Усе це — помилки збирання запиту, і вони мають бути такими ж явними, як і помилки мережі.

Зафіксуймо зручний принцип: усе, що може піти не так до відправлення запиту, має бути виражене в коді через перевірки та валідацію. Або через throws, або через Result. У курсі ми часто використовуємо throws там, де помилка — це саме «не змогли зібрати», і її потрібно підняти вгору по стеку, щоб показати людині повідомлення.

Створімо невеликий тип помилки, який приємно читати в налагоджувачі.

import Foundation

enum RequestBuildError: Error, CustomStringConvertible {
    case invalidBaseURL(String)
    case invalidPath(String)

    var description: String {
        switch self {
        case .invalidBaseURL(let s):
            return "Некоректний базовий URL: \(s)"
        case .invalidPath(let s):
            return "Некоректний шлях: \(s)"
        }
    }
}

CustomStringConvertible тут — не розкіш. Це спосіб зробити так, щоб помилка виглядала як людина, а не як “Error Domain=… Code=…”.

2. Метод і заголовки без описок

HTTPMethod: маленький enum проти великих проблем

URLRequest.httpMethod — це рядок. Рядок! Тобто технічно ви можете написати "GE T" або "Get" і довго дивуватися на сервер, який раптом почне відповідати дивно. А він, між іншим, матиме рацію. Тому, навіть якщо URLRequest не змушує нас бути акуратними, ми самі себе змусимо — маленьким enum.

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

import Foundation

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

Якщо ви помилитеся, компілятор не промовчить.

URLRequest як контейнер запиту

Важливо побачити картину цілком. URL — це адреса. Але HTTP‑запит — це не лише адреса. Запит — це адреса + метод + заголовки + (інколи) тіло + налаштування на кшталт таймауту. У Swift усе це зберігається в URLRequest.

Ми поки не запускаємо запит — це буде в наступній лекції через URLSession.dataTask. Зараз ми просто збираємо URLRequest так, ніби завтра цей код потрапить у продакшен, хай і навчальний.

Створімо функцію, яка збирає базовий GET‑запит.

import Foundation

func makeGetRequest(url: URL) -> URLRequest {
    var request = URLRequest(url: url)
    request.httpMethod = HTTPMethod.get.rawValue
    request.setValue("application/json", forHTTPHeaderField: "Accept")
    request.timeoutInterval = 10
    return request
}

Зверніть увагу на три речі.

  • По-перше, var request — це нормально. URLRequest — value type, тож ми спокійно його змінюємо.
  • По-друге, Accept: application/json означає: «Сервере, надішли мені JSON».
  • По-третє, таймаут — це не «прискорювач інтернету», а запобіжник, щоб не чекати вічність.

Accept vs Content-Type: що ми хочемо отримати і що надсилаємо

Цю плутанину новачки зустрічають так часто, що це вже майже обряд ініціації.

  • Accept — це те, що ми хочемо отримати у відповідь.
  • Content-Type — це те, що ми надсилаємо в тілі запиту.

Якщо в запиту немає тіла — наприклад, у звичайного GET, — Content-Type часто взагалі не потрібен. Якщо ж ми надсилаємо JSON, зазвичай у POST або PUT, то Content-Type: application/json — це майже обов’язкова частина домовленості із сервером.

У вигляді таблиці це запам’ятовується швидше:

Заголовок Сенс по-людськи Типовий приклад
Accept
«Сервере, надішли мені ось це»
application/json
Content-Type
«Сервере, я надсилаю тобі ось це»
application/json

А тепер маленький приклад коду — просто щоб відчути API:

import Foundation

var request = URLRequest(url: URL(string: "https://example.com")!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

Так, тут !, але це приклад із константним URL у демо. У реальному коді курсу ми так робити не будемо.

3. Тіло запиту та JSON

Чому httpBody — це Data

HTTP‑тіло — це байти. Навіть якщо ви надсилаєте «текст», «JSON» або «картинку», на дроті все одно їдуть байти. У Swift байти — це Data.

Тому URLRequest.httpBody має тип Data?. Не String. Не [String: Any]. Саме Data.

Найбезпечніший і найбільш «свівтівський» шлях перетворити вашу модель на JSON-байти — Codable + JSONEncoder. Саме тут увесь попередній матеріал про Codable починає окуповуватися.

Зробімо невеликий payload для умовного входу в систему — не тому, що ми будуємо авторизацію, а тому, що це знайомий приклад.

import Foundation

struct LoginPayload: Codable {
    let username: String
    let password: String
}

Тепер збираємо POST‑запит, акуратно кодувавши JSON:

import Foundation

func makeLoginRequest(url: URL, payload: LoginPayload) throws -> URLRequest {
    var request = URLRequest(url: url)
    request.httpMethod = HTTPMethod.post.rawValue
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    request.httpBody = try JSONEncoder().encode(payload)
    return request
}

Тут важливий момент: encode може кинути помилку. Рідко, але може. І це нормально — ми й це враховуємо, бо сьогодні наша тема: ніяких «авось».

4. URL без склеювання рядків

Базовий URL + appendingPathComponent

Є одна звичка зі «швидкого прототипування», від якої варто позбутися ще до того, як вона стане частиною характеру: склеювати URL руками.

// ❌ погано: легко помилитися зі слешами
let urlString = base + "/v1" + "/books/" + id

По-перше, ви постійно ловитимете "//" або пропущений "/". По-друге, ви випадково «з’їдатимете» частини шляху. По-третє, це погано читається.

Якщо у вас є базовий URL — наприклад, https://api.example.com, — шлях зручніше додавати через appendingPathComponent. Це не URLComponents — вони будуть пізніше, — а проста й корисна операція саме для шляху.

Зберімо базовий URL безпечно:

import Foundation

func makeBaseURL(_ s: String) throws -> URL {
    guard let url = URL(string: s) else {
        throw RequestBuildError.invalidBaseURL(s)
    }
    return url
}

А тепер додамо шлях:

import Foundation

func makePingURL(baseURL: URL) -> URL {
    baseURL
        .appendingPathComponent("v1")
        .appendingPathComponent("ping")
}

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

5. RequestBuilder для LibraryCLI

Мініконструктор запитів

Зараз ми акуратно прив’яжемо тему до нашого застосунку курсу — CLI-утиліти LibraryCLI. Ми не запускаємо мережу сьогодні, але вже можемо підготувати шар, який відповідатиме за збирання запитів. Це сильно спростить наступну лекцію, де ми надсилатимемо запит і оброблятимемо відповідь.

Уявімо, що у нас є віддалений API з такими кінцевими точками:

  • GET /v1/ping — перевірка доступності
  • POST /v1/books — додати книгу (умовно)

Ми зробимо тип, який зберігає базовий URL і вміє збирати запити.

import Foundation

struct RequestBuilder {
    let baseURL: URL
    let timeout: TimeInterval

    func makePingRequest() -> URLRequest {
        let url = baseURL.appendingPathComponent("v1").appendingPathComponent("ping")
        var request = URLRequest(url: url)
        request.httpMethod = HTTPMethod.get.rawValue
        request.timeoutInterval = timeout
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        return request
    }
}

Поки це проста версія. Але вже видно головне: «як зібрати URL» і «як зібрати URLRequest» заховано в одному місці, а не розкидано по всьому проєкту.

POST з JSON: приклад «створити книгу»

Тепер зробімо невеликий payload для книги, яку ми хочемо надіслати на сервер. У реальному проєкті це буде DTO/Domain-мапінг, але це пізніше. Зараз просто покажемо, як будується коректний POST.

import Foundation

struct CreateBookPayload: Codable {
    let title: String
    let author: String
}

І розширімо RequestBuilder:

import Foundation

extension RequestBuilder {
    func makeCreateBookRequest(payload: CreateBookPayload) throws -> URLRequest {
        let url = baseURL.appendingPathComponent("v1").appendingPathComponent("books")

        var request = URLRequest(url: url)
        request.httpMethod = HTTPMethod.post.rawValue
        request.timeoutInterval = timeout

        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        request.httpBody = try JSONEncoder().encode(payload)
        return request
    }
}

Зверніть увагу на «симетрію»: якщо ми кладемо JSON у httpBody, ми зобов’язані поставити Content-Type. Це не правило Swift, а правило «ми хочемо, щоб сервер нас зрозумів».

Валідація до відправлення: перевірка здорового глузду

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

Наприклад, можна домовитися так: GET/DELETE ми надсилаємо без тіла. POST/PUT — можна з тілом. Це не суворе правило протоколу, а здорове правило проєкту.

Зробімо дуже невелику перевірку:

import Foundation

extension HTTPMethod {
    var allowsBody: Bool {
        switch self {
        case .get, .delete:
            return false
        case .post, .put:
            return true
        }
    }
}

І використаємо її в збиранні:

import Foundation

enum RequestValidationError: Error {
    case bodyNotAllowed(method: HTTPMethod)
}

func validateBody(method: HTTPMethod, body: Data?) throws {
    if body != nil && !method.allowsBody {
        throw RequestValidationError.bodyNotAllowed(method: method)
    }
}

Сенс такої валідації не в тому, що «інакше інтернет зламається», а в тому, що ви захищаєте проєкт від випадкових помилок, коли хтось через місяць додасть httpBody у GET «бо так простіше».

6. Схема: як мислити збирання запиту

Щоб у голові не змішувалися «побудувати», «відправити» і «розпарсити», тримайте дуже просту послідовність. Сьогодні ми зупиняємося на перших кроках.

flowchart TD
    A["Рядок/конфіг: baseURL"] --> B["URL(string:) → URL?"]
    B -->|guard let| C["baseURL: URL"]
    C --> D["Додаємо компоненти шляху"]
    D --> E["URLRequest(url:)"]
    E --> F["method + headers + timeout"]
    F --> G["(за потреби) encode payload → Data"]
    G --> H["request.httpBody = Data"]

На наступній лекції з’явиться продовження: URLSession.dataTask і обробка результату.

7. Типові помилки

Помилка № 1: URL(string: ...)! «бо я впевнений».
Проблема не у впевненості, а в тому, що впевненість не є типом у Swift. Рядок URL легко змінюється: конфіг підвантажили з файла, користувач увів пробіл, хтось додав «http:/» замість «http://». Звичка ставити ! перетворює таку ситуацію на креш процесу. Набагато здоровіше змусити помилку стати звичайним значенням керування потоком через guard let + throws, як радить практика розкривати optional одразу там, де він з’являється.

Помилка № 2: плутанина Accept і Content-Type.
Симптом виглядає так: сервер повертає помилку або «не розуміє» тіло, а ви впевнені, що надіслали JSON. Часто виявляється, що ви поставили лише Accept, але забули Content-Type. Лікується простим правилом: Accept — про відповідь, Content-Type — про ваше тіло запиту. Якщо поклали JSON у httpBody, поставте Content-Type: application/json.

Помилка № 3: склеювання URL рядками і танці зі слешами.
Сьогодні все працювало, завтра ви додали ще один сегмент шляху й отримали https://api//v1/books або https://apiv1/books. Такі баги неприємні, бо виглядають майже правильно. У навчальному проєкті краще одразу виробити звичку: базовий URL будуємо один раз через URL(string:), а шлях додаємо через appendingPathComponent.

Помилка № 4: тіло запиту як рядок «і так зійде».
Іноді новачки намагаються зробити request.httpBody = "hello".data(using: .utf8) і надіслати «JSON вручну». Це може працювати, але дуже легко помилитися з екрануванням, лапками та кодуванням. Якщо у вас структура даних, використовуйте Codable + JSONEncoder, тому що це типобезпечно і простіше підтримується.

Помилка № 5: збирання запиту в одному місці з бізнес-логікою.
Коли код «і розбирає команду CLI», «і збирає URL», «і додає заголовки», «і кодує JSON» в одній функції на 80 рядків, він починає ламатися від кожної зміни. Краще виділити маленький RequestBuilder, який відповідає лише за збирання URLRequest. Тоді в наступній лекції ви зможете підключити URLSession без рефакторингу всього проєкту.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ