REST API запросы с параметрами: интеграция в iOS приложение

Мои REST API запросы такие точные, что сервер просит у меня автограф! ✍️

В 2025 году REST API остаётся основным способом взаимодействия мобильных приложений с серверами. Для iOS-разработчиков умение эффективно работать с REST API является важнейшим навыком, поскольку большинство современных приложений так или иначе взаимодействуют с внешними сервисами. От правильной организации сетевого слоя зависит отзывчивость приложения, энергопотребление устройства и удовлетворенность пользователей.

Особую актуальность представляет умение работать с параметрами запросов - будь то параметры в URL, заголовки, параметры запроса или тело запроса. Всё это позволяет создавать гибкие и эффективные взаимодействия с серверной частью приложения. В этой статье мы рассмотрим все аспекты работы с REST API в iOS-приложениях, уделяя особое внимание параметризации запросов и обработке ответов.

Содержание:


Основы REST API и их значение в современной iOS-разработке

REST (Representational State Transfer) — это архитектурный стиль для распределенных систем, который определяет набор ограничений для создания веб-сервисов. В контексте iOS-разработки REST API предоставляет стандартный способ взаимодействия клиентских приложений с серверами.

Ключевые принципы REST

REST основан на нескольких ключевых принципах:

  1. Клиент-серверная архитектура — разделение интерфейса пользователя от хранения данных
  2. Отсутствие состояния (Statelessness) — каждый запрос от клиента содержит всю необходимую информацию
  3. Кэширование — ответы сервера можно маркировать как кэшируемые или некэшируемые
  4. Единообразие интерфейса — упрощает архитектуру и повышает видимость взаимодействий
  5. Система слоев — клиент не может определить, взаимодействует ли он напрямую с сервером

HTTP-методы в REST API

REST API использует стандартные HTTP-методы для операций с ресурсами:

  • GET — получение ресурса
  • POST — создание нового ресурса
  • PUT — полное обновление ресурса
  • PATCH — частичное обновление ресурса
  • DELETE — удаление ресурса

Почему REST API важны для iOS-приложений

В 2025 году практически все iOS-приложения взаимодействуют с внешними сервисами:

  • Социальные сети интегрируют API для аутентификации и обмена данными
  • Финансовые приложения используют API банков и платежных систем
  • Приложения электронной коммерции подключаются к API магазинов и платежных шлюзов
  • Навигационные приложения взаимодействуют с картографическими сервисами
  • Медиа-приложения получают контент через API стриминговых сервисов

Понимание принципов работы REST API и умение эффективно их использовать в iOS-приложениях — это фундаментальный навык, который значительно расширяет возможности разработчика.

Фреймворки и инструменты для работы с REST API в iOS

Экосистема iOS предлагает несколько подходов к работе с REST API, от встроенных низкоуровневых инструментов до высокоуровневых библиотек.

URLSession: Встроенный фреймворк Apple

URLSession — это мощный и гибкий API, который является основой сетевого взаимодействия в iOS. В 2025 году он остаётся предпочтительным выбором для большинства проектов благодаря:

  • Нативной интеграции с экосистемой Apple
  • Высокой производительности
  • Отсутствию зависимостей от сторонних библиотек
  • Поддержке всех современных сетевых протоколов
  • Расширенным возможностям кэширования и управления сессиями

Базовый пример использования URLSession:

let url = URL(string: "https://api.example.com/users")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
        print("Ошибка: (error.localizedDescription)")
        return
    }

    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        print("Некорректный ответ сервера")
        return
    }

    if let data = data {
        do {
            let users = try JSONDecoder().decode([User].self, from: data)
            print("Получено пользователей: (users.count)")
        } catch {
            print("Ошибка декодирования: (error)")
        }
    }
}

task.resume()

Популярные сторонние библиотеки

Помимо нативного URLSession, разработчики часто используют сторонние библиотеки для упрощения работы с REST API:

  1. Alamofire — мощная обертка над URLSession, предоставляющая более высокоуровневый и удобный интерфейс
  2. Moya — сетевой уровень, который ориентирован на работу с API в виде набора эндпоинтов
  3. Apollo iOS — клиент для работы с GraphQL API (альтернатива REST)
  4. Combine + URLSession — нативное решение для реактивной работы с сетью

В этой статье мы сосредоточимся на использовании URLSession и Combine, так как они являются нативными инструментами Apple и не требуют установки дополнительных зависимостей.

Основы создания запросов с параметрами

Параметризация запросов позволяет клиентскому приложению передавать информацию на сервер различными способами. Рассмотрим основные методы передачи параметров в REST API запросах.

URL-параметры (Query Parameters)

URL-параметры добавляются к базовому URL в виде пар ключ-значение после знака вопроса (?). Они используются преимущественно в GET-запросах для фильтрации, пагинации и сортировки данных.

Пример URL с параметрами: https://api.example.com/users?page=1&limit=20&sort=name

В Swift есть несколько способов добавить URL-параметры:

  1. Ручное формирование URL-строки (не рекомендуется):
let baseURL = "https://api.example.com/users"
let queryString = "?page=1&limit=20&sort=name"
let urlString = baseURL + queryString
guard let url = URL(string: urlString) else { return }
  1. Использование URLComponents (рекомендованный подход):
var components = URLComponents(string: "https://api.example.com/users")!
components.queryItems = [
    URLQueryItem(name: "page", value: "1"),
    URLQueryItem(name: "limit", value: "20"),
    URLQueryItem(name: "sort", value: "name")
]

guard let url = components.url else { return }

Второй подход значительно безопаснее, так как URLComponents автоматически выполняет корректное URL-кодирование специальных символов, предотвращая возможные ошибки.

Заголовки запроса (Headers)

Заголовки запроса передают метаданные о запросе, включая информацию об аутентификации, формате данных и управлении кэшем. Для добавления заголовков используется объект URLRequest:

var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", forHTTPHeaderField: "Authorization")
request.addValue("iOS-App/1.0", forHTTPHeaderField: "User-Agent")

Часто используемые заголовки:

  • Content-Type — тип отправляемых данных (например, application/json)
  • Accept — ожидаемый формат ответа (например, application/json)
  • Authorization — данные для аутентификации (например, Bearer TOKEN)
  • Cache-Control — директивы для управления кэшированием

Тело запроса (Request Body)

Тело запроса используется для передачи данных в методах POST, PUT и PATCH. В iOS существует несколько форматов для тела запроса:

1. JSON (application/json)

Наиболее распространенный формат для современных API:

struct User: Codable {
    let name: String
    let email: String
    let age: Int
}

let user = User(name: "Иван Иванов", email: "ivan@example.com", age: 30)

var request = URLRequest(url: URL(string: "https://api.example.com/users")!)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")

do {
    request.httpBody = try JSONEncoder().encode(user)
} catch {
    print("Ошибка кодирования: (error)")
}

