По крайней мере в последнее десятилетие, количество приложений, которым требуется доступ в интернет, неимоверно возросло. Причем для большинства проектов требуется только выполнение REST запросов и загрузка изображений, с созданием preview. В связи с чем, необходим своего рода менеджер сетевых запросов для загрузки данных из сети. Далее будет представлен NetworkManager, с помощью которого выполняются REST запросы, а также загружаются файлы, в том числе и изображения.

Задача

Необходимо разработать менеджер сетевых запросов для выполнения REST запросов, загрузки и кэширования и создания preview изображений. При этом необходимо, чтобы была возможность обрабатывать появление/отсутствие соединения и конфигурировать кэширование. Решение должно использовать новую систему concurrency, представленную в iOS 13.

REST запросы

Ниже представлен базовый класс, в котором расположена логика по обработке ответа сервера (URLResponse), от которого будут наследоваться конкретные загрузчики (модули) и который зависит только от Foundation framework.

import Foundation

public class NetworkManager {
	
	// MARK: - Properties
	
	let session: URLSession
	let decoder: JSONDecoder
	
	// MARK: - Lifecycle
	
	public init(session: URLSession, decoder: JSONDecoder) {
		self.session = session
		self.decoder = decoder
	}
	
	// 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 decoder.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
		}
	}
}

DataLoader

Наиболее часто используется. Поскольку с его помощью выполняются REST запросы, то предпочтительно, чтобы данный модуль еще и выполнял сериализацию/десериализацию запросов.

import Foundation

public final class DataLoader: NetworkManager {
	@discardableResult
	public func dataRequest<T: Decodable>(url: UrlCreatable,
										  method: HTTPMethod,
										  headers: [HTTPHeader] = [],
										  parameters: Request.Parameters? = nil) async throws -> T {
		let data = try await dataRequest(
			url: url,
			method: method,
			headers: headers,
			parameters: parameters
		)
		
		return try decoder.decode(T.self, from: data)
	}
	
	@discardableResult
	public func dataRequest(url: UrlCreatable,
							method: HTTPMethod,
							headers: [HTTPHeader] = [],
							parameters: Request.Parameters? = nil) async throws -> Data {
		let url = try url.create()
		
		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)
	}
}

UrlCreatable — это интерфейс для создания URL, котрый реализуют типы String и URL.

import Foundation

public protocol UrlCreatable {
	func create() throws -> URL
}

extension String: UrlCreatable {
	public func create() throws -> URL {
		if let url = URL(string: self) {
			return url
		} else {
			throw "Cannot create url"
		}
	}
}

extension URL: UrlCreatable {
	public func create() throws -> URL {
		return self
	}
}

С HTTPMethod и HTTPHeader тоже все понятно — это также стандартный набор констант, используемых в запросе, отправляемом на сервер (URLRequest).

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)
	}()
}
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")
}

А вот создание самого запроса программировать уже несколько сложнее, так как нужно сериализовать параметры либо в query строку, либо в httpBody.

