JavaRush /Курсы /Swift SELF /URLSessionHTTPClient — адаптер вокруг URLSession

URLSessionHTTPClient — адаптер вокруг URLSession

Swift SELF
65 уровень , 4 лекция
Открыта

1. Зачем нужен адаптер и где граница ответственности

Если вы впервые видите идею «обёртки вокруг стандартной библиотеки», легко подумать: «Звучит как бюрократия. Я просто вызову URLSession.shared.data(for:) и пойду дальше». И да, технически вы можете так сделать — как можно и забивать гвозди микроскопом. Вопрос не в том, можно ли, а в том, какой ценой вы потом будете тестировать, менять и сопровождать этот код.

URLSessionHTTPClient нужен, чтобы ваш код выше по стеку (например, ApiClient) вообще не знал, кто именно «возит запросы по сети». Сегодня это URLSession, завтра — мок в тестах, послезавтра — другой транспорт. А ещё адаптер помогает привести низкоуровневые ошибки URLSession к вашей доменной/курсовой модели NetworkError в одном месте, вместо того чтобы размазывать do/catch по всему проекту.

Что именно даёт URLSession.data(for:)

Прежде чем писать адаптер, полезно остановиться и честно ответить: «А что именно я получаю от URLSession?». Потому что многие новички думают, что сеть возвращает «JSON» или «модель». На самом деле сеть возвращает байты и не всегда даже понимает, был ли это HTTP.

Современный URLSession даёт нам async-метод data(for:), который возвращает пару (Data, URLResponse) и может бросить ошибку. Заметьте: URLResponse, а не HTTPURLResponse. То есть компилятор честно говорит: «Я не гарантирую, что это HTTP; я гарантирую, что это какой-то ответ».

И вот тут появляется первая важная обязанность транспорта в нашем курсе: если мы хотим работать со статус-кодами, нам нужно получить именно HTTPURLResponse. Это та точка, где адаптер обязан либо привести тип, либо честно сказать: «Извини, это не HTTP-ответ».

Исторически в Swift было много обсуждений, как аккуратно переводить callback-API в async/await. В документах по structured concurrency даже приводят пример, как вручную оборачивать URLSession.dataTask в async через continuation и связывать это с отменой задачи. Сейчас нам проще: у URLSession уже есть async-форма, но сама идея «оборачивания транспорта» отлично иллюстрируется такими примерами.

Контракт транспорта HTTPClient

Прежде чем писать реализацию, полезно ещё раз закрепить рамки ответственности. Это прямо спасает от желания «ну раз уж я тут, давайте сразу и JSON декодить». Именно в этот момент рождаются монстры.

Транспорт (HTTPClient) в нашем курсе:

  1. принимает готовый URLRequest,
  2. возвращает сырые байты (Data) и HTTP-ответ (HTTPURLResponse),
  3. либо бросает ошибку доставки/валидации ответа на уровне транспорта.

Транспорт не должен проверять 200...299 и говорить «успех/ошибка» по бизнес-логике. Он также не должен декодировать JSON. Потому что как только транспорт начинает декодировать — вы теряете универсальность: транспорт становится «транспортом для конкретной модели» и перестаёт быть транспортом.

Для наглядности зафиксируем границу в небольшой таблице:

Слой Вход Выход Что делает Чего не делает
URLSessionHTTPClient (transport)
URLRequest
(Data, HTTPURLResponse)
отправляет запрос, приводит URLResponse к HTTPURLResponse не проверяет 2xx, не декодирует JSON
ApiClient (interpretation)
URLRequest
T: Decodable
проверяет statusCode, декодирует JSON не «ходит в сеть» напрямую

2. Реализация URLSessionHTTPClient

Каркас: храним URLSession как зависимость

Теперь мы готовы писать сам адаптер. Здесь хороший момент вспомнить, что URLSession — это объект (reference type) и у него есть конфигурация (таймауты, кэш, заголовки и т.д.). Поэтому создавать новую сессию «на каждый запрос» — странно: вы потеряете конфигурацию, и это будет дороже по ресурсам.