2. URL-кодированная форма (application/x-www-form-urlencoded)

Традиционный формат для отправки данных форм:

var request = URLRequest(url: URL(string: "https://api.example.com/login")!)
request.httpMethod = "POST"
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

let parameters = "username=ivan&password=secret123"
request.httpBody = parameters.data(using: .utf8)

3. Многочастная форма (multipart/form-data)

Используется для отправки файлов и форм с бинарными данными:

let boundary = UUID().uuidString
var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
request.httpMethod = "POST"
request.addValue("multipart/form-data; boundary=(boundary)", forHTTPHeaderField: "Content-Type")

var body = Data()

// Добавление текстовых полей
let fieldName = "username"
let fieldValue = "ivan"
body.append("--(boundary)rn".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name="(fieldName)"rnrn".data(using: .utf8)!)
body.append("(fieldValue)rn".data(using: .utf8)!)

// Добавление изображения
if let imageData = UIImage(named: "profile")?.jpegData(compressionQuality: 0.7) {
    let fieldName = "avatar"
    let fileName = "avatar.jpg"
    let mimeType = "image/jpeg"

    body.append("--(boundary)rn".data(using: .utf8)!)
    body.append("Content-Disposition: form-data; name="(fieldName)"; filename="(fileName)"rn".data(using: .utf8)!)
    body.append("Content-Type: (mimeType)rnrn".data(using: .utf8)!)
    body.append(imageData)
    body.append("rn".data(using: .utf8)!)
}

// Завершение тела запроса
body.append("--(boundary)--rn".data(using: .utf8)!)

request.httpBody = body

Параметры в URL-пути (Path Parameters)

Параметры могут быть частью самого URL-пути:

let userId = 123
let url = URL(string: "https://api.example.com/users/(userId)")!

let task = URLSession.shared.dataTask(with: url) { data, response, error in
    // Обработка ответа
}
task.resume()

Создание структурированного сетевого слоя для работы с REST API

Для эффективной работы с REST API в iOS-приложении рекомендуется создать структурированный сетевой слой, который обеспечит единообразие, повторное использование кода и удобство в обслуживании.

Определение моделей данных

Первый шаг — определение моделей данных, которые будут использоваться для запросов и ответов:

// Модели запросов
struct LoginRequest: Encodable {
    let email: String
    let password: String
}

struct ProductFilterRequest: Encodable {
    let category: String
    let minPrice: Double?
    let maxPrice: Double?
    let sort: String?
}

// Модели ответов
struct User: Decodable {
    let id: Int
    let name: String
    let email: String
    let avatar: URL?
}

struct Product: Decodable {
    let id: Int
    let title: String
    let description: String
    let price: Double
    let imageURL: URL
    let category: String
}

// Модель ошибки API
struct APIError: Decodable {
    let statusCode: Int
    let message: String
    let details: String?
}

Создание API-клиента

Следующий шаг — создание API-клиента, который будет управлять запросами:

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

enum APIError: Error {
    case invalidURL
    case requestFailed(Error)
    case invalidResponse
    case decodingError(Error)
    case serverError(statusCode: Int, message: String)
    case noData
}

class APIClient {
    private let session: URLSession
    private let baseURL: URL

    init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
    }

    func request<T: Decodable>(
        endpoint: String,
        method: HTTPMethod,
        queryItems: [URLQueryItem]? = nil,
        headers: [String: String]? = nil,
        body: Data? = nil
    ) async throws -> T {

        // Формирование URL с endpoint и query параметрами
        var components = URLComponents()
        components.scheme = baseURL.scheme
        components.host = baseURL.host
        components.port = baseURL.port
        components.path = baseURL.path + endpoint
        components.queryItems = queryItems

        guard let url = components.url else {
            throw APIError.invalidURL
        }

        // Создание запроса
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.httpBody = body

        // Установка заголовков
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        headers?.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) }

        // Выполнение запроса
        do {
            let (data, response) = try await session.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw APIError.invalidResponse
            }

            switch httpResponse.statusCode {
            case 200...299:
                do {
                    return try JSONDecoder().decode(T.self, from: data)
                } catch {
                    throw APIError.decodingError(error)
                }

            default:
                // Попытка декодировать ошибку сервера
                if let serverError = try? JSONDecoder().decode(ServerErrorResponse.self, from: data) {
                    throw APIError.serverError(statusCode: httpResponse.statusCode, message: serverError.message)
                }
                throw APIError.serverError(statusCode: httpResponse.statusCode, message: "Неизвестная ошибка")
            }
        } catch let error as APIError {
            throw error
        } catch {
            throw APIError.requestFailed(error)
        }
    }

    // Вспомогательные методы для работы с Encodable типами
    func request<T: Decodable, E: Encodable>(
        endpoint: String,
        method: HTTPMethod,
        queryItems: [URLQueryItem]? = nil,
        headers: [String: String]? = nil,
        body: E
    ) async throws -> T {
        let bodyData = try JSONEncoder().encode(body)
        return try await request(
            endpoint: endpoint,
            method: method,
            queryItems: queryItems,
            headers: headers,
            body: bodyData
        )
    }
}

// Модель ошибки от сервера
struct ServerErrorResponse: Decodable {
    let message: String
    let details: String?
}

Создание сервисных классов

На основе API-клиента создадим конкретные сервисы для работы с разными ресурсами:

// Сервис для работы с пользователями
class UserService {
    private let apiClient: APIClient

    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }

    func login(email: String, password: String) async throws -> AuthResponse {
        let loginRequest = LoginRequest(email: email, password: password)
        return try await apiClient.request(
            endpoint: "/auth/login",
            method: .post,
            body: loginRequest
        )
    }

    func getProfile() async throws -> User {
        return try await apiClient.request(
            endpoint: "/users/me",
            method: .get,
            headers: ["Authorization": "Bearer (TokenManager.shared.accessToken)"]
        )
    }

    func updateProfile(name: String, bio: String?) async throws -> User {
        struct UpdateProfileRequest: Encodable {
            let name: String
            let bio: String?
        }

        let request = UpdateProfileRequest(name: name, bio: bio)
        return try await apiClient.request(
            endpoint: "/users/me",
            method: .patch,
            headers: ["Authorization": "Bearer (TokenManager.shared.accessToken)"],
            body: request
        )
    }
}

