По крайней мере в последнее десятилетие, количество приложений, которым требуется доступ в интернет, неимоверно возросло. Причем для большинства проектов требуется только выполнение 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, реализованного с оглядкой на абстракцию, инкапсуляцию и наследование позволяет разработчику на мгновение откинуться в кресле и подумать над решением уже другой задачи.