import AceExtensions
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 = parameters {
			try encode(parameters: parameters)
		}
	}
	
	// MARK: - Methods
	
	private func encode(parameters: Parameters) throws {
		switch parameters.configuration.destination {
		case .query(let arrayEncoding, let boolEncoding):
			try appendQuery(parameters: parameters, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding)
		case .httpBody:
			try addHttpBody(parameters: parameters)
		}
	}
	
	private func appendQuery(parameters: Parameters,
							 arrayEncoding: Parameters.Configuration.ArrayEncoding,
							 boolEncoding: Parameters.Configuration.BoolEncoding) throws {
		guard let dictionary = parameters.object as? [String: Any] else {
			throw "Query parameters is not [String: Any] dictionary"
		}
		
		guard let url = urlRequest.url,
			  var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
		else {
			throw "Cannot create url components from url request"
		}
		
		for (name, value) in dictionary {
			let items = createQueryItems(
				name: name,
				value: value,
				arrayEncoding: arrayEncoding,
				boolEncoding: boolEncoding
			)
			
			var queryItems = components.queryItems ?? []
			queryItems.append(contentsOf: items)
			components.queryItems = queryItems
		}
		
		if let url = components.url {
			urlRequest.url = url
		}
		
		if parameters.configuration.updateHeaders {
			var headers = urlRequest.allHTTPHeaderFields ?? [:]
			headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"
			urlRequest.allHTTPHeaderFields = headers
		}
	}
	
	private func addHttpBody(parameters: Parameters) throws {
		if let data = parameters.object as? Data {
			urlRequest.httpBody = data
		} else if JSONSerialization.isValidJSONObject(parameters.object) {
			let data = try JSONSerialization.data(withJSONObject: parameters.object, options: .fragmentsAllowed)
			urlRequest.httpBody = data
		}
		
		if parameters.configuration.updateHeaders {
			var headers = urlRequest.allHTTPHeaderFields ?? [:]
			headers["Content-Type"] = "application/json; charset=utf-8"
			urlRequest.allHTTPHeaderFields = headers
		}
	}
	
	private func createQueryItems(name: String,
								  value: Any,
								  arrayEncoding: Parameters.Configuration.ArrayEncoding,
								  boolEncoding: Parameters.Configuration.BoolEncoding) -> [URLQueryItem] {
		switch value {
		case let boolean as Bool:
			let value = encodeQuery(boolean: boolean, encoding: boolEncoding)
			let queryItem = URLQueryItem(name: name, value: value)
			return [queryItem]
		case let number as NSNumber:
			let queryItem = URLQueryItem(name: name, value: number.stringValue)
			return [queryItem]
		case let array as [Any]:
			let name = encodeQuery(array: name, encoding: arrayEncoding)
			let queryItems = array.flatMap { value in
				createQueryItems(name: name, value: value, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding)
			}
			return queryItems
		case let dictionary as [String: Any]:
			let queryItems: [[URLQueryItem]] = dictionary.map { key, value in
				let name = name + "[\(key)]"
				return createQueryItems(name: name, value: value, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding)
			}
			return queryItems.flatMap { $0 }
		default:
			let queryItem = URLQueryItem(name: name, value: "\(value)")
			return [queryItem]
		}
	}
	
	private func encodeQuery(array name: String, encoding: Parameters.Configuration.ArrayEncoding) -> String {
		switch encoding {
		case .brackets:
			return name + "[]"
		case .noBrackets:
			return name
		}
	}
	
	func encodeQuery(boolean value: Bool, encoding: Parameters.Configuration.BoolEncoding) -> String {
		switch encoding {
		case .numeric:
			return (value as NSNumber).stringValue
		case .literal:
			return String(value)
		}
	}
}

Может показаться, что работа с параметрами запроса несколько запутана, но на деле это набор ключ: значение, сериализованные по определенным правилам, заданным в структуре Parameters.

import Foundation

public extension Request {
	struct Parameters {
		
		// MARK: - Types
		
		public struct Configuration {
			public enum Destination {
				case query(arrayEncoding: ArrayEncoding, boolEncoding: BoolEncoding)
				case httpBody
			}
			
			public enum ArrayEncoding {
				case brackets
				case noBrackets
			}
			
			public enum BoolEncoding {
				case numeric
				case literal
			}
			
			public var destination: Destination
			public var updateHeaders: Bool
			
			public static let `defaultQuery` = Configuration(
				destination: .query(arrayEncoding: .brackets, boolEncoding: .literal),
				updateHeaders: true
			)
			
			public static let `defaultJson` = Configuration(
				destination: .httpBody,
				updateHeaders: true
			)
		}
		
		// MARK: - Properties
		
		public let object: Any
		public let configuration: Configuration
		
		// MARK: - Lifecycle
		
		public init(object: [String: Any], configuration: Configuration = .defaultQuery) {
			self.object = object
			self.configuration = configuration
		}
		