// Сервис для работы с продуктами
class ProductService {
    private let apiClient: APIClient

    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }

    func getProducts(
        category: String? = nil,
        minPrice: Double? = nil,
        maxPrice: Double? = nil,
        sort: String? = nil,
        page: Int = 1,
        limit: Int = 20
    ) async throws -> ProductsResponse {
        var queryItems: [URLQueryItem] = [
            URLQueryItem(name: "page", value: "(page)"),
            URLQueryItem(name: "limit", value: "(limit)")
        ]

        if let category = category {
            queryItems.append(URLQueryItem(name: "category", value: category))
        }

        if let minPrice = minPrice {
            queryItems.append(URLQueryItem(name: "minPrice", value: "(minPrice)"))
        }

        if let maxPrice = maxPrice {
            queryItems.append(URLQueryItem(name: "maxPrice", value: "(maxPrice)"))
        }

        if let sort = sort {
            queryItems.append(URLQueryItem(name: "sort", value: sort))
        }

        return try await apiClient.request(
            endpoint: "/products",
            method: .get,
            queryItems: queryItems,
            headers: ["Authorization": "Bearer (TokenManager.shared.accessToken)"]
        )
    }

    func getProductDetails(id: Int) async throws -> Product {
        return try await apiClient.request(
            endpoint: "/products/(id)",
            method: .get,
            headers: ["Authorization": "Bearer (TokenManager.shared.accessToken)"]
        )
    }
}

// Структуры ответов
struct AuthResponse: Decodable {
    let accessToken: String
    let refreshToken: String
    let user: User
}

struct ProductsResponse: Decodable {
    let items: [Product]
    let total: Int
    let page: Int
    let limit: Int
    let hasMore: Bool
}

Внедрение зависимостей и настройка сервисов

class APIServiceProvider {
    static let shared = APIServiceProvider()

    private let baseURL = URL(string: "https://api.example.com/v1")!

    lazy var apiClient: APIClient = {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 30
        config.waitsForConnectivity = true

        let session = URLSession(configuration: config)
        return APIClient(baseURL: baseURL, session: session)
    }()

    lazy var userService: UserService = {
        return UserService(apiClient: apiClient)
    }()

    lazy var productService: ProductService = {
        return ProductService(apiClient: apiClient)
    }()
}

Практические примеры использования REST API с параметрами

Рассмотрим несколько практических примеров использования нашего сетевого слоя в iOS-приложении.

Пример 1: Аутентификация пользователя

class LoginViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let userService = APIServiceProvider.shared.userService

    func login() async {
        guard !email.isEmpty, !password.isEmpty else {
            errorMessage = "Пожалуйста, заполните все поля"
            return
        }

        isLoading = true
        errorMessage = nil

        do {
            let response = try await userService.login(email: email, password: password)

            // Сохранение токенов
            TokenManager.shared.saveTokens(
                accessToken: response.accessToken,
                refreshToken: response.refreshToken
            )

            // Переход на главный экран
            await MainActor.run {
                AppRouter.shared.navigateToMainScreen()
            }
        } catch let error as APIError {
            await handleAPIError(error)
        } catch {
            await MainActor.run {
                errorMessage = "Произошла неизвестная ошибка"
            }
        }

        await MainActor.run {
            isLoading = false
        }
    }

    private func handleAPIError(_ error: APIError) async {
        await MainActor.run {
            switch error {
            case .serverError(_, let message):
                errorMessage = message
            case .requestFailed:
                errorMessage = "Проверьте подключение к интернету"
            default:
                errorMessage = "Не удалось выполнить вход"
            }
        }
    }
}

SwiftUI представление для экрана входа:

struct LoginView: View {
    @StateObject private var viewModel = LoginViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Text("Вход в аккаунт")
                .font(.largeTitle)
                .padding(.bottom, 20)

            TextField("Email", text: $viewModel.email)
                .keyboardType(.emailAddress)
                .autocapitalization(.none)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(8)

            SecureField("Пароль", text: $viewModel.password)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(8)

            if let errorMessage = viewModel.errorMessage {
                Text(errorMessage)
                    .foregroundColor(.red)
                    .font(.caption)
            }

            Button {
                Task {
                    await viewModel.login()
                }
            } label: {
                Text(viewModel.isLoading ? "Выполняется вход..." : "Войти")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
            .disabled(viewModel.isLoading)

            Spacer()
        }
        .padding()
    }
}

Пример 2: Получение списка продуктов с фильтрацией

class ProductListViewModel: ObservableObject {
    @Published var products: [Product] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var hasMoreProducts = false

    // Параметры фильтрации
    @Published var selectedCategory: String?
    @Published var minPrice: Double?
    @Published var maxPrice: Double?
    @Published var sortOption: SortOption = .newest

    private var currentPage = 1
    private let limit = 20
    private let productService = APIServiceProvider.shared.productService

    enum SortOption: String, CaseIterable, Identifiable {
        case newest = "created_at:desc"
        case priceAsc = "price:asc"
        case priceDesc = "price:desc"
        case nameAsc = "title:asc"

        var id: String { self.rawValue }

        var displayName: String {
            switch self {
            case .newest: return "Новые"
            case .priceAsc: return "Сначала дешевле"
            case .priceDesc: return "Сначала дороже"
            case .nameAsc: return "По названию"
            }
        }
    }

    func loadProducts(refresh: Bool = false) async {
        if refresh {
            currentPage = 1
        }

        await MainActor.run {
            isLoading = true
            if refresh {
                products = []
            }
            errorMessage = nil
        }

        do {
            let response = try await productService.getProducts(
                category: selectedCategory,
                minPrice: minPrice,
                maxPrice: maxPrice,
                sort: sortOption.rawValue,
                page: currentPage,
                limit: limit
            )

            await MainActor.run {
                if refresh {
                    self.products = response.items
                } else {
                    self.products.append(contentsOf: response.items)
                }

                self.hasMoreProducts = response.hasMore
                self.currentPage += 1
            }
        } catch let error as APIError {
            await handleAPIError(error)
        } catch {
            await MainActor.run {
                errorMessage = "Не удалось загрузить продукты"
            }
        }

        await MainActor.run {
            isLoading = false
        }
    }

    func loadMoreIfNeeded(product: Product) async {
        // Проверяем, является ли продукт одним из последних в списке
        let thresholdIndex = products.index(products.endIndex, offsetBy: -5)
        if products.firstIndex(where: { $0.id == product.id }) ?? 0 >= thresholdIndex,
           !isLoading,
           hasMoreProducts {
            await loadProducts()
        }
    }

    // Применение фильтров
    func applyFilters() async {
        await loadProducts(refresh: true)
    }

    // Сброс фильтров
    func resetFilters() async {
        selectedCategory = nil
        minPrice = nil
        maxPrice = nil
        sortOption = .newest

        await loadProducts(refresh: true)
    }

    private func handleAPIError(_ error: APIError) async {
        await MainActor.run {
            switch error {
            case .serverError(_, let message):
                errorMessage = message
            case .requestFailed:
                errorMessage = "Проверьте подключение к интернету"
            default:
                errorMessage = "Не удалось загрузить продукты"
            }
        }
    }
}

SwiftUI представление для списка продуктов:

struct ProductListView: View {
    @StateObject private var viewModel = ProductListViewModel()
    @State private var showingFilterSheet = false

