Работая над любым 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 метод (GET, POST, ...)

path

Конечная часть URL (например, users/1)

parameters

Query-параметры (?postId=1)

headers

Заголовки (Content-Type, Authorization)

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
    }
}
  • Преобразует camelCasesnake_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)
    }
}

Что происходит:

  1. Создаём реквест — FetchUserListRequest(), который реализует Requestable.

  2. Оборачиваем его в Resource<[User]>, где [User] — тип ожидаемого ответа.

  3. Вызываем apiClient.dataTask — он сам всё сделает: соберёт URLRequest, отправит, распарсит.

  4. Результат кладём в userList — UI обновится автоматически.


Финальные выводы

Ну вот как бы и все)
Что мы в итоге получили на данный момент:

  • Декларативные запросы через Requestable, без ручного конструирования URL и заголовков;

  • Гибкий APIClient, который обрабатывает все запросы;

  • Типобезопасные модели, без Any, Dictionary, и прочего ада;

В следющий части доработаем нетворкинг до полноценной системы и учтем все моменты нужные для нормального сетевого слоя.

Ну, а пока — пока)

Полезные ссылки