		public init<T: Encodable>(object: T, configuration: Configuration = .defaultJson) throws {
			self.object = try JSONEncoder().encode(object)
			self.configuration = configuration
		}
		
		public init(object: Data, configuration: Configuration) throws {
			self.object = object
			self.configuration = configuration
		}
	}
}

// MARK: - ExpressibleByDictionaryLiteral

extension Request.Parameters: ExpressibleByDictionaryLiteral {
	public init(dictionaryLiteral elements: (String, Any)...) {
		let dictionary = Dictionary(uniqueKeysWithValues: elements)
		let object = (dictionary as [String: Any?]).compactMapValues { $0 }
		self.init(object: object, configuration: .defaultQuery)
	}
}

На этом ядро NetworkManager готово. В очередной раз обращаю внимание, что ядро не зависит от UI фреймворков, а значит легче поддерживается.

FileLoader

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

import Foundation

public final class FileLoader: NetworkManager {
	
	public init(session: URLSession) {
		super.init(session: session, decoder: JSONDecoder())
	}
	
	@discardableResult
	public func downloadRequest(url: UrlCreatable,
								method: HTTPMethod,
								headers: [HTTPHeader] = [],
								parameters: Request.Parameters? = nil) async throws -> URL {
		let url = try url.create()
		
		let request = try Request(
			url: url,
			method: method,
			headers: headers,
			parameters: parameters
		)
		
		let (fileUrl, response) = try await session.download(for: request.urlRequest)
		return try handle(response: response, content: fileUrl)
	}
}

ImageLoader

С загрузкой изображений все несколько сложнее, поскольку необходимо добавить логику кэширования и создания preview. Но, очень важно, чтобы эта логика выполнялась в потоке, параллельном main, иначе возможны тормоза в работе UI. По этой причине так называемый ImageLoader является actor, поскольку будет использоваться Task данного actor, а не MainActor. Также, поскольку уже идет работа с изображениями, добавлена зависимость от UI фреймворка — SwiftUI.

import SwiftUI

public actor ImageLoader {
	
	// MARK: - Properties
	
	private let fileLoader: FileLoader
	private let storage = ImagesStorage()
	
	// MARK: - Lifecycle
	
	public init(session: URLSession) {
		fileLoader = FileLoader(session: session)
	}
	
	// MARK: - Methods
	
	public func loadImage(at url: URL) async -> UIImage? {
		if let image = storage.loadObject(for: url) {
			return image
		} else {
			let image: UIImage? = try? await downloadImage(url: url)
			storage.save(object: image, for: url)
			return image
		}
	}
	
	public func createThumb(of image: UIImage?, size: CGSize) async -> UIImage? {
		guard let image = image else {
			return nil
		}
		
		let ratio = image.size.width / image.size.height
		let width: CGFloat
		let height: CGFloat
		
		if image.size.width > image.size.height {
			width = size.width
			height = width / ratio
		} else {
			height = size.height
			width = ratio * height
		}

		let size = CGSize(width: width, height: height)
		return await image.byPreparingThumbnail(ofSize: size)
	}
	
	public func fileUrl(forImageAt url: URL) async -> URL? {
		return storage.fileUrl(for: url)
	}
	
	@discardableResult
	public func downloadImage(url: UrlCreatable,
							  method: HTTPMethod = .get,
							  headers: [HTTPHeader] = [],
							  parameters: Request.Parameters? = nil) async throws -> Image? {
		let uiImage: UIImage? = try await downloadImage(
			url: url,
			method: method,
			headers: headers,
			parameters: parameters
		)
		
		if let uiImage = uiImage {
			return Image(uiImage: uiImage)
		} else {
			return nil
		}
	}
	
	@discardableResult
	public func downloadImage(url: UrlCreatable,
							  method: HTTPMethod = .get,
							  headers: [HTTPHeader] = [],
							  parameters: Request.Parameters? = nil) async throws -> UIImage? {
		let fileUrl = try await fileLoader.downloadRequest(
			url: url,
			method: method,
			headers: headers,
			parameters: parameters
		)
		
		return UIImage(contentsOfFile: fileUrl.path)
	}
}