    var body: some View {
        NavigationView {
            VStack {
                // Фильтры
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 10) {
                        FilterChip(
                            isSelected: viewModel.selectedCategory != nil,
                            label: "Категория",
                            onTap: { showingFilterSheet = true }
                        )

                        FilterChip(
                            isSelected: viewModel.minPrice != nil || viewModel.maxPrice != nil,
                            label: "Цена",
                            onTap: { showingFilterSheet = true }
                        )

                        ForEach(ProductListViewModel.SortOption.allCases) { option in
                            FilterChip(
                                isSelected: viewModel.sortOption == option,
                                label: option.displayName,
                                onTap: {
                                    viewModel.sortOption = option
                                    Task {
                                        await viewModel.applyFilters()
                                    }
                                }
                            )
                        }
                    }
                    .padding(.horizontal)
                }

                if viewModel.isLoading && viewModel.products.isEmpty {
                    ProgressView("Загрузка...")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                } else if viewModel.products.isEmpty && viewModel.errorMessage == nil {
                    ContentUnavailableView(
                        "Товары не найдены",
                        systemImage: "bag.badge.questionmark",
                        description: Text("Попробуйте изменить параметры фильтрации")
                    )
                } else {
                    ScrollView {
                        LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 16) {
                            ForEach(viewModel.products) { product in
                                NavigationLink(destination: ProductDetailView(productId: product.id)) {
                                    ProductCard(product: product)
                                        .task {
                                            await viewModel.loadMoreIfNeeded(product: product)
                                        }
                                }
                            }
                        }
                        .padding()

                        if viewModel.isLoading && !viewModel.products.isEmpty {
                            ProgressView()
                                .padding()
                        }

                        if let errorMessage = viewModel.errorMessage {
                            Text(errorMessage)
                                .foregroundColor(.red)
                                .padding()
                        }
                    }
                }
            }
            .navigationTitle("Товары")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        showingFilterSheet = true
                    }) {
                        Image(systemName: "slider.horizontal.3")
                    }
                }
            }
            .sheet(isPresented: $showingFilterSheet) {
                FilterView(viewModel: viewModel)
            }
            .task {
                await viewModel.loadProducts()
            }
            .refreshable {
                await viewModel.loadProducts(refresh: true)
            }
        }
    }
}

struct FilterChip: View {
    let isSelected: Bool
    let label: String
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            Text(label)
                .padding(.horizontal, 12)
                .padding(.vertical, 6)
                .background(isSelected ? Color.blue : Color.gray.opacity(0.1))
                .foregroundColor(isSelected ? .white : .primary)
                .cornerRadius(16)
        }
    }
}

Обработка ошибок и управление сетевыми состояниями

Эффективная обработка ошибок является ключевым компонентом надежного сетевого слоя в iOS-приложении. Рассмотрим стратегии обработки различных типов ошибок и состояний сети.

Типы сетевых ошибок

При работе с REST API мы можем столкнуться с различными типами ошибок:

  1. Ошибки соединения — отсутствие интернета, таймауты, сбои DNS
  2. Ошибки HTTP — ответы с кодами 4xx и 5xx
  3. Ошибки парсинга данных — некорректный формат ответа, несоответствие схемы
  4. Ошибки аутентификации — истекший токен, недостаточные права
  5. Бизнес-ошибки — валидационные ошибки, ошибки бизнес-логики

Структурированная обработка ошибок

Создадим расширенный тип ошибки с удобными методами для обработки:

enum NetworkError: Error, Equatable {
    case noConnection
    case timeout
    case invalidURL
    case requestFailed(statusCode: Int)
    case serverError(statusCode: Int)
    case authenticationError(message: String)
    case decodingError
    case invalidResponse
    case unexpectedError(message: String)

    var isRetryable: Bool {
        switch self {
        case .noConnection, .timeout, .serverError:
            return true
        default:
            return false
        }
    }

    var userFriendlyMessage: String {
        switch self {
        case .noConnection:
            return "Отсутствует подключение к интернету. Проверьте настройки сети и попробуйте снова."
        case .timeout:
            return "Превышено время ожидания. Пожалуйста, попробуйте позже."
        case .invalidURL:
            return "Некорректный URL запроса."
        case .requestFailed(let statusCode):
            return "Запрос не выполнен. Код ошибки: (statusCode)"
        case .serverError(let statusCode):
            return "Ошибка сервера. Код ошибки: (statusCode). Пожалуйста, попробуйте позже."
        case .authenticationError(let message):
            return "Ошибка аутентификации: (message)"
        case .decodingError:
            return "Не удалось обработать данные, полученные с сервера."
        case .invalidResponse:
            return "Получен некорректный ответ от сервера."
        case .unexpectedError(let message):
            return "Произошла непредвиденная ошибка: (message)"
        }
    }

    static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
        switch (lhs, rhs) {
        case (.noConnection, .noConnection),
             (.timeout, .timeout),
             (.invalidURL, .invalidURL),
             (.decodingError, .decodingError),
             (.invalidResponse, .invalidResponse):
            return true
        case (.requestFailed(let lhsCode), .requestFailed(let rhsCode)):
            return lhsCode == rhsCode
        case (.serverError(let lhsCode), .serverError(let rhsCode)):
            return lhsCode == rhsCode
        case (.authenticationError(let lhsMsg), .authenticationError(let rhsMsg)):
            return lhsMsg == rhsMsg
        case (.unexpectedError(let lhsMsg), .unexpectedError(let rhsMsg)):
            return lhsMsg == rhsMsg
        default:
            return false
        }
    }
}

Интерпретация ошибок HTTP в классе APIClient

Улучшим метод request в нашем APIClient, чтобы корректно обрабатывать ошибки HTTP:

func request<T: Decodable>(
    endpoint: String,
    method: HTTPMethod,
    queryItems: [URLQueryItem]? = nil,
    headers: [String: String]? = nil,
    body: Data? = nil
) async throws -> T {
    // ... существующий код формирования запроса ...

    do {
        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        switch httpResponse.statusCode {
        case 200...299:
            do {
                let decoder = JSONDecoder()
                decoder.keyDecodingStrategy = .convertFromSnakeCase
                return try decoder.decode(T.self, from: data)
            } catch {
                throw NetworkError.decodingError
            }

        case 400:
            throw NetworkError.requestFailed(statusCode: httpResponse.statusCode)

        case 401:
            if httpResponse.url?.path.contains("/auth/refresh") == true {
                // Ошибка при обновлении токена
                TokenManager.shared.clearTokens()
                throw NetworkError.authenticationError(message: "Сессия истекла. Пожалуйста, войдите снова.")
            } else {
                // Пробуем обновить токен и повторить запрос
                return try await refreshTokenAndRetry(request: request)
            }

        case 403:
            throw NetworkError.authenticationError(message: "Недостаточно прав для выполнения операции")

        case 404:
            throw NetworkError.requestFailed(statusCode: 404)

        case 500...599:
            throw NetworkError.serverError(statusCode: httpResponse.statusCode)

        default:
            // Пытаемся распарсить сообщение об ошибке, если оно есть
            if let errorResponse = try? JSONDecoder().decode(ServerErrorResponse.self, from: data) {
                throw NetworkError.unexpectedError(message: errorResponse.message)
            }
            throw NetworkError.requestFailed(statusCode: httpResponse.statusCode)
        }
    } catch let error as NetworkError {
        throw error
    } catch let error as URLError {
        switch error.code {
        case .notConnectedToInternet:
            throw NetworkError.noConnection
        case .timedOut:
            throw NetworkError.timeout
        case .badURL:
            throw NetworkError.invalidURL
        default:
            throw NetworkError.unexpectedError(message: error.localizedDescription)
        }
    } catch {
        throw NetworkError.unexpectedError(message: error.localizedDescription)
    }
}

