Пишем сетевой слой в Swift как надо: гибко, понятно и без лишних зависимостей [Часть 1]
Работая над любым iOS-приложением, рано или поздно сталкиваешься с тем, что нужно «поговорить» с сервером. И здесь легко скатиться в ад: ручная сборка URLRequest, коллбэки, дублирование логики, десятки do-catch, и мало гарантий, что ты не забудешь заголовок или не промажешь с endpoint’ом.
С выходом async/await и новой эпохой Swift появилась возможность писать сетевой код, который:
типобезопасен;
масштабируем без боли;
покрывается тестами;
не превращается в помойку.
В этих статьях мы построим чистый, модульный, современный сетевой слой с нуля:
Сконфигурируем окружения через
.xcconfigиInfo.plist;Опишем универсальные модели для запросов и ответов;
Разделим всё по ответственности:
Requestable,Resource,APIClient;Реализуем полноценную систему ошибок, с
APIError,NetworkErrorи статус-кодами;Подключим ViewModel на async/await и свяжем её с UI.
Всё, что вы увидите — реальный продакшн-код, а не игрушечные демки. Сможете сразу брать и использовать его в своих проектах, адаптируя под любую архитектуру — от MVVM до VIPER и TCA.
В первой части остановимся на самой базовой реализации, а самое мясо начнется со второй)
Сегодня накидаем все что нужно для обычного GET запроса. Подготовим модель, сущность запроса, и собственно сам клиент который его выполнит, конечно же с примером работы.
Запросы остального рода, как и обработку ошибок и разные окружения оставим на следющий раз.
Ну что, готовы? Поехали!
Структура проекта
Demo Project
├── App
│ ├── Assets
│ ├── Info.plist // Значения из .xcconfig, например baseURL
│ └── NetworkingApp.swift // Точка входа приложения, инициализация ViewModel
│
├── Configuration
│ ├── AppConfiguration.swift // Чтение baseURL и других параметров из Info.plist в рантайме
│ ├── Production.xcconfig // Конфигурация продакшен-окружения
│ └── Sandbox.xcconfig // Конфигурация песочницы / тестового окружения
│
├── Data
│ ├── BodyModels.swift // Модели, которые мы отправляем на сервер (например, Post)
│ ├── Requests.swift // Описание API-запросов (структуры, реализующие Requestable)
│ └── Responses.swift // Модели данных, приходящие от сервера (например, User, Comment)
│
├── Errors
│ ├── APIError.swift // Ошибки, которые возвращает API в теле ответа
│ └── NetworkError.swift // Ошибки клиента: неверный URL, ошибки кодирования/декодирования и пр.
│
├── Extensions
│ ├── JSONDecoderExt.swift // Расширение JSONDecoder для snake_case → camelCase
│ ├── JSONEncoderExt.swift // Расширение JSONEncoder для camelCase → snake_case
│ └── URLRequestExt.swift // Создание URLRequest на основе Requestable
│
├── Networking
│ ├── APIClient.swift // Универсальный клиент, отправляющий запросы через URLSession
│ ├── APIEnvironment.swift // Описание текущего окружения: baseURL и общие заголовки
│ ├── Requestable.swift // Протокол для декларативного описания запросов
│ └── Resource.swift // Объединяет запрос и стратегию декодирования ответа
│
├── Screens
│ ├── ContentView.swift // SwiftUI-интерфейс с кнопками для вызова методов ViewModel
│ └── ViewModel.swift // ViewModel с async/await, связывает UI и сетевой слой
│
└── Type safety
├── HTTPMethod.swift // Перечисление HTTP-методов (GET, POST, PUT, DELETE)
├── HTTPHeaderKey.swift // Типизированные ключи заголовков (например, .contentType)
└── HTTPStatusCode.swift // Обёртка над HTTP статус-кодами с удобными свойствами
Модели данных и реквесты
В этом разделе мы определяем:
модели данных, которые приходят от сервера (
User);реквесты, реализующие
Requestable;и тип
HTTPMethod, задающий HTTP-метод запроса.
Модели ответов от сервера (Responses.swift)
Модель пользователя (User)
struct User: Decodable {
let id: Int
let name: String
let email: String
}
Это простая модель пользователя. Она приходит с сервера и автоматически парсится через JSONDecoder.
Пример JSON:
{
"id": 1,
"name": "Leanne Graham",
"email": "leanne@example.com"
}
Реквесты (Requests.swift)
Каждая структура реализует Requestable и описывает конкретный запрос к API. Это делает код декларативным и предсказуемым.
Получение списка пользователей
struct FetchUserListRequest: Requestable {
let path = "users/"
}
GETпо умолчанию (задано вRequestable)pathуказывает, куда идёт запросНикаких параметров и тела — значит, обычный
GETбез query/body
Абстракция Requestable
Чтобы не писать руками URLRequest, параметры, заголовки, тело — мы описываем HTTP-запрос декларативно через Requestable.
Requestable: описание запроса
protocol Requestable {
associatedtype Body: Encodable = Never
var method: HTTPMethod { get }
var path: String { get }
var parameters: [URLQueryItem] { get }
var headers: [HTTPHeaderKey: String] { get }
var body: Body? { get }
func fullURL(baseURL: URL) -> URL? {
guard let url = URL(string: path, relativeTo: baseURL),
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)
else { return nil }
if !parameters.isEmpty {
urlComponents.queryItems = parameters
}
return urlComponents.url
}
}
Каждый запрос (например, FetchUserListRequest) реализует Requestable. Протокол описывает всё, что нужно для формирования HTTP-запроса:
Свойство | Назначение |
|---|---|
method | HTTP метод ( |
path | Конечная часть URL (например, |
parameters | Query-параметры ( |
headers | Заголовки ( |
body | Тело запроса (если есть) |
Body: Encodable = Never
Это значит:
Если запрос не имеет тела (
GET) — не нужно его описыватьЕсли тело есть (
POST,PUT) — тип указывается явно (например,Post)
Значения по умолчанию
extension Requestable {
var method: HTTPMethod { .GET }
var parameters: [URLQueryItem] { [] }
var headers: [HTTPHeaderKey: String] { [:] }
var body: Never? { nil }
}
Если описываем обычный GET без параметров и тела — нам даже не надо реализовывать эти поля. Достаточно указать path:
struct FetchUserListRequest: Requestable {
let path = "users/"
}
Метод fullURL(baseURL:)
func fullURL(baseURL: URL) -> URL? {
guard let url = URL(string: path, relativeTo: baseURL),
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)
else { return nil }
if !parameters.isEmpty {
urlComponents.queryItems = parameters
}
return urlComponents.url
}
Эта функция собирает итоговый URL:
если есть
parameters, они добавляются какquery string;pathскладывается сbaseURL;возвращается итоговая ссылка, полностью готовая к отправке.
HTTPMethod (HTTPMethod.swift)
enum HTTPMethod: String {
case GET
case POST
case PUT
case DELETE
}
Простой, но нужный enum. Позволяет явно указывать HTTP-метод, а не использовать строки типа GET или POST.
С таким подходом будет меньше ошибок, удобный автокомплит и централизованное расширение (например, можно добавить .PATCH при необходимости).
Типобезопасные заголовки — HTTPHeaderKey
Работать с HTTP-заголовками через строки — это ошибки на ровном месте. Мы уходим от строк и используем специальный тип:
struct HTTPHeaderKey: ExpressibleByStringLiteral, Hashable {
let rawValue: String
init(stringLiteral value: String) { rawValue = value }
}
И расширяем его типовыми ключами:
extension HTTPHeaderKey {
static let contentType = HTTPHeaderKey("Content-Type")
static let accept = HTTPHeaderKey("Accept")
static let authorization = HTTPHeaderKey("Authorization")
static let userAgent = HTTPHeaderKey("User-Agent")
}
Теперь в запросах мы пишем вот так:
headers = [.contentType: "application/json"]
Это удобно, читаемо, и IDE подскажет, если что-то забудем.
Итого по разделу:
Все реквесты максимально декларативные — каждый описывает что он делает, а не как он это делает.
Использование
Requestableделает код легко расширяемым и типобезопасным.Чёткое разделение моделей на
EncodableиDecodableпоказывает, кто «отправляет», а кто «принимает».HTTPMethodиHTTPHeaderKeyизбавляет от магических строк и упрощает поддержку.
APIClient и его dataTask
Теперь, когда мы описали что хотим отправить (Requestable) и что ожидаем получить (Resource), пришло время реализовать тот самый исполнитель, который отправит запрос в сеть, получит ответ, проверит его, распарсит — и отдаст обратно. Всё это делает APIClient:
final class APIClient {
private let baseURL: URL
private let urlSession = URLSession
init(
basePath: String = "https://jsonplaceholder.typicode.com/",
urlSession: URLSession = .shared
) {
self.baseURL = URL(string: basePath)!
self.urlSession = urlSession
}
func dataTask<Response, Request>(
with resource: Resource<Response, Request>
) async throws -> Response {
let urlRequest = try URLRequest(
request: resource.request,
baseURL: URL(string: basePath)!
)
let (data, response) = try await urlSession.data(for: urlRequest)
return try resource.decode(data)
}
}
Как работает метод
Шаг 1: Сборка URLRequest
let urlRequest = try URLRequest(
request: resource.request,
baseURL: URL(string: basePath)!
)
Тут используется кастомный init у URLRequest, который принимает наш Requestable и сам собирает итоговый запрос.
Шаг 2: Вызов URLSession
let (data, response) = try await urlSession.data(for: urlRequest)
Асинхронный вызов. Получаем data и response.
Расширение URLRequest
extension URLRequest {
init(
request: some Requestable,
baseURL: URL
) throws {
guard let fullURL = request.fullURL(baseURL: baseURL) else {
throw NSError(
domain: "com.myapp.Networking",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]
)
}
self.init(url: fullURL)
self.httpMethod = request.method.rawValue
for (key, value) in request.headers {
self.addValue(value, forHTTPHeaderField: key.rawValue)
}
}
}
Что происходит под капотом
1. Формируется URL через request.fullURL(...)
guard let fullURL = request.fullURL(baseURL: baseURL) else {
throw NSError(
domain: "com.myapp.Networking",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]
)
}
self.init(url: fullURL)
Формируем итоговый URL (с путём и query) и инициализируем URLRequest.
2. Устанавливается HTTP-метод
self.httpMethod = request.method.rawValue
3. Добавляются заголовки (APIEnvironment + Requestable)
for (key, value) in request.headers {
self.addValue(value, forHTTPHeaderField: key.rawValue)
}
request.headers— специфичные для запроса заголовки
Resource: как обрабатывать ответ
Затем оборачиваем в Resource, который умеет декодировать ответ.
struct Resource<Response, Request: Requestable> {
let request: Request
let decode: (Data) throws -> Response
}
Здесь два параметра:
Request: нашRequestable, описывающий что и как запрашивать.Response: тип, который мы ожидаем получить в ответ.
Декодируемый ответ
extension Resource where Response: Decodable {
init(request: Request) {
self.init(request: request) { data in
return try JSONDecoder.snakeCaseConverting.decode(Response.self, from: data)
}
}
}
Это позволяет не писать руками декодирование — Resource сам подставит логику, если ждём Decodable.
Пример:
let request = FetchUserListRequest()
let resource = Resource<[User], FetchUserListRequest>(request: request)
Пустой ответ (Void)
extension Resource where Response == Void {
init(request: Request) {
self.init(request: request) { _ in return () }
}
}
Если сервер ничего не возвращает в теле (например, 201 Created, 204 No Content) — просто используем Resource<Void, ...>, и декодер ничего не делает.
Расширение JSONDecoder
extension JSONDecoder {
static var snakeCaseConverting: JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
}
Это избавляет от необходимости писать CodingKeys в моделях. JSON вроде:
{
"user_id": 123,
"user_name": "John"
}
автоматически заматчится на:
struct User: Decodable {
let userId: Int
let userName: String
}
Расширение JSONEncoder
extension JSONEncoder {
static var snakeCaseConverting: JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}
}
Преобразует
camelCase→snake_caseИзбавляет от необходимости писать
CodingKeys
Итого по разделу
Один
APIClientобслуживает все виды запросов (GET,POST,Void,Decodable, кастомныйdecode)Мы разделяем описание запроса (
Requestable), и обработку ответа (Resource) от транспорта (URLSession).Нет дублирования логики, всё декларативно.
Код легко расширяется — достаточно создать новую реализацию
Requestable.Поддержка
Void,Decodable, кастомного декодинга — уже встроена.Это в 10 раз лучше, чем писать
URLRequestруками каждый раз.
ViewModel: связываем API с UI
@Observable
final class ViewModel {
private(set) var userList: [User] = []
private let apiClient: APIClient
init(apiClient: APIClient = .init()) {
self.apiClient = apiClient
}
}
Методы ViewModel
Получение списка пользователей
func fetchUserList() async {
do {
let request = FetchUserListRequest()
let resource = Resource<[User], FetchUserListRequest>(request: request)
let response = try await apiClient.dataTask(with: resource)
userList = response
} catch {
dump(error)
}
}
Что происходит:
Создаём реквест —
FetchUserListRequest(), который реализуетRequestable.Оборачиваем его в
Resource<[User]>, где[User]— тип ожидаемого ответа.Вызываем
apiClient.dataTask— он сам всё сделает: соберётURLRequest, отправит, распарсит.Результат кладём в
userList— UI обновится автоматически.
Финальные выводы
Ну вот как бы и все)
Что мы в итоге получили на данный момент:
Декларативные запросы через
Requestable, без ручного конструирования URL и заголовков;Гибкий
APIClient, который обрабатывает все запросы;Типобезопасные модели, без
Any,Dictionary, и прочего ада;
В следющий части доработаем нетворкинг до полноценной системы и учтем все моменты нужные для нормального сетевого слоя.
Ну, а пока — пока)
Полезные ссылки
Репозиторий проекта: https://github.com/slip-def/SwiftyNetworking
Видео референс: https://www.youtube.com/watch?v=xV2DVQ8sw7o