diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 31dcc48c..fbc5bfa5 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -18,12 +18,18 @@ 23EA9CF9292FB70A00B8E418 /* SampleUserResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CEF292FB70A00B8E418 /* SampleUserResponse.swift */; }; 23EA9CFA292FB70A00B8E418 /* SampleUserAuthRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CF1292FB70A00B8E418 /* SampleUserAuthRequest.swift */; }; 23EA9CFB292FB70A00B8E418 /* SampleUserRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CF2292FB70A00B8E418 /* SampleUserRequest.swift */; }; + 58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */; }; + 58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */; }; + 58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */; }; + 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */; }; 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E4E0EC2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift */; }; 58E4E0EF29843B42000ACBC0 /* NetworkingSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E4E0EE29843B42000ACBC0 /* NetworkingSampleApp.swift */; }; 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E4E0F029850E86000ACBC0 /* ContentView.swift */; }; 58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FB80C6298521FF0031FC59 /* AuthorizationView.swift */; }; 58FB80CE29895ABF0031FC59 /* TestData.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 58FB80CD29895ABF0031FC59 /* TestData.xcassets */; }; DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */; }; + DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */; }; + DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */; }; DD887780293E33850065ED03 /* SampleErrorProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88777F293E33850065ED03 /* SampleErrorProcessor.swift */; }; DDD3AD1F2950E794006CB777 /* SampleAuthRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD3AD1E2950E794006CB777 /* SampleAuthRouter.swift */; }; DDD3AD212951F527006CB777 /* SampleAuthorizationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD3AD202951F527006CB777 /* SampleAuthorizationManager.swift */; }; @@ -45,12 +51,18 @@ 23EA9CEF292FB70A00B8E418 /* SampleUserResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUserResponse.swift; sourceTree = ""; }; 23EA9CF1292FB70A00B8E418 /* SampleUserAuthRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUserAuthRequest.swift; sourceTree = ""; }; 23EA9CF2292FB70A00B8E418 /* SampleUserRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUserRequest.swift; sourceTree = ""; }; + 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; + 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsViewModel.swift; sourceTree = ""; }; + 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleDownloadRouter.swift; sourceTree = ""; }; + 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProgressViewModel.swift; sourceTree = ""; }; 58E4E0EC2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleAuthorizationStorageManager.swift; sourceTree = ""; }; 58E4E0EE29843B42000ACBC0 /* NetworkingSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingSampleApp.swift; sourceTree = ""; }; 58E4E0F029850E86000ACBC0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 58FB80C6298521FF0031FC59 /* AuthorizationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationView.swift; sourceTree = ""; }; 58FB80CD29895ABF0031FC59 /* TestData.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = TestData.xcassets; sourceTree = ""; }; DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationViewModel.swift; sourceTree = ""; }; + DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadProgressView.swift; sourceTree = ""; }; + DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadAPIManager+SharedInstance.swift"; sourceTree = ""; }; DD88777F293E33850065ED03 /* SampleErrorProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleErrorProcessor.swift; sourceTree = ""; }; DDD3AD1E2950E794006CB777 /* SampleAuthRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleAuthRouter.swift; sourceTree = ""; }; DDD3AD202951F527006CB777 /* SampleAuthorizationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleAuthorizationManager.swift; sourceTree = ""; }; @@ -91,6 +103,7 @@ 23A575B325F8B9DA00617551 /* NetworkingSampleApp */ = { isa = PBXGroup; children = ( + DD6E48742A0E2CC70025AD05 /* Extensions */, 58FB80CC29895A8D0031FC59 /* Resources */, 23EA9CE7292FB70A00B8E418 /* API */, 23A575ED25F8BF0E00617551 /* Scenes */, @@ -113,6 +126,7 @@ 23A575ED25F8BF0E00617551 /* Scenes */ = { isa = PBXGroup; children = ( + 58C3E75B29B78ED3004FD1CD /* Download */, 58FB80C5298521DA0031FC59 /* Authorization */, ); path = Scenes; @@ -153,6 +167,7 @@ isa = PBXGroup; children = ( DDD3AD1E2950E794006CB777 /* SampleAuthRouter.swift */, + 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */, 23EA9CE9292FB70A00B8E418 /* SampleUserRouter.swift */, ); path = Routers; @@ -178,6 +193,17 @@ path = Requests; sourceTree = ""; }; + 58C3E75B29B78ED3004FD1CD /* Download */ = { + isa = PBXGroup; + children = ( + DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */, + 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */, + 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */, + 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */, + ); + path = Download; + sourceTree = ""; + }; 58FB80C5298521DA0031FC59 /* Authorization */ = { isa = PBXGroup; children = ( @@ -195,6 +221,14 @@ path = Resources; sourceTree = ""; }; + DD6E48742A0E2CC70025AD05 /* Extensions */ = { + isa = PBXGroup; + children = ( + DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */, + ); + path = Extensions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -271,21 +305,27 @@ buildActionMask = 2147483647; files = ( 58E4E0EF29843B42000ACBC0 /* NetworkingSampleApp.swift in Sources */, + DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */, DDE8884529476AC300DD3BFF /* SampleRefreshTokenRequest.swift in Sources */, DDD3AD212951F527006CB777 /* SampleAuthorizationManager.swift in Sources */, 23EA9CF7292FB70A00B8E418 /* SampleUserAuthResponse.swift in Sources */, 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */, 23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */, 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */, + 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */, 23EA9CF9292FB70A00B8E418 /* SampleUserResponse.swift in Sources */, DDD3AD1F2950E794006CB777 /* SampleAuthRouter.swift in Sources */, DD887780293E33850065ED03 /* SampleErrorProcessor.swift in Sources */, 23EA9CFB292FB70A00B8E418 /* SampleUserRequest.swift in Sources */, 23EA9CFA292FB70A00B8E418 /* SampleUserAuthRequest.swift in Sources */, + 58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */, 23EA9CF4292FB70A00B8E418 /* SampleUserRouter.swift in Sources */, 23EA9CF5292FB70A00B8E418 /* SampleAPIConstants.swift in Sources */, 58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */, DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */, + 58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */, + 58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */, + DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */, 23EA9CF8292FB70A00B8E418 /* SampleUsersResponse.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleDownloadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleDownloadRouter.swift new file mode 100644 index 00000000..850f4794 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleDownloadRouter.swift @@ -0,0 +1,28 @@ +// +// SampleDownloadRouter.swift +// +// +// Created by Matej Molnár on 07.03.2023. +// + +import Foundation +import Networking + +/// Implementation of sample API router +enum SampleDownloadRouter: Requestable { + case download(url: URL) + + var baseURL: URL { + switch self { + case let .download(url): + return url + } + } + + var path: String { + switch self { + case .download: + return "" + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift index ea15a586..1db7331c 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift @@ -13,4 +13,5 @@ enum SampleAPIConstants { static let authHost = "https://nonexistentmockauth.com/api" static let validEmail = "eve.holt@reqres.in" static let validPassword = "cityslicka" + static let videoUrl = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" } diff --git a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift index 92bb4936..9238bea9 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift @@ -7,23 +7,26 @@ import SwiftUI -enum NetworkingCase: String, Hashable, CaseIterable { +enum NetworkingFeature: String, Hashable, CaseIterable { case authorization + case downloads } struct ContentView: View { var body: some View { NavigationStack { List { - ForEach(NetworkingCase.allCases, id: \.self) { screen in - NavigationLink(screen.rawValue.capitalized, value: NetworkingCase.authorization) + ForEach(NetworkingFeature.allCases, id: \.self) { feature in + NavigationLink(feature.rawValue.capitalized, value: feature) } } .navigationTitle("Examples") - .navigationDestination(for: NetworkingCase.self) { screen in - switch screen { + .navigationDestination(for: NetworkingFeature.self) { feature in + switch feature { case .authorization: AuthorizationView() + case .downloads: + DownloadsView() } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift new file mode 100644 index 00000000..0364dacb --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift @@ -0,0 +1,32 @@ +// +// DownloadAPIManager+SharedInstance.swift +// NetworkingSampleApp +// +// Created by Dominika Gajdová on 12.05.2023. +// + +import Networking + +extension DownloadAPIManager { + static var shared: DownloadAPIManaging = { + var responseProcessors: [ResponseProcessing] = [ + LoggingInterceptor.shared, + StatusCodeProcessor.shared + ] + var errorProcessors: [ErrorProcessing] = [LoggingInterceptor.shared] + + #if DEBUG + responseProcessors.append(EndpointRequestStorageProcessor.shared) + errorProcessors.append(EndpointRequestStorageProcessor.shared) + #endif + + return DownloadAPIManager( + urlSessionConfiguration: .default, + requestAdapters: [ + LoggingInterceptor.shared + ], + responseProcessors: responseProcessors, + errorProcessors: errorProcessors + ) + }() +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Info.plist b/NetworkingSampleApp/NetworkingSampleApp/Info.plist index f34a3882..22ba9f38 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Info.plist +++ b/NetworkingSampleApp/NetworkingSampleApp/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift new file mode 100644 index 00000000..162f9f92 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift @@ -0,0 +1,75 @@ +// +// DownloadProgressView.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 07.03.2023. +// + +import SwiftUI + +struct DownloadProgressView: View { + @StateObject var viewModel: DownloadProgressViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + content + } + .task { + await viewModel.startObservingDownloadProgress() + } + .padding(10) + .background( + Color.white + .cornerRadius(15) + .shadow(radius: 10) + ) + .padding(15) + } +} + +// MARK: Components +private extension DownloadProgressView { + @ViewBuilder + var content: some View { + Text(viewModel.state.title) + .padding(.bottom, 8) + + Text("Status: \(viewModel.state.statusTitle)") + Text("\(String(format: "%.1f", viewModel.state.percentCompleted))% of \(String(format: "%.1f", viewModel.state.totalMegaBytes))MB") + + if let errorTitle = viewModel.state.errorTitle { + Text("Error: \(errorTitle)") + } + + if let fileURL = viewModel.state.fileURL { + Text("FileURL: \(fileURL)") + } + + downloadState + } + + @ViewBuilder + var downloadState: some View { + if viewModel.state.status != .completed { + HStack { + Button { + viewModel.suspend() + } label: { + Text("Suspend") + } + + Button { + viewModel.resume() + } label: { + Text("Resume") + } + + Button { + viewModel.cancel() + } label: { + Text("Cancel") + } + } + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift new file mode 100644 index 00000000..c1b0885f --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift @@ -0,0 +1,72 @@ +// +// DownloadProgressViewModel.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 07.03.2023. +// + +import Foundation +import Networking + +@MainActor +final class DownloadProgressViewModel: ObservableObject { + private let task: URLSessionTask + + @Published var state: DownloadProgressState = .init() + + init(task: URLSessionTask) { + self.task = task + } + + func startObservingDownloadProgress() async { + let stream = DownloadAPIManager.shared.progressStream(for: task) + + for try await downloadState in stream { + var newState = DownloadProgressState() + newState.percentCompleted = downloadState.fractionCompleted * 100 + newState.totalMegaBytes = Double(downloadState.totalBytes) / 1_000_000 + newState.status = downloadState.taskState + newState.statusTitle = downloadState.taskState.title + newState.errorTitle = downloadState.error?.localizedDescription + newState.fileURL = downloadState.downloadedFileURL?.absoluteString + newState.title = task.currentRequest?.url?.absoluteString ?? "-" + state = newState + } + } + + func suspend() { + task.suspend() + } + + func resume() { + task.resume() + } + + func cancel() { + task.cancel() + } +} + +// MARK: Download state +struct DownloadProgressState { + var title: String = "" + var status: URLSessionTask.State = .running + var statusTitle: String = "" + var percentCompleted: Double = 0 + var totalMegaBytes: Double = 0 + var errorTitle: String? + var fileURL: String? +} + +// MARK: URLSessionTask states +private extension URLSessionTask.State { + var title: String { + switch self { + case .canceling: return "cancelling" + case .completed: return "completed" + case .running: return "running" + case .suspended: return "suspended" + @unknown default: return "" + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift new file mode 100644 index 00000000..abd14cf5 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift @@ -0,0 +1,40 @@ +// +// DownloadsView.swift +// +// +// Created by Matej Molnár on 07.03.2023. +// + +import SwiftUI +import Networking + +struct DownloadsView: View { + @StateObject private var viewModel = DownloadsViewModel() + + var body: some View { + VStack { + HStack { + TextField("File URL", text: $viewModel.urlText, axis: .vertical) + .textFieldStyle(.roundedBorder) + + Button { + viewModel.startDownload() + } label: { + Text("Download") + } + .buttonStyle(.bordered) + } + .padding(.horizontal, 15) + + ScrollView { + LazyVStack { + ForEach(viewModel.tasks, id: \.taskIdentifier) { task in + DownloadProgressView(viewModel: .init(task: task)) + } + } + .padding(.vertical, 5) + } + } + .navigationTitle("Downloads") + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift new file mode 100644 index 00000000..ba616989 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -0,0 +1,43 @@ +// +// DownloadsViewModel.swift +// +// +// Created by Matej Molnár on 07.03.2023. +// + +import Foundation +import Networking +import OSLog + +@MainActor +final class DownloadsViewModel: ObservableObject { + @Published var tasks: [URLSessionTask] = [] + @Published var urlText: String = SampleAPIConstants.videoUrl + private let downloadAPIManager = DownloadAPIManager.shared + + func startDownload() { + Task { + await downloadItem() + } + } +} + +private extension DownloadsViewModel { + func downloadItem() async { + guard let url = URL(string: urlText) else { + return + } + + do { + let (task, _) = try await downloadAPIManager.downloadRequest( + SampleDownloadRouter.download(url: url), + resumableData: nil, + retryConfiguration: RetryConfiguration.default + ) + + tasks.append(task) + } catch { + os_log("❌ DownloadAPIManager failed to download \(self.urlText) with error: \(error.localizedDescription)") + } + } +} diff --git a/Sources/Networking/Core/APIManager.swift b/Sources/Networking/Core/APIManager.swift index 17c58721..481a2587 100644 --- a/Sources/Networking/Core/APIManager.swift +++ b/Sources/Networking/Core/APIManager.swift @@ -8,13 +8,13 @@ import Foundation /// Default API manager -open class APIManager: APIManaging { +open class APIManager: APIManaging, Retryable { private let requestAdapters: [RequestAdapting] private let responseProcessors: [ResponseProcessing] private let errorProcessors: [ErrorProcessing] private let responseProvider: ResponseProviding private let sessionId: String - private var retryCounter = Counter() + internal var retryCounter = Counter() public init( urlSession: URLSession = .init(configuration: .default), @@ -52,6 +52,7 @@ open class APIManager: APIManaging { } } +// MARK: Private private extension APIManager { func request(_ endpointRequest: EndpointRequest, retryConfiguration: RetryConfiguration?) async throws -> Response { do { @@ -82,32 +83,4 @@ private extension APIManager { } } } - - /// Handle if error triggers retry mechanism and return delay for next attempt - private func sleepIfRetry(for error: Error, endpointRequest: EndpointRequest, retryConfiguration: RetryConfiguration?) async throws { - let retryCount = await retryCounter.count(for: endpointRequest.id) - - guard - let retryConfiguration = retryConfiguration, - retryConfiguration.retryHandler(error), - retryConfiguration.retries > retryCount - else { - /// reset retry count - await retryCounter.reset(for: endpointRequest.id) - throw error - } - - /// count the delay for retry - await retryCounter.increment(for: endpointRequest.id) - - var sleepDuration: UInt64 - switch retryConfiguration.delay { - case .constant(let timeInterval): - sleepDuration = UInt64(timeInterval) * 1000000000 - case .progressive(let timeInterval): - sleepDuration = UInt64(timeInterval) * UInt64(retryCount) * 1000000000 - } - - try await Task.sleep(nanoseconds: sleepDuration) - } } diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift new file mode 100644 index 00000000..1f0d8914 --- /dev/null +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -0,0 +1,214 @@ +// +// DownloadAPIManager.swift +// +// +// Created by Matej Molnár on 07.03.2023. +// + +import Foundation +import Combine + +/// Default Download API manager +open class DownloadAPIManager: NSObject, Retryable { + private let requestAdapters: [RequestAdapting] + private let responseProcessors: [ResponseProcessing] + private let errorProcessors: [ErrorProcessing] + private let sessionId: String + private let downloadStateDictSubject = CurrentValueSubject<[URLSessionTask: URLSessionTask.DownloadState], Never>([:]) + private var urlSession = URLSession(configuration: .default) + private var taskStateCancellables = ThreadSafeDictionary() + private var downloadStateDict = ThreadSafeDictionary() + + internal var retryCounter = Counter() + + public var allTasks: [URLSessionDownloadTask] { + get async { + await urlSession.allTasks.compactMap { $0 as? URLSessionDownloadTask } + } + } + + public init( + urlSessionConfiguration: URLSessionConfiguration = .default, + requestAdapters: [RequestAdapting] = [], + responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], + errorProcessors: [ErrorProcessing] = [] + ) { + /// generate session id in readable format + sessionId = Date().ISO8601Format() + + self.requestAdapters = requestAdapters + self.responseProcessors = responseProcessors + self.errorProcessors = errorProcessors + + super.init() + + urlSession = URLSession( + configuration: urlSessionConfiguration, + delegate: self, + delegateQueue: OperationQueue() + ) + } +} + +// MARK: Public API +extension DownloadAPIManager: DownloadAPIManaging { + public func invalidateSession(shouldFinishTasks: Bool = false) { + if shouldFinishTasks { + urlSession.invalidateAndCancel() + } else { + urlSession.finishTasksAndInvalidate() + } + } + + public func downloadRequest( + _ endpoint: Requestable, + resumableData: Data? = nil, + retryConfiguration: RetryConfiguration? + ) async throws -> DownloadResult { + /// create identifiable request from endpoint + let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + return try await downloadRequest(endpointRequest, resumableData: resumableData, retryConfiguration: retryConfiguration) + } + + /// Creates an async stream of download state updates for a given task. + /// Each time an update is received from the `URLSessionDownloadDelegate`, the async stream emits a new download state. + public func progressStream(for task: URLSessionTask) -> AsyncStream { + AsyncStream { continuation in + let cancellable = downloadStateDictSubject + .sink(receiveValue: { dict in + guard let downloadState = dict[task] else { + return + } + + continuation.yield(downloadState) + + if downloadState.error != nil || downloadState.downloadedFileURL != nil { + continuation.finish() + } + }) + + continuation.onTermination = { _ in + cancellable.cancel() + } + } + } +} + +// MARK: Private +private extension DownloadAPIManager { + func downloadRequest( + _ endpointRequest: EndpointRequest, + resumableData: Data?, + retryConfiguration: RetryConfiguration? + ) async throws -> DownloadResult { + do { + /// create original url request + let originalRequest = try endpointRequest.endpoint.asRequest() + + /// adapt request with all adapters + let request = try await requestAdapters.adapt(originalRequest, for: endpointRequest) + + /// create URLSessionDownloadTask with resumableData if available otherwise with URLRequest + let downloadTask = { + if let resumableData { + return urlSession.downloadTask(withResumeData: resumableData) + } else { + return urlSession.downloadTask(with: request) + } + }() + + /// downloadTask must be initiated by resume() before we try to await a response from downloadObserver, because it gets the response from URLSessionDownloadDelegate methods + downloadTask.resume() + + updateTasks() + + let urlResponse = try await downloadTask.asyncResponse() + + /// process response + let response = try await responseProcessors.process((Data(), urlResponse), with: request, for: endpointRequest) + + /// reset retry count + await retryCounter.reset(for: endpointRequest.id) + + /// create download AsyncStream + return (downloadTask, response) + } catch { + do { + /// If retry fails (retryCount is 0 or Task.sleep thrown), catch the error and process it with `ErrorProcessing` plugins. + try await sleepIfRetry( + for: error, + endpointRequest: endpointRequest, + retryConfiguration: retryConfiguration + ) + + return try await downloadRequest( + endpointRequest, + resumableData: resumableData, + retryConfiguration: retryConfiguration + ) + } catch { + /// error processing + throw await errorProcessors.process(error, for: endpointRequest) + } + } + } + + /// Creates a record in the `downloadStateDict` for each task and observes their states. + /// Every `downloadStateDict` update triggers an event to the `downloadStateDictSubject` + /// which then leads to a task state update from `progressStream`. + func updateTasks() { + Task { + for task in await allTasks where await downloadStateDict.getValue(for: task) == nil { + /// In case there is no DownloadState for a given task in the dictionary, we need to create one. + await downloadStateDict.set(value: .init(task: task), for: task) + + /// We need to observe URLSessionTask.State via KVO individually for each task, because there is no delegate callback for the state change. + let cancellable = task + .publisher(for: \.state) + .sink { [weak self] state in + guard let self else { + return + } + + Task { + await self.downloadStateDict.update(task: task, for: \.taskState, with: state) + self.downloadStateDictSubject.send(await self.downloadStateDict.getValues()) + + if state == .completed { + await self.taskStateCancellables.set(value: nil, for: task) + } + } + } + + await taskStateCancellables.set(value: cancellable, for: task) + } + } + } +} + +// MARK: URLSession Delegate +extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { + public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + Task { + await downloadStateDict.update(task: downloadTask, for: \.downloadedBytes, with: totalBytesWritten) + await downloadStateDict.update(task: downloadTask, for: \.totalBytes, with: totalBytesExpectedToWrite) + downloadStateDictSubject.send(await downloadStateDict.getValues()) + } + } + + public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + Task { + await downloadStateDict.update(task: downloadTask, for: \.downloadedFileURL, with: location) + downloadStateDictSubject.send(await downloadStateDict.getValues()) + updateTasks() + } + } + + public func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + Task { + await downloadStateDict.update(task: task, for: \.error, with: error) + downloadStateDictSubject.send(await downloadStateDict.getValues()) + updateTasks() + } + } +} diff --git a/Sources/Networking/Core/DownloadAPIManaging.swift b/Sources/Networking/Core/DownloadAPIManaging.swift new file mode 100644 index 00000000..bf3dbd5a --- /dev/null +++ b/Sources/Networking/Core/DownloadAPIManaging.swift @@ -0,0 +1,53 @@ +// +// DownloadAPIManaging.swift +// +// +// Created by Dominika Gajdová on 12.05.2023. +// + +import Foundation + +// MARK: - Defines Download API managing +public typealias DownloadResult = (URLSessionDownloadTask, Response) + +/// A definition of an API layer with methods for handling data downloading. +/// Recommended to be used as singleton. +/// If you wish to use multiple instances, make sure you manually invalidate url session by calling the `invalidateSession` method. +public protocol DownloadAPIManaging { + /// List of all currently ongoing download tasks. + var allTasks: [URLSessionDownloadTask] { get async } + + /// Invalidates urlSession instance. + /// - Parameters: + /// - shouldFinishTasks: Indicates whether all currently active tasks should be able to finish before invalidating. Otherwise they will be cancelled. + func invalidateSession(shouldFinishTasks: Bool) + + /// Initiates a download request for a given endpoint, with optional resumable data and retry configuration. + /// - Parameters: + /// - endpoint: API endpoint requestable definition. + /// - resumableData: Optional data the download request will be resumed with. + /// - retryConfiguration: Configuration for retrying behaviour. + /// - Returns: A download result consisting of `URLSessionDownloadTask` and `Response` + func downloadRequest( + _ endpoint: Requestable, + resumableData: Data?, + retryConfiguration: RetryConfiguration? + ) async throws -> DownloadResult + + + /// Provides real time download updates for a given `URLSessionTask` + /// - Parameter task: The task whose updates are requested. + /// - Returns: An async stream of download states describing the task's download progress. + func progressStream(for task: URLSessionTask) -> AsyncStream +} + +// MARK: - Provide request with default nil resumable data, retry configuration +public extension DownloadAPIManaging { + func downloadRequest( + _ endpoint: Requestable, + resumableData: Data? = nil, + retryConfiguration: RetryConfiguration? = .default + ) async throws -> DownloadResult { + try await downloadRequest(endpoint, resumableData: resumableData, retryConfiguration: retryConfiguration) + } +} diff --git a/Sources/Networking/Core/Requestable+Convenience.swift b/Sources/Networking/Core/Requestable+Convenience.swift index 24fa725a..3cd26e75 100644 --- a/Sources/Networking/Core/Requestable+Convenience.swift +++ b/Sources/Networking/Core/Requestable+Convenience.swift @@ -48,7 +48,8 @@ public extension Requestable { public extension Requestable { func urlComponents() throws -> URLComponents { // url creation - let urlPath = baseURL.appendingPathComponent(path) + let urlPath = path.isEmpty ? baseURL : baseURL.appendingPathComponent(path) + guard var urlComponents = URLComponents(url: urlPath, resolvingAgainstBaseURL: true) else { throw RequestableError.invalidURLComponents } diff --git a/Sources/Networking/Core/RetryConfiguration.swift b/Sources/Networking/Core/RetryConfiguration.swift index 6ea10ad5..61480e7b 100644 --- a/Sources/Networking/Core/RetryConfiguration.swift +++ b/Sources/Networking/Core/RetryConfiguration.swift @@ -28,7 +28,7 @@ public struct RetryConfiguration { } // default configuration ignores - static var `default` = RetryConfiguration( + public static var `default` = RetryConfiguration( retries: 3, delay: .constant(2) ) { error in diff --git a/Sources/Networking/Core/Retryable.swift b/Sources/Networking/Core/Retryable.swift new file mode 100644 index 00000000..42106a59 --- /dev/null +++ b/Sources/Networking/Core/Retryable.swift @@ -0,0 +1,54 @@ +// +// Retryable.swift +// +// +// Created by Dominika Gajdová on 09.05.2023. +// + +/// Provides retry utility functionality to subjects that require it. +protocol Retryable { + /// Keeps count of executed retries so far given by `RetryConfiguration.retries`. + var retryCounter: Counter { get } + + /// Determines whether request should be retried based on `RetryConfiguration.retryHandler`, + /// otherwise suspends for a given time interval given by `DelayConfiguration`. + /// If the retries count hits limit or the request should not be retried, it throws the original error. + /// - Parameters: + /// - error: Initial error thrown by the attempted url request. + /// - endpointRequest: The endpoint describing the url request. + /// - retryConfiguration: Retry configuration for the given url request. + func sleepIfRetry( + for error: Error, + endpointRequest: EndpointRequest, + retryConfiguration: RetryConfiguration? + ) async throws +} + +extension Retryable { + func sleepIfRetry(for error: Error, endpointRequest: EndpointRequest, retryConfiguration: RetryConfiguration?) async throws { + let retryCount = await retryCounter.count(for: endpointRequest.id) + + guard + let retryConfiguration = retryConfiguration, + retryConfiguration.retryHandler(error), + retryConfiguration.retries > retryCount + else { + /// reset retry count + await retryCounter.reset(for: endpointRequest.id) + throw error + } + + /// count the delay for retry + await retryCounter.increment(for: endpointRequest.id) + + var sleepDuration: UInt64 + switch retryConfiguration.delay { + case .constant(let timeInterval): + sleepDuration = UInt64(timeInterval) * 1_000_000_000 + case .progressive(let timeInterval): + sleepDuration = UInt64(timeInterval) * UInt64(retryCount) * 1_000_000_000 + } + + try await Task.sleep(nanoseconds: sleepDuration) + } +} diff --git a/Sources/Networking/Misc/ThreadSafeDictionary.swift b/Sources/Networking/Misc/ThreadSafeDictionary.swift new file mode 100644 index 00000000..5d81e04a --- /dev/null +++ b/Sources/Networking/Misc/ThreadSafeDictionary.swift @@ -0,0 +1,37 @@ +// +// ThreadSafeDictionary.swift +// +// +// Created by Dominika Gajdová on 25.05.2023. +// + +import Foundation + +/// A thread safe generic wrapper for dictionary. +actor ThreadSafeDictionary { + private var values = [Key: Value]() + + func getValues() -> [Key: Value] { + values + } + + func getValue(for task: Key) -> Value? { + values[task] + } + + func set(value: Value?, for task: Key) { + values[task] = value + } + + /// Updates the property of a given keyPath. + func update( + task: Key, + for keyPath: WritableKeyPath, + with value: Type + ) { + if var state = values[task] { + state[keyPath: keyPath] = value + values[task] = state + } + } +} diff --git a/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift b/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift new file mode 100644 index 00000000..8451765d --- /dev/null +++ b/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift @@ -0,0 +1,39 @@ +// +// URLSessionTask+AsyncResponse.swift +// +// +// Created by Dominika Gajdová on 12.05.2023. +// + +import Foundation +import Combine + +extension URLSessionTask { + func asyncResponse() async throws -> URLResponse { + var cancellable: AnyCancellable? + + return try await withTaskCancellationHandler( + operation: { + try await withCheckedThrowingContinuation { continuation in + cancellable = Publishers.CombineLatest( + publisher(for: \.response), + publisher(for: \.error) + ) + .first(where: { (response, error) in + response != nil || error != nil + }) + .sink { (response, error) in + if let error { + continuation.resume(throwing: error) + } else if let response { + continuation.resume(returning: response) + } + } + } + }, + onCancel: { [cancellable] in + cancellable?.cancel() + } + ) + } +} diff --git a/Sources/Networking/Misc/URLSessionTask+DownloadState.swift b/Sources/Networking/Misc/URLSessionTask+DownloadState.swift new file mode 100644 index 00000000..239d19e5 --- /dev/null +++ b/Sources/Networking/Misc/URLSessionTask+DownloadState.swift @@ -0,0 +1,37 @@ +// +// DownloadState.swift +// +// +// Created by Matej Molnár on 07.03.2023. +// + +import Foundation + +public extension URLSessionTask { + struct DownloadState { + public var downloadedBytes: Int64 + public var totalBytes: Int64 + public var taskState: URLSessionTask.State + public var error: Error? + public var downloadedFileURL: URL? + + public var resumableData: Data? { + (error as? URLError)?.userInfo[NSURLSessionDownloadTaskResumeData] as? Data + } + public var fractionCompleted: Double { + guard totalBytes > 0 else { + return 0 + } + + return Double(downloadedBytes)/Double(totalBytes) + } + + public init(task: URLSessionTask) { + downloadedBytes = task.countOfBytesReceived + totalBytes = task.countOfBytesExpectedToReceive + taskState = task.state + error = task.error + downloadedFileURL = nil + } + } +}