private func refreshTokenAndRetry<T: Decodable>(request: URLRequest) async throws -> T {
    // Если нет refresh токена, выбрасываем ошибку аутентификации
    guard let refreshToken = TokenManager.shared.refreshToken else {
        TokenManager.shared.clearTokens()
        throw NetworkError.authenticationError(message: "Сессия истекла. Пожалуйста, войдите снова.")
    }

    // Создаем запрос на обновление токена
    var components = URLComponents()
    components.scheme = baseURL.scheme
    components.host = baseURL.host
    components.port = baseURL.port
    components.path = baseURL.path + "/auth/refresh"

    guard let refreshURL = components.url else {
        throw NetworkError.invalidURL
    }

    var refreshRequest = URLRequest(url: refreshURL)
    refreshRequest.httpMethod = "POST"
    refreshRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")

    let refreshBody = ["refreshToken": refreshToken]
    refreshRequest.httpBody = try? JSONEncoder().encode(refreshBody)

    // Выполняем запрос на обновление токена
    let (data, response) = try await session.data(for: refreshRequest)

    guard let httpResponse = response as? HTTPURLResponse, 
          (200...299).contains(httpResponse.statusCode) else {
        TokenManager.shared.clearTokens()
        throw NetworkError.authenticationError(message: "Не удалось обновить сессию")
    }

    // Декодируем ответ
    struct RefreshResponse: Decodable {
        let accessToken: String
        let refreshToken: String
    }

    let refreshResponse = try JSONDecoder().decode(RefreshResponse.self, from: data)

    // Сохраняем новые токены
    TokenManager.shared.saveTokens(
        accessToken: refreshResponse.accessToken,
        refreshToken: refreshResponse.refreshToken
    )

    // Создаем новый запрос с обновленным токеном
    var newRequest = request
    newRequest.setValue(
        "Bearer (refreshResponse.accessToken)",
        forHTTPHeaderField: "Authorization"
    )

    // Повторяем оригинальный запрос
    let (responseData, responseObj) = try await session.data(for: newRequest)

    guard let httpResponseObj = responseObj as? HTTPURLResponse,
          (200...299).contains(httpResponseObj.statusCode) else {
        throw NetworkError.requestFailed(statusCode: (responseObj as? HTTPURLResponse)?.statusCode ?? 0)
    }

    return try JSONDecoder().decode(T.self, from: responseData)
}

Представление состояний загрузки в UI

Для обеспечения хорошего пользовательского опыта важно корректно отображать различные состояния загрузки. Создадим обобщенное перечисление для управления состояниями:

enum LoadingState<T, E: Error> {
    case idle
    case loading
    case loaded(T)
    case failed(E)

    var isLoading: Bool {
        if case .loading = self {
            return true
        }
        return false
    }

    var value: T? {
        if case .loaded(let value) = self {
            return value
        }
        return nil
    }

    var error: E? {
        if case .failed(let error) = self {
            return error
        }
        return nil
    }
}

Применение в ViewModel:

class ProductsViewModel: ObservableObject {
    @Published var productsState: LoadingState<[Product], NetworkError> = .idle

    func loadProducts() async {
        await MainActor.run {
            productsState = .loading
        }

        do {
            let products = try await productService.getProducts()
            await MainActor.run {
                productsState = .loaded(products.items)
            }
        } catch let error as NetworkError {
            await MainActor.run {
                productsState = .failed(error)
            }
        } catch {
            await MainActor.run {
                productsState = .failed(.unexpectedError(message: error.localizedDescription))
            }
        }
    }
}

Использование в SwiftUI:

struct ProductsView: View {
    @StateObject private var viewModel = ProductsViewModel()

    var body: some View {
        Group {
            switch viewModel.productsState {
            case .idle:
                Color.clear.onAppear {
                    Task {
                        await viewModel.loadProducts()
                    }
                }

            case .loading:
                ProgressView("Загрузка товаров...")

            case .loaded(let products):
                if products.isEmpty {
                    ContentUnavailableView(
                        "Товары не найдены",
                        systemImage: "bag.badge.questionmark"
                    )
                } else {
                    productsList(products)
                }

            case .failed(let error):
                VStack {
                    ContentUnavailableView(
                        "Не удалось загрузить товары",
                        systemImage: "exclamationmark.triangle",
                        description: Text(error.userFriendlyMessage)
                    )

                    if error.isRetryable {
                        Button("Повторить") {
                            Task {
                                await viewModel.loadProducts()
                            }
                        }
                        .buttonStyle(.bordered)
                        .padding()
                    }
                }
            }
        }
        .navigationTitle("Товары")
    }

    private func productsList(_ products: [Product]) -> some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 16) {
                ForEach(products) { product in
                    ProductCard(product: product)
                }
            }
            .padding()
        }
        .refreshable {
            await viewModel.loadProducts()
        }
    }
}

Интеграция с Combine для реактивной работы с API

Combine предоставляет мощный инструментарий для реактивной обработки асинхронных операций. Интеграция фреймворка Combine с нашим сетевым слоем позволит создавать более декларативные и реактивные потоки данных.

Расширение URLSession для работы с Combine

extension URLSession {
    func dataTaskPublisher<T: Decodable>(for request: URLRequest) -> AnyPublisher<T, NetworkError> {
        return self.dataTaskPublisher(for: request)
            .tryMap { data, response in
                guard let httpResponse = response as? HTTPURLResponse else {
                    throw NetworkError.invalidResponse
                }

                if !(200...299).contains(httpResponse.statusCode) {
                    switch httpResponse.statusCode {
                    case 401:
                        throw NetworkError.authenticationError(message: "Требуется авторизация")
                    case 403:
                        throw NetworkError.authenticationError(message: "Доступ запрещен")
                    case 404:
                        throw NetworkError.requestFailed(statusCode: 404)
                    case 500...599:
                        throw NetworkError.serverError(statusCode: httpResponse.statusCode)
                    default:
                        throw NetworkError.requestFailed(statusCode: httpResponse.statusCode)
                    }
                }

                return data
            }
            .decode(type: T.self, decoder: JSONDecoder())
            .mapError { error in
                if let networkError = error as? NetworkError {
                    return networkError
                }

                if let urlError = error as? URLError {
                    switch urlError.code {
                    case .notConnectedToInternet:
                        return NetworkError.noConnection
                    case .timedOut:
                        return NetworkError.timeout
                    default:
                        return NetworkError.unexpectedError(message: urlError.localizedDescription)
                    }
                }

                if error is DecodingError {
                    return NetworkError.decodingError
                }

                return NetworkError.unexpectedError(message: error.localizedDescription)
            }
            .eraseToAnyPublisher()
    }
}