Мы сделаем URLSessionHTTPClient, который хранит URLSession в свойстве и получает её через init. Для удобства можно дать дефолт URLSession.shared, но в учебном проекте я обычно советую использовать «явно созданную» сессию в composition root (там, где вы собираете зависимости приложения). Так вы меньше зависите от глобального состояния.

Мини-каркас (короткий и читаемый):


import Foundation

struct URLSessionHTTPClient {
    let session: URLSession

    init(session: URLSession) {
        self.session = session
    }
}

Да, пока это просто «коробка с одним полем». Но это очень полезная коробка: теперь ваш транспорт можно подменять, настраивать, тестировать и не трогать код выше.

Приводим URLResponse к HTTPURLResponse

Самая «техническая» часть адаптера — привести URLResponse к HTTPURLResponse. Делать это через as! — прямой путь к падению программы в рантайме. А падение программы из-за не-HTTP ответа — это не «контракт разработчика», это вполне нормальная ситуация, которую нужно превращать в контролируемую ошибку.

Начнём с маленького helper’а. Он короткий, поэтому его удобно читать даже новичкам:


import Foundation

func toHTTP(_ response: URLResponse) -> HTTPURLResponse? {
    response as? HTTPURLResponse
}

Идея простая: «Если смог — верни HTTPURLResponse, если нет — верни nil». Дальше nil мы превратим в NetworkError.invalidResponse (или как у вас назван этот кейс в канонической модели).

Реализация send(_:): тонкий транспорт и аккуратные ошибки

Вот теперь пишем «сердце» адаптера: метод send(_:). В прошлой лекции (про протокол HTTPClient) мы договорились о сигнатуре примерно такой формы:

func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)

Внутри метода нам нужны три шага: вызвать session.data(for:), привести URLResponse к HTTPURLResponse, вернуть результат. И вокруг этого — do/catch, где мы переводим «низкоуровневые» ошибки в NetworkError.transport(...), а «не HTTP-ответ» — в NetworkError.invalidResponse.

Ниже пример, который предполагает, что HTTPClient и NetworkError уже существуют в вашем модуле Networking (мы их не переизобретаем):

import Foundation

extension URLSessionHTTPClient: HTTPClient {
    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                throw NetworkError.invalidResponse
            }
            return (data, http)
        } catch {
            throw NetworkError.transport(error)
        }
    }
}

Обратите внимание на важный «воспитательный» момент: здесь нет проверки statusCode, нет декодирования JSON, нет попыток «всё починить». Транспорт либо доставил, либо нет. Всё остальное — выше.

Иногда вы увидите чуть более «аккуратный» вариант, где мы не оборачиваем ошибку повторно, если это уже NetworkError (чтобы не получить матрёшку из матрёшек). Это полезно, когда выше по стеку кто-то уже бросил NetworkError, а транспорт просто пробрасывает его:

import Foundation

extension URLSessionHTTPClient: HTTPClient {
    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                throw NetworkError.invalidResponse
            }
            return (data, http)
        } catch let e as NetworkError {
            throw e
        } catch {
            throw NetworkError.transport(error)
        }
    }
}

Этот паттерн «перехватить свою ошибку и пробросить как есть» часто выглядит чуть многословно, но он экономит нервы при отладке: вы сохраняете исходную категорию ошибки.

3. Встраивание в проект и быстрый тест

Где держать код в проекте LibraryCLI

Сейчас хороший момент остановиться и сделать вид, что мы взрослые люди, которые не складывают всё в main.swift. Иначе потом «проект на 15 файлов» внезапно превращается в «один файл на 3000 строк», и даже кот перестаёт сидеть на клавиатуре из уважения.

В архитектуре курса у нас есть модуль/target Networking. Туда логично положить:

  • HTTPClient.swift (протокол),
  • URLSessionHTTPClient.swift (сегодняшняя лекция),
  • NetworkError.swift (канонический тип ошибок сети из прошлых дней),
  • позже — ApiClient.swift (который делает интерпретацию и декодирование).

Условная структура может выглядеть так:

