
По крайней мере в последнее десятилетие, количество приложений, которым требуется доступ в интернет, неимоверно возросло. Причем для большинства проектов требуется только выполнение REST запросов и загрузка изображений, с созданием preview. В связи с чем, необходим своего рода менеджер сетевых запросов для загрузки данных из сети. Далее будет представлен NetworkManager, с помощью которого выполняются REST запросы.
Задача
Необходимо разработать менеджер сетевых запросов для выполнения REST запросов. При этом необходимо, чтобы была возможность обрабатывать появление/отсутствие соединения и конфигурировать кэширование. Решение должно использовать новую систему concurrency, представленную в iOS 13.
Решение
Ниже представлен базовый класс, в котором расположена логика по обработке ответа сервера (URLResponse), от которого будут наследоваться конкретные загрузчики (модули), в том числе DataLoader и который зависит только от Foundation framework.
import Foundation public class NetworkManager { // MARK: - Properties let session: URLSession // MARK: - Lifecycle public init(session: URLSession) { self.session = session } // MARK: - Methods func handle<T>(response: URLResponse, content: T) throws -> T { guard let response = response as? HTTPURLResponse else { throw "Unknown response received" } guard let httpStatusCode = HttpStatusCode(rawValue: response.statusCode) else { throw "Unknown http status code" } if httpStatusCode.isSuccessStatusCode { return content } else if let content = content as? Data { throw try JSONDecoder().decode(Request.Error.self, from: content) } else { throw Request.Error(code: response.statusCode) } } }
Не стоит удивляться наличию перечисления HTTPStatusCode, поскольку это достаточно стандартный набор констант, возвращаемых серверами в URLResponse.
import Foundation public enum HttpStatusCode: Int { case unknown = -1 // Informational response case `continue` = 100 case switchingProtocols = 101 case processing = 102 case earlyHints = 103 // Success case ok = 200 case created = 201 case accepted = 202 case nonAuthoritativeInformation = 203 case noContent = 204 case resetContent = 205 case partialContent = 206 case multiStatus = 207 case alreadyReported = 208 case used = 226 // Redirection case multipleChoices = 300 case movedPermanently = 301 case found = 302 case seeOther = 303 case notModified = 304 case useProxy = 305 case switchProxy = 306 case temporaryRedirect = 307 case permanentRedirect = 308 // Client errors case badRequest = 400 case unauthorized = 401 case paymentRequired = 402 case forbidden = 403 case notFound = 404 case methodNotAllowed = 405 case notAcceptable = 406 case proxyAuthenticationRequired = 407 case requestTimeout = 408 case conflict = 409 case gone = 410 case lengthRequired = 411 case preconditionFailed = 412 case payloadTooLarge = 413 case urlTooLong = 414 case unsupportedMediaType = 415 case rangeNotSatisfiable = 416 case expectationFailed = 417 case teapot = 418 case misdirectedRequest = 421 case unprocessableEntity = 422 case locked = 423 case failedDependency = 424 case tooEarly = 425 case upgradeRequired = 426 case preconditionRequired = 428 case tooManyRequests = 429 case requestHeaderFieldsTooLarge = 431 case unavailableForLegalReasons = 451 // Server errors case internalServerError = 500 case notImplemented = 501 case badGateway = 502 case serviceUnavailable = 503 case gatewayTimeout = 504 case httpVersionNotSupported = 505 case variantAlsoNegotiates = 506 case insufficientStorage = 507 case loopDetected = 508 case notExtended = 510 case networkAuthenticationRequired = 511 public var isSuccessStatusCode: Bool { switch self.rawValue { case 200..<300: return true default: return false } } }
Также Request.Error - наиболее тривиальная структура, которая содержит ответ сервера в случае возникновения ошибки.
import Foundation extension Request { public struct Error: LocalizedError, Codable { public var code: Int public var error: String? public var statusCode: HttpStatusCode? { return HttpStatusCode(rawValue: code) } public var errorDescription: String? { return error } } }
DataLoader наиболее часто используется. Поскольку с его помощью выполняются REST запросы, то предпочтительно, чтобы данный модуль еще и выполнял сериализацию/десериализацию запросов.
import Foundation public final class DataLoader: NetworkManager { @discardableResult public func dataRequest<T: Decodable>(url: URL, method: HTTPMethod, headers: [HTTPHeader] = [], parameters: Request.Parameters? = nil, decoder: JSONDecoder? = nil) async throws -> T { let data = try await dataRequest( url: url, method: method, headers: headers, parameters: parameters ) let decoder = decoder ?? JSONDecoder() return try decoder.decode(T.self, from: data) } @discardableResult public func dataRequest(url: URL, method: HTTPMethod, headers: [HTTPHeader] = [], parameters: Request.Parameters? = nil) async throws -> Data { let request = try Request( url: url, method: method, headers: headers, parameters: parameters ) let (data, response) = try await session.data(for: request.urlRequest) return try handle(response: response, content: data) } }
HTTPMethod - очередной объект содержащий константы, которые определяют метод запроса. Поскольку методы могут быть и нестандартными, для возможности расширения в качестве контейнера была выбрана структура вместо перечисления.
import Foundation public struct HTTPMethod: RawRepresentable, Equatable, Hashable { public let rawValue: String public init(rawValue: String) { self.rawValue = rawValue } } public extension HTTPMethod { static let connect = HTTPMethod(rawValue: "CONNECT") static let delete = HTTPMethod(rawValue: "DELETE") static let get = HTTPMethod(rawValue: "GET") static let head = HTTPMethod(rawValue: "HEAD") static let options = HTTPMethod(rawValue: "OPTIONS") static let patch = HTTPMethod(rawValue: "PATCH") static let post = HTTPMethod(rawValue: "POST") static let put = HTTPMethod(rawValue: "PUT") static let trace = HTTPMethod(rawValue: "TRACE") }
HTTPHeader - объект, который содержит информацию о header запроса. ��акже реализован в виде структуры, а не перечисления, поскольку необходима возможность расширения нестандартными значениями.
import Foundation public struct HTTPHeader: Hashable { public let name: String public let value: String public init(name: String, value: String) { self.name = name self.value = value } } extension HTTPHeader: CustomStringConvertible { public var description: String { "\(name): \(value)" } } public extension HTTPHeader { static func accept(_ value: String) -> HTTPHeader { HTTPHeader(name: "Accept", value: value) } static func acceptCharset(_ value: String) -> HTTPHeader { HTTPHeader(name: "Accept-Charset", value: value) } static func acceptLanguage(_ value: String) -> HTTPHeader { HTTPHeader(name: "Accept-Language", value: value) } static func acceptEncoding(_ value: String) -> HTTPHeader { HTTPHeader(name: "Accept-Encoding", value: value) } static func authorization(username: String, password: String) -> HTTPHeader { let credential = Data("\(username):\(password)".utf8).base64EncodedString() return authorization("Basic \(credential)") } static func authorization(bearerToken: String) -> HTTPHeader { authorization("Bearer \(bearerToken)") } static func authorization(_ value: String) -> HTTPHeader { HTTPHeader(name: "Authorization", value: value) } static func contentDisposition(_ value: String) -> HTTPHeader { HTTPHeader(name: "Content-Disposition", value: value) } static func contentType(_ value: String) -> HTTPHeader { HTTPHeader(name: "Content-Type", value: value) } static func contentLength(_ value: String) -> HTTPHeader { HTTPHeader(name: "Content-Length", value: value) } static func userAgent(_ value: String) -> HTTPHeader { HTTPHeader(name: "User-Agent", value: value) } } public extension HTTPHeader { static func qualityEncoded(_ encodings: [String]) -> String { return encodings.enumerated().map { index, encoding in let quality = 1.0 - (Double(index) * 0.1) return "\(encoding);q=\(quality)" } .joined(separator: ", ") } static let defaultAcceptEncoding: HTTPHeader = { let encodings = ["br", "gzip", "deflate"] let value = qualityEncoded(encodings) return .acceptEncoding(value) }() static let defaultAcceptLanguage: HTTPHeader = { let encodings = Array(Locale.preferredLanguages.prefix(6)) let value = qualityEncoded(encodings) return .acceptLanguage(value) }() static let defaultUserAgent: HTTPHeader = { let info = Bundle.main.infoDictionary let executable = (info?["CFBundleExecutable"] as? String) ?? (ProcessInfo.processInfo.arguments.first?.split(separator: "/").last.map(String.init)) ?? "Unknown" let bundle = info?["CFBundleIdentifier"] as? String ?? "Unknown" let appVersion = info?["CFBundleShortVersionString"] as? String ?? "Unknown" let appBuild = info?["CFBundleVersion"] as? String ?? "Unknown" let osNameVersion: String = { let version = ProcessInfo.processInfo.operatingSystemVersion let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" let osName: String = { #if os(iOS) #if targetEnvironment(macCatalyst) return "macOS(Catalyst)" #else return "iOS" #endif #elseif os(watchOS) return "watchOS" #elseif os(tvOS) return "tvOS" #elseif os(macOS) return "macOS" #elseif os(Linux) return "Linux" #elseif os(Windows) return "Windows" #else return "Unknown" #endif }() return "\(osName) \(versionString)" }() let userAgent = "\(executable)/\(appVersion) (\(bundle); build:\(appBuild); \(osNameVersion))" return .userAgent(userAgent) }() }
С параметрами запроса все несколько сложнее, так как они могут передаваться как в query строке, так и в body запроса. Поэтому структура Parameters конфигурирует то, как параметры будут передаваться, в то время как логика по формированию query или body расположена в объекте Query<T>.
import Foundation public extension Request { struct Parameters { // MARK: - Types public enum Destination { case query case body } // MARK: - Properties let destination: Destination let object: Any // MARK: - Methods public static func query(_ query: Query<String>, encoding: QueryEncoding? = nil) -> Parameters { let encoding = encoding ?? QueryEncoding() let object = (query, encoding) return Parameters(destination: .query, object: object) } public static func body<T: Encodable>(_ object: T, encoder: JSONEncoder? = nil) throws -> Parameters { let encoder = encoder ?? JSONEncoder() let data = try encoder.encode(object) return Parameters(destination: .body, object: data) } public static func body(_ data: Data) -> Parameters { return Parameters(destination: .body, object: data) } } }
Более подробно про структуру Query<T> рассказано в Swift. Сериализация параметров запроса, поэтому ниже представлен только source code объекта.
import Foundation public extension Request { struct Query<Key: Encodable> { // MARK: - Types public struct Parameter<K, V> { public var key: K public var value: V } // MARK: - Properties private var elements: [Element] // MARK: - Lifecycle init<S: Sequence>(uniqueKeysWithValues elements: S) where S.Element == (Key, Value) { self.elements = elements.map(Parameter.init) } // MARK: - Methods public subscript(key: Key) -> Value? where Key: Equatable { get { elements.first { $0.key == key }?.value } set { if let index = elements.firstIndex(where: { $0.key == key }) { if let newValue { elements[index].value = newValue } else { elements.remove(at: index) } } else { if let newValue { elements.append(Element(key: key, value: newValue)) } } } } } } // MARK: - Extensions extension Request.Query: ExpressibleByDictionaryLiteral { public typealias Value = any Encodable public init(dictionaryLiteral elements: (Key, Value)...) { self.elements = elements.map(Parameter.init) } } extension Request.Query: RangeReplaceableCollection { public init() { self.elements = [] } } extension Request.Query: Sequence { public typealias Iterator = IndexingIterator<Array<Element>> public func makeIterator() -> Iterator { return elements.makeIterator() } } extension Request.Query: Collection { public typealias Element = Parameter<Key, Value> public typealias Index = Int public var startIndex: Index { return elements.startIndex } public var endIndex: Int { return elements.endIndex } public subscript(position: Int) -> Element { return elements[position] } public func index(after i: Int) -> Int { return elements.index(after: i) } } extension Request.Query where Key == String { public func encode(to url: URL, encoding: QueryEncoding) -> URL? { var components = URLComponents(url: url, resolvingAgainstBaseURL: false) components?.queryItems = elements.flatMap { encodeQueryItem(element: $0, encoding: encoding) } return components?.url } private func encodeQueryItem(element: Element, encoding: QueryEncoding) -> [URLQueryItem] { encodeQueryItem(name: element.key, value: element.value, encoding: encoding) } private func encodeQueryItem(name: String, value: Any, encoding: QueryEncoding) -> [URLQueryItem] { switch value { case let boolean as Bool: let queryItem = encodeBool(name: name, value: boolean, encoding: encoding) return [queryItem] case let number as NSNumber: let queryItem = encodeNumber(name: name, value: number) return [queryItem] case let array as [Any]: let queryItems = encodeArray(name: name, value: array, encoding: encoding) return queryItems case let dictionary as [String: Any]: let queryItems = encodeDictionary(name: name, value: dictionary, encoding: encoding) return queryItems default: let queryItem = URLQueryItem(name: name, value: "\(value)") return [queryItem] } } private func encodeBool(name: String, value: Bool, encoding: QueryEncoding) -> URLQueryItem { let stringValue: String switch encoding.bool { case .numeric: stringValue = (value as NSNumber).stringValue case .literal: stringValue = String(value) } return URLQueryItem(name: name, value: stringValue) } private func encodeNumber(name: String, value: NSNumber) -> URLQueryItem { let stringValue = value.stringValue return URLQueryItem(name: name, value: stringValue) } private func encodeArray(name: String, value: [Any], encoding: QueryEncoding) -> [URLQueryItem] { switch encoding.array { case .enclosingBrackets: return value.flatMap { encodeQueryItem(name: name + "[]", value: $0, encoding: encoding) } case .surroundingBrackets: let value = value .flatMap { encodeQueryItem(name: name, value: $0, encoding: encoding) } .compactMap { $0.value } .map { "\"\($0)\"" } .joined(separator: ",") let queryItem = URLQueryItem(name: name, value: "[\(value)]") return [queryItem] case .noBrackets: return value.flatMap { encodeQueryItem(name: name, value: $0, encoding: encoding) } } } private func encodeDictionary(name: String, value: [String: Any], encoding: QueryEncoding) -> [URLQueryItem] { return value .map { encodeQueryItem(name: name + "[\($0)]", value: $1, encoding: encoding) } .flatMap { $0 } } } extension Request.Query: Encodable { public func encode(to encoder: Encoder) throws { if Key.self is CodingKeyRepresentable.Type { var container = encoder.container(keyedBy: QueryCodingKey.self) for element in elements { guard let key = element.key as? CodingKeyRepresentable else { continue } let codingKey = QueryCodingKey(key: key) try container.encode(element.value, forKey: codingKey) } } else { var container = encoder.unkeyedContainer() for element in elements { try container.encode(element.key) try container.encode(element.value) } } } }
QueryCodingKey - одна из зависимостей Query<T> структуры, с помощью которой присходит энкодинг any Encodable значения.
import Foundation struct QueryCodingKey: CodingKey { let stringValue: String let intValue: Int? init(stringValue: String) { self.stringValue = stringValue self.intValue = Int(stringValue) } init(intValue: Int) { self.stringValue = "\(intValue)" self.intValue = intValue } init(key: CodingKeyRepresentable) { self.stringValue = key.codingKey.stringValue self.intValue = key.codingKey.intValue } }
QueryEncoding - еще одна зависимость Query<T>, которая конфигурирует серилизацию query строки.
import Foundation public struct QueryEncoding { public enum ArrayEncoding { case enclosingBrackets case surroundingBrackets case noBrackets } public enum BoolEncoding { case numeric case literal } public var array: ArrayEncoding public var bool: BoolEncoding public init(array: QueryEncoding.ArrayEncoding = .enclosingBrackets, bool: QueryEncoding.BoolEncoding = .literal) { self.array = array self.bool = bool } }
И, наконец сам запрос. Является ничем иным, как оберткой над объектом URLRequest.
import Foundation public final class Request { // MARK: - Properties private(set) var urlRequest: URLRequest // MARK: - Lifecycle init(url: URL, method: HTTPMethod, headers: [HTTPHeader], parameters: Parameters?) throws { urlRequest = URLRequest(url: url) urlRequest.httpMethod = method.rawValue urlRequest.allHTTPHeaderFields = headers.reduce(into: [:]) { $0[$1.name] = $1.value } if let parameters { switch parameters.destination { case .query: guard let (query, encoding) = parameters.object as? (Query<String>, QueryEncoding) else { throw "Cannot resolve query object" } urlRequest.url = query.encode(to: url, encoding: encoding) case .body: guard let data = parameters.object as? Data else { throw "Cannot resolve data object" } urlRequest.httpBody = data } } } }
На этом ядро NetworkManager сформировано и готово к использованию.
Использование
Приведенный выше код формирует удобную для использования абстракцию, вследствие чего код читается и поддерживается намного легче.
extension DataLoader { func updateAccount(id: Int, name: String) async throws { let url = try createUrl(host: .staging, path: "api/v1/account/\(id)") let accessToken = try accessToken(for: .staging) let headers: [HTTPHeader] = [ .authorization("Bearer \(accessToken)") ] let parameters: Request.Query = [ "name": name ] try await dataRequest( url: url, method: .put, headers: headers, parameters: .query(parameters) ) } }
Реализация методов createUrl(host:path:) и accessToken(for:) выходит за рамки данной статьи, поэтому рассматриваться не будет.
Заключение
Вполне возможно, что Apple представят своего конкурента Alamofire. Тем более, что Alamofire имеет недостатки и в качестве примера стоит привести спагетти код и зависимость от других библиотек. В любом случае, наличие под рукой NetworkManager, реализованного с оглядкой на абстракцию, инкапсуляцию и наследование позволяет разработчику на мгновение откинуться в кресле и подумать над решением уже другой задачи.