Добавление поддержки Combine в APIClient

extension APIClient {
    func requestPublisher<T: Decodable>(
        endpoint: String,
        method: HTTPMethod,
        queryItems: [URLQueryItem]? = nil,
        headers: [String: String]? = nil,
        body: Data? = nil
    ) -> AnyPublisher<T, NetworkError> {
        // Формирование URL с endpoint и query параметрами
        var components = URLComponents()
        components.scheme = baseURL.scheme
        components.host = baseURL.host
        components.port = baseURL.port
        components.path = baseURL.path + endpoint
        components.queryItems = queryItems

        guard let url = components.url else {
            return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()
        }

        // Создание запроса
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.httpBody = body

        // Установка заголовков
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        headers?.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) }

        // Выполнение запроса
        return session.dataTaskPublisher(for: request)
    }

    // Вспомогательный метод для работы с Encodable типами
    func requestPublisher<T: Decodable, E: Encodable>(
        endpoint: String,
        method: HTTPMethod,
        queryItems: [URLQueryItem]? = nil,
        headers: [String: String]? = nil,
        body: E
    ) -> AnyPublisher<T, NetworkError> {
        do {
            let bodyData = try JSONEncoder().encode(body)
            return requestPublisher(
                endpoint: endpoint,
                method: method,
                queryItems: queryItems,
                headers: headers,
                body: bodyData
            )
        } catch {
            return Fail(error: NetworkError.unexpectedError(message: "Ошибка кодирования: (error)")).eraseToAnyPublisher()
        }
    }
}

Пример использования с Combine во ViewModel

class SearchViewModel: ObservableObject {
    @Published var searchQuery = ""
    @Published var results: [SearchResult] = []
    @Published var isLoading = false
    @Published var error: NetworkError?

    private let apiClient = APIServiceProvider.shared.apiClient
    private var cancellables = Set<AnyCancellable>()

    init() {
        setupSearchPublisher()
    }

    private func setupSearchPublisher() {
        $searchQuery
            .removeDuplicates()
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .filter { !$0.isEmpty && $0.count >= 3 }
            .handleEvents(receiveOutput: { [weak self] _ in
                self?.isLoading = true
                self?.error = nil
            })
            .flatMap { [weak self] query -> AnyPublisher<[SearchResult], NetworkError> in
                guard let self = self else {
                    return Fail(error: NetworkError.unexpectedError(message: "Внутренняя ошибка")).eraseToAnyPublisher()
                }

                let queryItems = [URLQueryItem(name: "q", value: query)]
                return self.apiClient.requestPublisher(
                    endpoint: "/search",
                    method: .get,
                    queryItems: queryItems,
                    headers: ["Authorization": "Bearer (TokenManager.shared.accessToken ?? "")"]
                )
            }
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.error = error
                    }
                },
                receiveValue: { [weak self] results in
                    self?.results = results
                }
            )
            .store(in: &cancellables)
    }
}

Кэширование ответов REST API для повышения производительности

Кэширование данных, полученных от API, может значительно улучшить производительность приложения и обеспечить оффлайн-доступ к контенту. Рассмотрим несколько стратегий кэширования в iOS-приложениях.

Встроенное кэширование URLSession

URLSession имеет встроенную систему кэширования, которую можно настроить через URLSessionConfiguration:

let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .returnCacheDataElseLoad
configuration.urlCache = URLCache(
    memoryCapacity: 10 * 1024 * 1024,    // 10 МБ памяти
    diskCapacity: 50 * 1024 * 1024       // 50 МБ на диске
)

let session = URLSession(configuration: configuration)

При создании запроса можно указать конкретную политику кэширования:

var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataDontLoad // Использовать только кэш
// или
request.cachePolicy = .reloadIgnoringLocalCacheData // Игнорировать кэш
// или
request.cachePolicy = .returnCacheDataElseLoad // Кэш или загрузка

HTTP-заголовки для управления кэшированием

REST API часто предоставляет HTTP-заголовки, которые определяют стратегию кэширования:

  • Cache-Control — основной механизм управления кэшированием в HTTP/1.1
  • ETag — тег для условной загрузки ресурса, если он изменился
  • Last-Modified — дата последнего изменения ресурса

Пример проверки заголовков в ответе:

func checkCacheHeaders(response: HTTPURLResponse) {
    // Проверка ETag
    if let etag = response.allHeaderFields["ETag"] as? String {
        cache.saveETag(etag, for: response.url?.absoluteString ?? "")
    }

    // Проверка Cache-Control
    if let cacheControl = response.allHeaderFields["Cache-Control"] as? String {
        if cacheControl.contains("no-cache") || cacheControl.contains("no-store") {
            // Не кэшировать
        } else if let maxAgeString = cacheControl.components(separatedBy: "max-age=").last,
                  let maxAge = Int(maxAgeString.components(separatedBy: ",").first ?? "") {
            // Кэшировать на maxAge секунд
            let expirationDate = Date().addingTimeInterval(TimeInterval(maxAge))
            cache.saveExpirationDate(expirationDate, for: response.url?.absoluteString ?? "")
        }
    }
}

Реализация собственного кэша с использованием SwiftData/CoreData

Для более сложного кэширования можно использовать базы данных:

// Модель кэшированного ответа в SwiftData
@Model
class CachedResponse {
    var url: String
    var data: Data
    var dateCreated: Date
    var expirationDate: Date?
    var etag: String?

    init(url: String, data: Data, expirationDate: Date? = nil, etag: String? = nil) {
        self.url = url
        self.data = data
        self.dateCreated = Date()
        self.expirationDate = expirationDate
        self.etag = etag
    }

    var isExpired: Bool {
        if let expirationDate = expirationDate {
            return Date() > expirationDate
        }
        return false
    }
}

// Менеджер кэша
class CacheManager {
    private let modelContainer: ModelContainer

    init() throws {
        let schema = Schema([CachedResponse.self])
        self.modelContainer = try ModelContainer(for: schema)
    }