Sources/
  LibraryCLI/
    main.swift
  Networking/
    HTTPClient.swift
    URLSessionHTTPClient.swift
    NetworkError.swift
    ApiClient.swift

Почему это важно? Потому что это фиксирует направление зависимостей. URLSessionHTTPClient зависит от Foundation, но не зависит от «команд CLI», не знает про парсер команд и не знает про хранилище. Это делает транспорт переносимым и тестируемым.

Мини smoke test: проверяем статус-код

Иногда новичкам хочется «сразу увидеть, что оно живое». Это нормально: мозг любит быстрые подтверждения. Давайте сделаем микро-пример, который просто отправляет запрос и печатает статус-код. Это не будет частью финального дизайна (там будет ApiClient), но как проверка транспорта — отлично.

import Foundation

func pingExampleCom(http: HTTPClient) async {
    let url = URL(string: "https://example.com")!
    let request = URLRequest(url: url)

    do {
        let (_, response) = try await http.send(request)
        print("HTTP status:", response.statusCode) // например: HTTP status: 200
    } catch {
        print("Request failed:", error)
    }
}

Если вы запускаете это в окружении, где сеть доступна, вы увидите статус. Если сеть недоступна — получите ошибку. И это хороший момент: транспорт не «прячет» реальность, он её честно сообщает.

4. Типичные ошибки при реализации URLSessionHTTPClient

Ошибка №1: использовать as! HTTPURLResponse вместо as? и guard.
Такой код выглядит «короче», но он превращает вполне нормальную ситуацию (ответ не того типа) в краш приложения. Даже если вам кажется, что «в реальности всегда HTTP», вы всё равно выигрываете от guard: у вас появляется контролируемая ошибка (invalidResponse), а не падение «непонятно где».

Ошибка №2: проверять statusCode прямо внутри транспорта.
Это очень популярная логическая ловушка: «Раз уж у меня есть HTTPURLResponse, почему бы не проверить 200...299 прямо тут?». Потому что тогда транспорт перестаёт быть транспортом и начинает принимать решения, которые относятся к интерпретации. Вы потеряете возможность, например, специально обработать 404 иначе, чем 500, или прочитать тело ошибки на верхнем уровне — и всё это без смешивания ответственности.

Ошибка №3: декодировать JSON внутри URLSessionHTTPClient.
Поначалу кажется, что это удобно: «я же всё равно хочу получить модель». Но вы мгновенно привязываете транспорт к Decodable и к конкретной схеме данных. А затем вам понадобится получить «сырое тело» для логов, или скачать файл, или обработать нестандартный ответ — и вы поймёте, что ваш «транспорт» внезапно стал «мини-апиклиентом».

Ошибка №4: создавать URLSession внутри send(_:), а не хранить как зависимость.
Так вы теряете контроль над конфигурацией и получаете непредсказуемость: в одном месте таймауты одни, в другом — другие, а в третьем — вообще shared. Плюс это усложняет тестирование и отладку, потому что у вас нет одной «точки правды» про настройки сети.

Ошибка №5: терять исходную ошибку URLSession.
Иногда в попытке «нормализовать» ошибки разработчик бросает что-то вроде NetworkError.transport(NSError(domain: "Network", code: 0)) и выбрасывает оригинальный error. Потом приходит день, когда всё падает только у одного пользователя, и вы очень захотите увидеть оригинальный URLError с кодом и описанием. Поэтому транспортная ошибка должна сохранять исходный Error внутри (как значение), а не заменять его абстрактной строкой.

1
Задача
Swift SELF, 65 уровень, 4 лекция
Недоступна
Клиент сессии
Клиент сессии
1
Задача
Swift SELF, 65 уровень, 4 лекция
Недоступна
Безопасный каст
Безопасный каст
1
Задача
Swift SELF, 65 уровень, 4 лекция
Недоступна
Отправка запроса
Отправка запроса
1
Задача
Swift SELF, 65 уровень, 4 лекция
Недоступна
Пинг через протокол
Пинг через протокол
1
Опрос
Async Throws, 65 уровень, 4 лекция
Недоступен
Async Throws
Асинхронные ошибки и cancellation
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