Если с созданием preview все понятно — достаточно вызвать метод byPreparingThumbnail(ofSize:), то с логикой кэширования нужно разобраться. Ведь использовать NSCache<KeyType, ObjectType> ошибочно, так как изображение и так будет сохранено в ОЗУ, при наполнении модели. А вот для того, чтобы изображения были кэшированы в постоянную память, требуется некоторый ImagesStorage объект, сохраняющий в постоянную память и загружающий из неё.

import Foundation

public protocol NetworkCache {
	associatedtype Item
	func save(object: Item?, for requestUrl: URL)
	func loadObject(for requestUrl: URL) -> Item?
	func hasCache(for requestUrl: URL) -> Bool
}
import UIKit

public final class ImagesStorage: CachedStorage, NetworkCache {
	
	// MARK: - Methods
	
	public func save(object: UIImage?, for requestUrl: URL) {
		let data = object?.pngData()
		let url = fileUrl(for: requestUrl)
		save(data: data, at: url)
	}
	
	public func loadObject(for requestUrl: URL) -> UIImage? {
		let url = fileUrl(for: requestUrl)
		
		if let data = loadData(at: url) {
			return UIImage(data: data)
		} else {
			return nil
		}
	}
	
	public func hasCache(for requestUrl: URL) -> Bool {
		let url = fileUrl(for: requestUrl)
		return hasCache(at: url)
	}
	
	public func fileUrl(for requestUrl: URL) -> URL {
		let url = cachesUrl()
		let className = String(describing: Self.self)
		let hash = (requestUrl.absoluteString as NSString).hash
		
		return url
			.appendingPathComponent(className)
			.appendingPathComponent(String(hash))
			.appendingPathComponent(requestUrl.lastPathComponent)
	}
}

import Foundation

public class CachedStorage {

	// MARK: - Properties
	
	private let fileManager: FileManager = .default

	// MARK: - Methods
	
	public func save(data: Data?, at url: URL) {
		let directoryUrl = url.deletingLastPathComponent()
		
		if !directoryExistsAtPath(directoryUrl.path) {
			createCacheDirectory(at: directoryUrl)
		}
		
		fileManager.createFile(atPath: url.path, contents: data, attributes: nil)
	}
	
	public func loadData(at url: URL) -> Data? {
		return try? Data(contentsOf: url)
	}
	
	public func hasCache(at url: URL) -> Bool {
		return fileManager.fileExists(atPath: url.path)
	}
	
	private func directoryExistsAtPath(_ path: String) -> Bool {
		var isDirectory = ObjCBool(true)
		let exists = fileManager.fileExists(atPath: path, isDirectory: &isDirectory)
		return exists && isDirectory.boolValue
	}
	
	private func createCacheDirectory(at url: URL) {
		do {
			try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
		} catch {
			assertionFailure("Cache directory not created: \(error.localizedDescription)")
		}
	}
	
	public func cachesUrl() -> URL {
		let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)
		
		if let url = urls.first {
			return url
		} else {
			return fileManager.temporaryDirectory
		}
	}
}

Внимательного читателя может удивить использование hash свойства NSString вместо стандартного md5 hash. В этом месте разработчик позволил себе сделать паузу и выпить чашечку хорошего кофе, после чего запрограммировал наиболее простое решение.

Этот объект унаследован от CachedStorage, который сохраняет файлы в постоянную память и загружает из неё и реализует NetworkCache интерфейс.

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

Сначала необходимо сконфигурировать URLSession.

import Foundation

extension URLSession {
	static let `default`: URLSession = {
		let configuration = URLSessionConfiguration.default
		configuration.waitsForConnectivity = true
		configuration.timeoutIntervalForResource = configuration.timeoutIntervalForRequest
		return URLSession(configuration: configuration)
	}()
	