    func saveResponse(_ data: Data, for url: String, expirationDate: Date? = nil, etag: String? = nil) throws {
        let context = ModelContext(modelContainer)

        // Проверяем, есть ли уже кэш для данного URL
        let descriptor = FetchDescriptor<CachedResponse>(predicate: #Predicate { $0.url == url })
        if let existingCache = try context.fetch(descriptor).first {
            // Обновляем существующий кэш
            existingCache.data = data
            existingCache.dateCreated = Date()
            existingCache.expirationDate = expirationDate
            existingCache.etag = etag
        } else {
            // Создаем новый кэш
            let cachedResponse = CachedResponse(
                url: url,
                data: data,
                expirationDate: expirationDate,
                etag: etag
            )
            context.insert(cachedResponse)
        }

        try context.save()
    }

    func getCachedResponse(for url: String) throws -> CachedResponse? {
        let context = ModelContext(modelContainer)
        let descriptor = FetchDescriptor<CachedResponse>(predicate: #Predicate { $0.url == url })

        if let cachedResponse = try context.fetch(descriptor).first {
            if cachedResponse.isExpired {
                return nil
            }
            return cachedResponse
        }

        return nil
    }

    func clearCache() throws {
        let context = ModelContext(modelContainer)
        let descriptor = FetchDescriptor<CachedResponse>()
        let cachedResponses = try context.fetch(descriptor)

        for response in cachedResponses {
            context.delete(response)
        }

        try context.save()
    }

    func clearExpiredCache() throws {
        let context = ModelContext(modelContainer)
        let now = Date()
        let descriptor = FetchDescriptor<CachedResponse>(
            predicate: #Predicate {
                $0.expirationDate != nil && $0.expirationDate! < now
            }
        )

        let expiredResponses = try context.fetch(descriptor)

        for response in expiredResponses {
            context.delete(response)
        }

        try context.save()
    }
}

Интеграция кэширования в APIClient

class APIClient {
    // ... существующие свойства ...
    private let cacheManager: CacheManager

    init(baseURL: URL, session: URLSession = .shared, cacheManager: CacheManager) {
        self.baseURL = baseURL
        self.session = session
        self.cacheManager = cacheManager
    }

    func request<T: Decodable>(
        endpoint: String,
        method: HTTPMethod,
        queryItems: [URLQueryItem]? = nil,
        headers: [String: String]? = nil,
        body: Data? = nil,
        cachePolicy: CachePolicy = .useProtocolCachePolicy
    ) async throws -> T {
        // Формирование URL
        var components = URLComponents()
        // ... существующий код формирования URL ...
        guard let url = components.url else {
            throw NetworkError.invalidURL
        }

        // Если это GET-запрос и политика кэширования позволяет, проверяем кэш
        if method == .get && cachePolicy != .reloadIgnoringCache {
            if let cachedResponse = try? cacheManager.getCachedResponse(for: url.absoluteString) {
                do {
                    let decoder = JSONDecoder()
                    decoder.keyDecodingStrategy = .convertFromSnakeCase
                    return try decoder.decode(T.self, from: cachedResponse.data)
                } catch {
                    // Если декодирование не удалось, продолжаем с сетевым запросом
                    Logger.warning("Не удалось декодировать кэшированный ответ: (error)")
                }
            }

            // Если cachePolicy равен .onlyCached, но кэш не найден, выбрасываем ошибку
            if cachePolicy == .onlyCached {
                throw NetworkError.unexpectedError(message: "Данные отсутствуют в кэше")
            }
        }

        // Создание запроса
        var request = URLRequest(url: url)
        // ... существующий код создания запроса ...

        // Если у нас есть ETag для данного URL, добавляем If-None-Match заголовок
        if method == .get, 
           let cachedResponse = try? cacheManager.getCachedResponse(for: url.absoluteString),
           let etag = cachedResponse.etag {
            request.addValue(etag, forHTTPHeaderField: "If-None-Match")
        }

        // Выполнение запроса
        do {
            let (data, response) = try await session.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw NetworkError.invalidResponse
            }

            switch httpResponse.statusCode {
            case 200...299:
                // Кэширование ответа для GET-запросов
                if method == .get {
                    var expirationDate: Date? = nil
                    var etag: String? = nil

                    // Извлечение Cache-Control и ETag заголовков
                    if let cacheControl = httpResponse.value(forHTTPHeaderField: "Cache-Control") {
                        if let maxAgeString = cacheControl.components(separatedBy: "max-age=").last,
                           let maxAge = Int(maxAgeString.components(separatedBy: ",").first ?? "") {
                            expirationDate = Date().addingTimeInterval(TimeInterval(maxAge))
                        }
                    }

                    if let etagValue = httpResponse.value(forHTTPHeaderField: "ETag") {
                        etag = etagValue
                    }

                    try? cacheManager.saveResponse(
                        data,
                        for: url.absoluteString,
                        expirationDate: expirationDate,
                        etag: etag
                    )
                }

                do {
                    let decoder = JSONDecoder()
                    decoder.keyDecodingStrategy = .convertFromSnakeCase
                    return try decoder.decode(T.self, from: data)
                } catch {
                    throw NetworkError.decodingError
                }

            case 304: // Not Modified
                // Используем кэшированный ответ, так как ресурс не изменился
                if let cachedResponse = try? cacheManager.getCachedResponse(for: url.absoluteString) {
                    do {
                        let decoder = JSONDecoder()
                        decoder.keyDecodingStrategy = .convertFromSnakeCase
                        return try decoder.decode(T.self, from: cachedResponse.data)
                    } catch {
                        throw NetworkError.decodingError
                    }
                } else {
                    // Это странный случай - сервер вернул 304, но у нас нет кэша
                    throw NetworkError.unexpectedError(message: "Получен код 304, но кэш не найден")
                }

            // ... существующая обработка других статус-кодов ...
            }
        } catch {
            // ... существующая обработка ошибок ...
        }
    }
}

// Политики кэширования
enum CachePolicy {
    case useProtocolCachePolicy // Использовать политику из HTTP-заголовков
    case reloadIgnoringCache    // Всегда загружать с сервера
    case returnCacheIfAvailable // Использовать кэш, если доступен, иначе загружать
    case onlyCached            // Использовать только кэшированные данные
}

Тестирование сетевого слоя

Тестирование сетевого слоя является критически важным для обеспечения надежности приложения. Рассмотрим несколько подходов к тестированию работы с REST API.

Модульное тестирование с моками

Для изоляции тестов от внешних зависимостей используем моки:

// Протокол для абстракции URLSession
protocol URLSessionProtocol {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

// Расширение для реального URLSession
extension URLSession: URLSessionProtocol {}

// Мок-реализация для тестирования
class MockURLSession: URLSessionProtocol {
    var data: Data?
    var response: URLResponse?
    var error: Error?

    init(data: Data? = nil, response: URLResponse? = nil, error: Error? = nil) {
        self.data = data
        self.response = response
        self.error = error
    }

    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        if let error = error {
            throw error
        }

        guard let data = data, let response = response else {
            throw NetworkError.invalidResponse
        }

        return (data, response)
    }
}

Пример теста с использованием моков:

import XCTest
@testable import YourApp

