1. endpoint поверх URLComponents
Если честно, после знакомства с URLComponents у многих возникает ощущение: «Ну всё, проблема решена, давайте просто в каждом месте кода собирать URL и делать запрос». Это чувство понятно — и опасно. Опасно не потому, что URLComponents плохой, а потому что «собирать URL в каждом месте» очень быстро превращается в копипасту, расхождения в заголовках и тихие баги: в одном месте path начинается с /, в другом — нет, в одном месте вы добавили limit, в другом забыли.
Endpoint — это следующая ступень дисциплины. Мы договариваемся, что запросы к API у нас описываются как данные, в одном перечислении (enum). А уже потом отдельный builder превращает эти данные в URLRequest.
И вот тут появляется главный психологический бонус: вместо «давайте в этом месте тоже соберём URL» вы начинаете думать «а какой endpoint мы вызываем?». Это сильно проще для мозга. И для ревью кода тоже.
Endpoint как описание: метод + путь + query
Давайте аккуратно определим, что именно мы называем endpoint’ом в рамках курса.
Endpoint — это не сетевой вызов, и даже не URLRequest. Endpoint — это описание маршрута: каким HTTP-методом идём, какой path используем, какие query-параметры прикладываем. Его задача — быть понятным, типизированным и исчерпывающим.
Тут очень помогает enum с associated values. У enum есть два супер-свойства: он задаёт конечный список вариантов, а associated values позволяют каждому варианту носить свои параметры. Если вам нужна хорошая интуиция, можно думать, что кейс enum с associated values похож на «конструктор» или «статическую функцию», которая возвращает значение enum. Это довольно близкая модель: кейс без параметров — как статическое свойство, кейс с параметрами — как статическая функция.
И ещё важный нюанс читабельности: метки (labels) у associated values — часть имени «конструктора» кейса, поэтому они реально влияют на то, как читается код при создании значения. Это нам пригодится, чтобы endpoint’ы выглядели как предложение на английском: .searchBooks(query:page:limit:), а не .searchBooks("swift", 1, 20) (второе тоже работает, но читается хуже).
2. Базовые типы: HTTPMethod и EndpointBuildError
Перед тем как писать endpoint’ы, мы сделаем пару маленьких «строительных кирпичей». Тут легко переборщить с архитектурой, поэтому держим всё приземлённо: один enum для метода, один enum для ошибки сборки.
Начнём с метода. В файле Sources/Networking/HTTPMethod.swift (путь условный — подстройте под ваш проект) можно держать так:
import Foundation
enum HTTPMethod: String {
case get = "GET"
}
Да, пока только "GET". Не потому что других не существует, а потому что наш учебный сценарий сейчас про чтение данных. Когда появится запись — добавим новые методы.
Теперь ошибка сборки запроса. Это не NetworkError, потому что сеть мы ещё не трогали — мы даже URL иногда собрать не можем. Это другая природа проблемы: «мы сами неправильно сконфигурировали endpoint / base URL».
import Foundation
enum EndpointBuildError: Error {
case invalidURL
}
Соблазн сделать fatalError("URL is nil") будет возникать регулярно. У компилятора, конечно, нет кнопки «отобрать клавиатуру», но наша дисциплина такая: если можно обработать ошибку типом Error, мы так и делаем.
4. База API отдельно: scheme и host не должны расползаться
Когда студенты впервые делают endpoint’ы, они часто начинают зашивать host внутрь каждого кейса: мол, этот endpoint ходит на api.example.com, другой — на other.example.com. Иногда так и нужно, но в большинстве учебных проектов это создаёт хаос: хост меняется — и вы «чините» 20 мест.
Поэтому вводим маленький тип APIBase, который хранит базовые части адреса. Это похоже на «настройку окружения»: dev/prod, или просто единый источник истины.
import Foundation
struct APIBase {
let scheme: String
let host: String
}
Кстати, именно из-за таких «URL из кусочков» нам и нужен URLComponents. В Foundation эти типы — часть нормального инструментария, и URLComponents/URLQueryItem там существуют как отдельные сущности, а не как «самодельный парсер строки».
5. BooksEndpoint: один список запросов к Books API
Теперь самое вкусное: делаем endpoint’ы. В учебном приложении LibraryCLI мы часто работаем с книгами, поэтому пусть сетевой API тоже «про книги»: поиск и детали.
Создадим BooksEndpoint:
import Foundation
enum BooksEndpoint {
case search(query: String, page: Int, limit: Int)
case details(id: Int)
}
Смотрите, как приятно читается создание значений:
let ep1 = BooksEndpoint.search(query: "swift", page: 1, limit: 20)
let ep2 = BooksEndpoint.details(id: 42)
Это и есть та самая идея «endpoint как данные»: мы пока ничего не выполняем, просто описываем намерение.
Метод запроса как вычисляемое свойство
Почти всегда метод зависит от кейса. Сейчас у нас оба "GET", но всё равно оформим правильно — чтобы позже не переписывать всё.
import Foundation
extension BooksEndpoint {
var method: HTTPMethod {
.get
}
}
Да, switch не нужен — пока что.
path: строка, но по правилам
path остаётся строкой, потому что у URL путь — это реально строковый компонент. Но мы вводим правило: path всегда начинается с /. Одно правило — минус пять багов.
import Foundation
extension BooksEndpoint {
var path: String {
switch self {
case .search:
return "/v1/books"
case .details(let id):
return "/v1/books/\(id)"
}
}
}
Обратите внимание: мы не вставляем сюда query (?page=...). Никогда. Query — отдельным механизмом.
queryItems: параметры и контроль «пустого ?»
Query параметры — это как специи: если сыпать их везде без меры, блюдо превращается в… ну, в строку URL, склеенную руками.
Мы вернём nil для кейсов без query (например, детали по id), и массив URLQueryItem для поиска.
import Foundation
extension BooksEndpoint {
var queryItems: [URLQueryItem]? {
switch self {
case let .search(query, page, limit):
return [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit))
]
case .details:
return nil
}
}
}
На этом этапе мы сделали важную вещь: все правила маршрутизации в одном месте. Если API решит, что параметр поиска должен называться "query", а не "q", вы меняете это в одном месте, и всё.
6. Builder: превращаем endpoint в URLRequest
Сейчас у нас есть endpoint, но выполнять его мы не умеем — и не должны в этом же типе. Наша цель: сделать builder, который знает, как собрать URLRequest. Тогда любое место в коде сможет взять endpoint и получить «готовый к отправке» запрос.
Сделаем метод makeRequest(base:). Можно оформить как extension на endpoint, можно отдельным типом RequestBuilder. Для начала проще extension: меньше сущностей — меньше путаницы.
import Foundation
extension BooksEndpoint {
func makeRequest(base: APIBase) throws -> URLRequest {
var c = URLComponents()
c.scheme = base.scheme
c.host = base.host
c.path = path
c.queryItems = queryItems
guard let url = c.url else { throw EndpointBuildError.invalidURL }
return URLRequest(url: url)
}
}
Это уже полезно, но у запроса ещё нет метода и заголовков. Дополним: метод ставим из method, заголовки пока добавим минимально-одинаковые (например, "Accept").
import Foundation
extension BooksEndpoint {
func makeRequest(base: APIBase) throws -> URLRequest {
var c = URLComponents()
c.scheme = base.scheme
c.host = base.host
c.path = path
c.queryItems = queryItems
guard let url = c.url else { throw EndpointBuildError.invalidURL }
var r = URLRequest(url: url)
r.httpMethod = method.rawValue
r.setValue("application/json", forHTTPHeaderField: "Accept")
return r
}
}
Почему это важно держать централизованно? Потому что заголовки — это классическая зона «а тут забыл» или «а тут случайно другой». Builder — ваш единый источник истины: в нём все запросы получаются одинаково «аккуратными».
7. Пример использования: CLI видит только endpoint и URLRequest
Теперь давайте представим, что в LibraryCLI у нас есть команда «поиск книг» (или похожая). Важно: CLI-слой не обязан знать, как устроен URL. CLI-слой должен сформировать endpoint и попросить request.
Сборка может выглядеть примерно так:
import Foundation
let base = APIBase(scheme: "https", host: "api.example.com")
let ep = BooksEndpoint.search(query: "swift basics", page: 1, limit: 20)
let request = try ep.makeRequest(base: base)
print(request.url?.absoluteString ?? "nil")
Вывод (примерно):
// https://api.example.com/v1/books?q=swift%20basics&page=1&limit=20
И вот тут вы ловите удовольствие: пробелы закодированы сами, &/? нигде руками не писали, а формат запроса единый.
8. Единая точка сборки: зачем и как это спасает проект
Когда проект маленький, кажется, что можно «и так». Но наш курс как раз про то, чтобы писать код, который не развалится, когда вы добавите 10 команд и 15 запросов.
Единый builder защищает вас сразу от нескольких бед.
Во-первых, он убирает размазывание базовых правил по проекту. Если в одном месте вы случайно делаете c.path = "v1/books" без слэша, вы получите не тот URL или nil — и будете долго грустить. Когда сборка в одном месте, такие правила проще закрепить и проверить.
Во-вторых, builder делает поведение заголовков предсказуемым. В реальных API бывает, что без "Accept": "application/json" сервер отдаёт HTML-страницу (и вы потом «декодируете» её в DTO и удивляетесь). Централизованный builder снижает вероятность таких сюрпризов.
В-третьих, builder помогает отделять слои: endpoint — это описание маршрута, builder — это сборка запроса, а сеть — это выполнение. Мы сейчас сознательно не смешиваем эти роли, потому что дальше по курсу вам будет легче тестировать и поддерживать код.
Схема потока: как это течёт по коду
Чтобы зафиксировать в голове «кто за что отвечает», удобно один раз увидеть это как поток.
flowchart LR
CLI["CLI-команда<br/>(парсинг аргументов)"] --> EP["BooksEndpoint<br/>(enum + параметры)"]
EP --> RB["makeRequest(base:)<br/>(builder)"]
RB --> REQ["URLRequest"]
REQ --> NET["Сетевой слой<br/>(выполнение)"]
В этой лекции мы «закрываем» первые три узла: CLI знает, какой endpoint нужен, endpoint хранит параметры, builder делает URLRequest. Сеть пока оставляем за кадром.
9. Типичные ошибки
Ошибка №1: добавлять query прямо в path.
Очень хочется написать return "/v1/books?page=\(page)", потому что «быстрее». Потом появляется второй параметр, третий, пробелы, амперсанды — и вы внезапно пишете мини-браузер руками. Лечится просто: path — только путь, параметры — только queryItems.
Ошибка №2: не учитывать, что components.url — это URL?.
Иногда кажется, что если scheme/host/path заполнены, URL обязан собраться. На практике можно ошибиться в path, передать пустой host, или случайно оставить scheme пустым. Поэтому guard let url = c.url else { throw ... } — это не «мелочь», а часть контракта сборки.
Ошибка №3: смешивать ошибку сборки запроса с сетевой ошибкой.
Если URL не собрался, сеть ещё даже не началась. Это другой класс проблем. Когда вы всё называете «NetworkError», потом в логах и обработке ошибок возникает каша: вы лечите «нет интернета» там, где надо было исправить host.
Ошибка №4: держать host внутри endpoint’ов.
Иногда кажется логичным: «этот кейс ходит туда, тот — сюда». Но если у вас один API (а в учебном проекте почти всегда так), лучше держать APIBase отдельно. Тогда смена окружения (например, тестовый хост) не превращается в квест «найди 20 строк».
Ошибка №5: делать endpoint «умным» и пытаться выполнить сеть внутри enum.
enum endpoint’ов должен быть максимально простым: метод/путь/параметры. Как только вы начинаете делать внутри него URLSession, вы теряете разделение ролей, и код становится сложно тестировать и расширять. Пусть endpoint будет «описанием», а не «действием».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