	static let background: URLSession = {
		let configuration = URLSessionConfiguration.background(withIdentifier: "background-session")
		return URLSession(configuration: configuration)
	}()
}

Далее объявить несколько вспомогательных методов, которые удовлетворяют требованиям проекта.

extension DataLoader {
	convenience init(session: URLSession = .default) {
		let decoder = JSONDecoder()
		decoder.dateDecodingStrategy = .iso8601
		
		self.init(session: session, decoder: decoder)
	}
	
	private func baseUrl() throws -> URL {
		guard let host = Bundle.main.object(forInfoDictionaryKey: "Host") as? String else {
			throw "Host variable is not secified"
		}
		
		guard let url = URL(string: host) else {
			throw "Cannot create url"
		}
		
		return url.appendingPathComponent("swagger")
	}
	
	private var accessToken: String {
		let secureSettings = SecureSettings()
		return secureSettings.accessToken ?? ""
	}
	
	@discardableResult
	public func dataRequest(path: String,
							method: HTTPMethod,
							headers: [HTTPHeader] = [],
							parameters: Request.Parameters? = nil) async throws -> Data {
		let url = try baseUrl().appendingPathComponent(path)
		
		return try await dataRequest(
			url: url,
			method: method,
			headers: headers,
			parameters: parameters
		)
	}
	
	@discardableResult
	public func dataRequest<T: Decodable>(path: String,
										  method: HTTPMethod,
										  headers: [HTTPHeader] = [],
										  parameters: Request.Parameters? = nil) async throws -> T {
		let url = try baseUrl().appendingPathComponent(path)
		
		return try await dataRequest(
			url: url,
			method: method,
			headers: headers,
			parameters: parameters
		)
	}
}

И, наконец, объявить REST методы.

extension DataLoader {
 func login(userName: String, password: String) async throws -> LoginResponse {
		return try await dataRequest(
			path: "auth/login",
			method: .get,
			parameters: ["userName": username, "password": password]
		)
	} 
}

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

class TestViewController: UIViewController {
  func loadPreviews(of news: [NewsViewModel]) async {
    await withTaskGroup(of: Void.self) { group in
      for news in news {
        group.addTask { [weak self] in
          await self?.loadImage(of: news)
        }
      }
    }
  }

  private func loadImage(of news: NewsViewModel) async {
    guard let url = news.underlyingModel.preview else {
      return
    }

    if let image = await imageLoader.loadImage(at: url) {
      news.preview = Image(uiImage: image)
    }
  } 
}

Заключение

Вполне возможно, что Apple представят своего конкурента Alamofire. Тем более, что Alamofire имеет недостатки и в качестве примера стоит привести спагетти код. В любом случае иметь под рукой такой немаловажный инструмент вовсе не будет лишним.

  1. URLSession

  2. URLRequest

  3. URLResponse

  4. Alamofire

Комментарии (2)


  1. ws233
    11.07.2022 07:59

    Цитата по ссылке:

    "Пример NSURLCache в очередной раз показывает нам, насколько важно знать возможности системы, с которой работаешь.

    Многие разработчики изобретают свой велосипед для организации кеширования, так как не знают о возможностях NSURLCache, инициализация которого занимает всего 2 строчки кода и делает работу в 100 раз эффективнее. Еще большее число вообще не задумывается о преимуществах сетевого кеширования и не использует его, нагружая свой сервер огромным количеством ненужных запросов."

    https://habr.com/ru/post/179349/


  1. thomasR
    11.07.2022 20:50

    Нативная реализация запросов с использованием возможностей языка - это конечно сильно.
    Но как всегда в сфере программирования ищутся пути побыстрее.
    В своей практике использую связку Alamofire + ObjectMapper + PromiseKit - и асинхронность и удобная де/сеарилизация и общая организация вызовов.