final class APIClientTests: XCTestCase {
    var apiClient: APIClient!
    var mockSession: MockURLSession!

    override func setUp() {
        super.setUp()
        mockSession = MockURLSession()
        apiClient = APIClient(baseURL: URL(string: "https://api.example.com")!, session: mockSession)
    }

    func testSuccessfulRequest() async throws {
        // Подготовка данных
        let userData = """
        {
            "id": 1,
            "name": "John Doe",
            "email": "john@example.com"
        }
        """.data(using: .utf8)!

        let response = HTTPURLResponse(
            url: URL(string: "https://api.example.com/users/1")!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: ["Content-Type": "application/json"]
        )!

        mockSession.data = userData
        mockSession.response = response

        // Выполнение запроса
        let user: User = try await apiClient.request(
            endpoint: "/users/1",
            method: .get
        )

        // Проверка результатов
        XCTAssertEqual(user.id, 1)
        XCTAssertEqual(user.name, "John Doe")
        XCTAssertEqual(user.email, "john@example.com")
    }

    func testRequestWithError() async {
        // Подготовка данных
        let errorResponse = HTTPURLResponse(
            url: URL(string: "https://api.example.com/users/1")!,
            statusCode: 404,
            httpVersion: nil,
            headerFields: nil
        )!

        mockSession.data = "Not Found".data(using: .utf8)!
        mockSession.response = errorResponse

        // Выполнение запроса и проверка ошибки
        do {
            let _: User = try await apiClient.request(
                endpoint: "/users/1",
                method: .get
            )
            XCTFail("Запрос должен был завершиться с ошибкой")
        } catch let error as NetworkError {
            XCTAssertEqual(error, NetworkError.requestFailed(statusCode: 404))
        } catch {
            XCTFail("Неожиданный тип ошибки: (error)")
        }
    }
}

Интеграционное тестирование с настоящим API

Для более полного тестирования можно использовать реальные API-эндпоинты:

import XCTest
@testable import YourApp

final class APIIntegrationTests: XCTestCase {
    var apiClient: APIClient!

    override func setUp() {
        super.setUp()

        // Используем тестовое окружение
        let baseURL = URL(string: "https://api-test.example.com")!
        apiClient = APIClient(baseURL: baseURL)
    }

    func testFetchProducts() async throws {
        // Выполнение реального запроса
        let products: ProductsResponse = try await apiClient.request(
            endpoint: "/products",
            method: .get,
            queryItems: [URLQueryItem(name: "limit", value: "5")]
        )

        // Проверка результатов
        XCTAssertFalse(products.items.isEmpty, "Список товаров не должен быть пустым")
        XCTAssertEqual(products.items.count, 5, "Должно быть получено 5 товаров")
    }
}

Использование записанных ответов API с OHHTTPStubs

Библиотека OHHTTPStubs позволяет записывать и воспроизводить ответы API:

import XCTest
import OHHTTPStubs
@testable import YourApp

final class StubsTests: XCTestCase {
    var apiClient: APIClient!

    override func setUp() {
        super.setUp()

        apiClient = APIClient(baseURL: URL(string: "https://api.example.com")!)

        // Настройка стаба для GET /users/1
        stub(condition: isHost("api.example.com") && isPath("/users/1") && isMethodGET()) { _ in
            let stubPath = OHPathForFile("user_response.json", type(of: self))!
            return fixture(filePath: stubPath, headers: ["Content-Type": "application/json"])
        }
    }

    override func tearDown() {
        HTTPStubs.removeAllStubs()
        super.tearDown()
    }

    func testFetchUser() async throws {
        let user: User = try await apiClient.request(
            endpoint: "/users/1",
            method: .get
        )

        XCTAssertEqual(user.id, 1)
        XCTAssertEqual(user.name, "John Doe")
    }
}

Лучшие практики работы с REST API в iOS

На основе опыта разработки iOS-приложений, взаимодействующих с REST API, можно выделить ряд лучших практик.

1. Структурируйте сетевой слой

  • Разделяйте код на слои: клиент API, сервисы, репозитории, модели
  • Используйте протоколы для абстракции сетевых запросов
  • Изолируйте бизнес-логику от деталей сетевого взаимодействия

2. Эффективно обрабатывайте ошибки

  • Создавайте понятные пользовательские сообщения для разных ошибок
  • Реализуйте механизм повторных попыток для временных проблем
  • Логируйте ошибки для последующего анализа

3. Оптимизируйте производительность

  • Используйте кэширование для минимизации сетевых запросов
  • Применяйте пагинацию для больших наборов данных
  • Оптимизируйте размер запросов и ответов

4. Обеспечивайте безопасность

  • Всегда используйте HTTPS
  • Храните токены аутентификации в безопасном месте (Keychain)
  • Внедряйте механизм обновления токенов
  • Не хардкодьте ключи API и другие секреты

5. Обеспечивайте удобство использования

  • Отображайте состояния загрузки, ошибки и пустые состояния
  • Предоставляйте возможность отмены и повтора операций
  • Реализуйте оффлайн-режим там, где это возможно

6. Тестируйте тщательно

  • Пишите модульные тесты для сетевого слоя
  • Используйте моки и стабы для изоляции тестов
  • Проводите интеграционное тестирование с реальными API

7. Поддерживайте разные окружения

  • Легко переключайтесь между тестовым, staging и production API
  • Используйте разные настройки для разных окружений

Заключение

В этой статье мы рассмотрели комплексный подход к интеграции REST API в iOS-приложения, уделяя особое внимание параметризации запросов. Мы изучили:

  • Основные принципы REST API и их значение для iOS-разработки
  • Различные способы передачи параметров в запросах
  • Создание структурированного сетевого слоя с APIClient и сервисами
  • Эффективную обработку ошибок и управление состояниями
  • Интеграцию с Combine для реактивной работы
  • Стратегии кэширования для повышения производительности
  • Подходы к тестированию сетевого слоя
  • Лучшие практики работы с REST API

Умение эффективно работать с REST API и передавать параметры в запросах является ключевым навыком для современного iOS-разработчика. Правильно спроектированный сетевой слой делает приложение более надежным, производительным и удобным в поддержке.

При разработке сетевого взаимодействия в iOS-приложениях стоит следовать принципам SOLID, использовать абстракции и учитывать особенности мобильной среды: нестабильное соединение, ограниченные ресурсы устройства и необходимость оффлайн-работы.


Не забудьте загрузить приложение iJun в AppStore и подписаться на мой YouTube канал для получения дополнительных материалов по iOS-разработке.

Список литературы и дополнительных материалов

  1. URLSession - Apple Developer Documentation
  2. Handling URL Session Delegates and Callbacks - WWDC 2023
  3. Meet URLSession in Swift - NSHipster
  4. Alamofire - Elegant Networking in Swift
  5. RESTful API Design: Best Practices
Также читайте:  iOS 18: Что Известно на Данный Момент?