From 8a615b909c95fc2abca7e826cd989e2b641a0f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Molna=CC=81r?= Date: Tue, 7 Mar 2023 22:31:23 +0100 Subject: [PATCH 01/79] feat: add download support v2 --- .../project.pbxproj | 28 ++ .../API/Routers/SampleDownloadRouter.swift | 42 +++ .../NetworkingSampleApp/ContentView.swift | 5 +- .../NetworkingSampleApp/Info.plist | 5 + .../Scenes/Download/DownloadRow.swift | 60 +++++ .../Download/DownloadRowViewModel.swift | 65 +++++ .../Scenes/Download/DownloadsView.swift | 42 +++ .../Scenes/Download/DownloadsViewModel.swift | 63 +++++ .../Networking/Core/DownloadAPIManager.swift | 240 ++++++++++++++++++ .../Core/Requestable+Convenience.swift | 9 +- .../Networking/Core/RetryConfiguration.swift | 2 +- Sources/Networking/Misc/DownloadState.swift | 37 +++ 12 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleDownloadRouter.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift create mode 100644 Sources/Networking/Core/DownloadAPIManager.swift create mode 100644 Sources/Networking/Misc/DownloadState.swift diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 31dcc48c..41481a37 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -18,6 +18,11 @@ 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 */; }; + 58C3E76329B7D6C1004FD1CD /* DownloadRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76229B7D6C1004FD1CD /* DownloadRow.swift */; }; + 58C3E76529B7D709004FD1CD /* DownloadRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76429B7D709004FD1CD /* DownloadRowViewModel.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 */; }; @@ -45,6 +50,11 @@ 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 = ""; }; + 58C3E76229B7D6C1004FD1CD /* DownloadRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadRow.swift; sourceTree = ""; }; + 58C3E76429B7D709004FD1CD /* DownloadRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadRowViewModel.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 = ""; }; @@ -113,6 +123,7 @@ 23A575ED25F8BF0E00617551 /* Scenes */ = { isa = PBXGroup; children = ( + 58C3E75B29B78ED3004FD1CD /* Download */, 58FB80C5298521DA0031FC59 /* Authorization */, ); path = Scenes; @@ -153,6 +164,7 @@ isa = PBXGroup; children = ( DDD3AD1E2950E794006CB777 /* SampleAuthRouter.swift */, + 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */, 23EA9CE9292FB70A00B8E418 /* SampleUserRouter.swift */, ); path = Routers; @@ -178,6 +190,17 @@ path = Requests; sourceTree = ""; }; + 58C3E75B29B78ED3004FD1CD /* Download */ = { + isa = PBXGroup; + children = ( + 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */, + 58C3E76229B7D6C1004FD1CD /* DownloadRow.swift */, + 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */, + 58C3E76429B7D709004FD1CD /* DownloadRowViewModel.swift */, + ); + path = Download; + sourceTree = ""; + }; 58FB80C5298521DA0031FC59 /* Authorization */ = { isa = PBXGroup; children = ( @@ -277,15 +300,20 @@ 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */, 23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */, 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */, + 58C3E76529B7D709004FD1CD /* DownloadRowViewModel.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 */, + 58C3E76329B7D6C1004FD1CD /* DownloadRow.swift in Sources */, 58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */, DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */, + 58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */, + 58C3E75E29B78EE6004FD1CD /* DownloadsView.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..b81914b6 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleDownloadRouter.swift @@ -0,0 +1,42 @@ +// +// 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 "" + } + } + + var urlParameters: [String: Any]? { + switch self { + case .download: + return nil + } + } + + var method: HTTPMethod { + switch self { + case .download: + return .get + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift index 92bb4936..c9af2c20 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift @@ -9,6 +9,7 @@ import SwiftUI enum NetworkingCase: String, Hashable, CaseIterable { case authorization + case downloads } struct ContentView: View { @@ -16,7 +17,7 @@ struct ContentView: View { NavigationStack { List { ForEach(NetworkingCase.allCases, id: \.self) { screen in - NavigationLink(screen.rawValue.capitalized, value: NetworkingCase.authorization) + NavigationLink(screen.rawValue.capitalized, value: screen) } } .navigationTitle("Examples") @@ -24,6 +25,8 @@ struct ContentView: View { switch screen { case .authorization: AuthorizationView() + case .downloads: + DownloadsView() } } } 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/DownloadRow.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift new file mode 100644 index 00000000..b174ccef --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift @@ -0,0 +1,60 @@ +// +// DownloadRow.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 07.03.2023. +// + +import SwiftUI + +struct DownloadRow: View { + @StateObject var viewModel: DownloadRowViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.title) + .padding(.bottom, 8) + + Text("Status: \(viewModel.status)") + Text("\(String(format: "%.1f", viewModel.percentCompleted))% of \(String(format: "%.1f", viewModel.totalMegaBytes))MB") + + if let errorTitle = viewModel.errorTitle { + Text("Error: \(errorTitle)") + } + + if let fileURL = viewModel.fileURL { + Text("FileURL: \(fileURL)") + } + + HStack { + Button { + viewModel.suspend() + } label: { + Text("Suspend") + } + + Button { + viewModel.resume() + } label: { + Text("Resume") + } + + Button { + viewModel.cancel() + } label: { + Text("Cancel") + } + } + } + .padding(10) + .background( + Color.white + .cornerRadius(15) + .shadow(radius: 10) + ) + .padding(15) + .onAppear { + viewModel.onAppear() + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift new file mode 100644 index 00000000..ae61c7b4 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift @@ -0,0 +1,65 @@ +// +// DownloadRowViewModel.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 07.03.2023. +// + +import SwiftUI +import Networking + +class DownloadRowViewModel: ObservableObject { + private let task: URLSessionTask + + @Published var title: String = "" + @Published var status: String = "" + @Published var percentCompleted: Double = 0 + @Published var totalMegaBytes: Double = 0 + @Published var errorTitle: String? + @Published var fileURL: String? + + init(task: URLSessionTask) { + self.task = task + title = task.currentRequest?.url?.absoluteString ?? "-" + } + + func onAppear() { + Task { + let stream = DownloadAPIManager.shared.progressStream(for: task) + + for try await downloadState in stream { + DispatchQueue.main.async { [weak self] in + self?.percentCompleted = downloadState.fractionCompleted * 100 + self?.totalMegaBytes = Double(downloadState.totalBytesExpectedToWrite) / 1000_000 + self?.status = downloadState.taskState.title + self?.errorTitle = downloadState.error?.localizedDescription + self?.fileURL = downloadState.downloadedFileURL?.absoluteString + } + } + } + } + + func suspend() { + task.suspend() + } + + func resume() { + task.resume() + } + + func cancel() { + task.cancel() + } +} + +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..3e78b85a --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift @@ -0,0 +1,42 @@ +// +// 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 { + Task { + await viewModel.download() + } + } label: { + Text("Download") + } + .buttonStyle(.bordered) + } + .padding(.horizontal, 15) + + ScrollView { + LazyVStack { + ForEach(viewModel.tasks, id: \.taskIdentifier) { task in + DownloadRow(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..ee4d94dd --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -0,0 +1,63 @@ +// +// DownloadsViewModel.swift +// +// +// Created by Matej Molnár on 07.03.2023. +// + +import Foundation +import Networking + +extension DownloadAPIManager { + static var shared: DownloadAPIManager = { + 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 + ) + }() +} + +@MainActor final class DownloadsViewModel: ObservableObject { + @Published var tasks: [URLSessionTask] = [] + @Published var urlText: String = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" + private let downloadAPIManager = DownloadAPIManager.shared + + func onAppear() { + Task { + tasks = await downloadAPIManager.allTasks + } + } + + func download() 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 { + + } + } +} diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift new file mode 100644 index 00000000..881279e8 --- /dev/null +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -0,0 +1,240 @@ +// +// DownloadAPIManager.swift +// +// +// Created by Matej Molnár on 07.03.2023. +// + +import Foundation +import Combine + +/// Default Download API manager +open class DownloadAPIManager: NSObject { + private let requestAdapters: [RequestAdapting] + private let responseProcessors: [ResponseProcessing] + private let errorProcessors: [ErrorProcessing] + private var urlSession: URLSession! + private let sessionId: String + private var retryCounter = Counter() + private var taskStateCancellables: [URLSessionTask: AnyCancellable] = [:] + private let downloadStateDictSubject = CurrentValueSubject<[URLSessionTask: URLSessionTask.DownloadState], Never>([:]) + private var downloadStateDict = [URLSessionTask: URLSessionTask.DownloadState]() { + didSet { + downloadStateDictSubject.send(downloadStateDict) + } + } + + 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() + ) + } + + public func downloadRequest( + _ endpoint: Requestable, + resumableData: Data? = nil, + retryConfiguration: RetryConfiguration? + ) async throws -> (URLSessionDownloadTask, Response) { + /// create identifiable request from endpoint + let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + return try await downloadRequest(endpointRequest, resumableData: resumableData, retryConfiguration: retryConfiguration) + } + + 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() + } + } + } +} + +private extension DownloadAPIManager { + func downloadRequest( + _ endpointRequest: EndpointRequest, + resumableData: Data?, + retryConfiguration: RetryConfiguration? + ) async throws -> (URLSessionDownloadTask, Response) { + 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) + } + } + } + + func updateTasks() { + Task { + for task in await allTasks where downloadStateDict[task] == nil { + /// In case there is no DownloadState for a given task in the dictionary, we need to create one. + downloadStateDict[task] = .init(task: task) + + /// We need to observe URLSessionTask.State via KVO individually for each task, because there is no delegate callback for the state change. + taskStateCancellables[task] = task + .publisher(for: \.state) + .sink { [weak self] state in + self?.downloadStateDict[task]?.taskState = state + + if state == .completed { + self?.taskStateCancellables[task] = nil + } + } + } + } + } + + /// 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) + } +} + +extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { + public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + downloadStateDict[downloadTask]?.totalBytesWritten = totalBytesWritten + downloadStateDict[downloadTask]?.totalBytesExpectedToWrite = totalBytesExpectedToWrite + } + + public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + downloadStateDict[downloadTask]?.downloadedFileURL = location + updateTasks() + } + + public func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + downloadStateDict[task]?.error = error + updateTasks() + } +} + +private 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) + } + + if let response { + continuation.resume(returning: response) + } + } + } + }, + onCancel: { [cancellable] in + cancellable?.cancel() + }) + } +} diff --git a/Sources/Networking/Core/Requestable+Convenience.swift b/Sources/Networking/Core/Requestable+Convenience.swift index 24fa725a..866c5c14 100644 --- a/Sources/Networking/Core/Requestable+Convenience.swift +++ b/Sources/Networking/Core/Requestable+Convenience.swift @@ -48,7 +48,14 @@ public extension Requestable { public extension Requestable { func urlComponents() throws -> URLComponents { // url creation - let urlPath = baseURL.appendingPathComponent(path) + let urlPath = { + if path.isEmpty { + return baseURL + } else { + return 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/Misc/DownloadState.swift b/Sources/Networking/Misc/DownloadState.swift new file mode 100644 index 00000000..172ad31c --- /dev/null +++ b/Sources/Networking/Misc/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 totalBytesWritten: Int64 + public var totalBytesExpectedToWrite: 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 totalBytesExpectedToWrite > 0 else { + return 0 + } + + return Double(totalBytesWritten)/Double(totalBytesExpectedToWrite) + } + + public init(task: URLSessionTask) { + totalBytesWritten = task.countOfBytesReceived + totalBytesExpectedToWrite = task.countOfBytesExpectedToReceive + taskState = task.state + error = task.error + downloadedFileURL = nil + } + } +} From da1e113d6ddb4551f0acbffcc435161b0c6877a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Mon, 8 May 2023 13:40:22 +0200 Subject: [PATCH 02/79] chore: support for associated array given by ArrayEncoding --- .../Core/RequestUrlParametersType.swift | 18 ++++++++ .../Core/Requestable+Convenience.swift | 45 +++++++++++++++++-- Sources/Networking/Core/Requestable.swift | 2 +- Sources/Networking/Misc/ArrayEncoding.swift | 16 +++++++ .../AssociatedArrayQueryTests.swift | 44 ++++++++++++++++++ 5 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 Sources/Networking/Core/RequestUrlParametersType.swift create mode 100644 Sources/Networking/Misc/ArrayEncoding.swift create mode 100644 Tests/NetworkingTests/AssociatedArrayQueryTests.swift diff --git a/Sources/Networking/Core/RequestUrlParametersType.swift b/Sources/Networking/Core/RequestUrlParametersType.swift new file mode 100644 index 00000000..149987db --- /dev/null +++ b/Sources/Networking/Core/RequestUrlParametersType.swift @@ -0,0 +1,18 @@ +// +// RequestableUrlParametersType.swift +// +// +// Created by Dominika Gajdová on 08.05.2023. +// + +import Foundation + +public struct RequestUrlParametersType { + let parameters: [String: Any] + let arrayEncoding: ArrayEncoding + + init(_ parameters: [String : Any], arrayEncoding: ArrayEncoding = .individual) { + self.parameters = parameters + self.arrayEncoding = arrayEncoding + } +} diff --git a/Sources/Networking/Core/Requestable+Convenience.swift b/Sources/Networking/Core/Requestable+Convenience.swift index 24fa725a..07ecfdbf 100644 --- a/Sources/Networking/Core/Requestable+Convenience.swift +++ b/Sources/Networking/Core/Requestable+Convenience.swift @@ -27,7 +27,7 @@ public extension Requestable { } /// The default value is `nil`. - var urlParameters: [String: Any]? { + var urlParametersType: RequestUrlParametersType? { nil } @@ -54,8 +54,11 @@ public extension Requestable { } // encode url parameters - if let urlParameters { - urlComponents.queryItems = urlParameters.map { URLQueryItem(name: $0, value: String(describing: $1)) } + if let urlParametersType { + urlComponents.queryItems = buildQueryItems( + urlParameters: urlParametersType.parameters, + arrayEncoding: urlParametersType.arrayEncoding + ) } return urlComponents @@ -104,3 +107,39 @@ public extension Requestable { return request } } + +// MARK: Private utils +private extension Requestable { + func buildQueryItems(urlParameters: [String: Any], arrayEncoding: ArrayEncoding) -> [URLQueryItem] { + urlParameters + .map { key, value -> [URLQueryItem] in + buildQueryItems(key: key, value: value, arrayEncoding: arrayEncoding) + } + .flatMap { $0 } + } + + func buildQueryItems(key: String, value: Any, arrayEncoding: ArrayEncoding) -> [URLQueryItem] { + if let values = value as? [Any] { + var queryItems: [URLQueryItem] = [] + + switch arrayEncoding { + case .commaSeparated: + queryItems = [URLQueryItem( + name: key, + value: values.map { String(describing: $0) }.joined(separator: ",") + )] + + case .individual: + for parameter in values { + queryItems.append(URLQueryItem( + name: key, + value: String(describing: parameter) + )) + } + } + return queryItems + } + + return [URLQueryItem(name: key, value: String(describing: value))] + } +} diff --git a/Sources/Networking/Core/Requestable.swift b/Sources/Networking/Core/Requestable.swift index 0b66719f..ffbabe73 100644 --- a/Sources/Networking/Core/Requestable.swift +++ b/Sources/Networking/Core/Requestable.swift @@ -22,7 +22,7 @@ public protocol Requestable: EndpointIdentifiable { var method: HTTPMethod { get } /// The GET url parameters which are encoded into url. - var urlParameters: [String: Any]? { get } + var urlParametersType: RequestUrlParametersType? { get } /// The HTTP request headers. var headers: [String: String]? { get } diff --git a/Sources/Networking/Misc/ArrayEncoding.swift b/Sources/Networking/Misc/ArrayEncoding.swift new file mode 100644 index 00000000..bfd673d8 --- /dev/null +++ b/Sources/Networking/Misc/ArrayEncoding.swift @@ -0,0 +1,16 @@ +// +// ArrayEncoding.swift +// +// +// Created by Dominika Gajdová on 08.05.2023. +// + +import Foundation + +/// Associated array parameters query options. +public enum ArrayEncoding { + /// filter=1,2,3 + case commaSeparated + /// filter=1&filter=2&filter=3 + case individual +} diff --git a/Tests/NetworkingTests/AssociatedArrayQueryTests.swift b/Tests/NetworkingTests/AssociatedArrayQueryTests.swift new file mode 100644 index 00000000..d269f147 --- /dev/null +++ b/Tests/NetworkingTests/AssociatedArrayQueryTests.swift @@ -0,0 +1,44 @@ +// +// File.swift +// +// +// Created by Dominika Gajdová on 08.05.2023. +// + +@testable import Networking +import XCTest + +final class AssociatedArrayQueryTests: XCTestCase { + enum TestRouter: Requestable { + case single + case arrayIndividual + case arraySeparated + + var baseURL: URL { URL(string: "http://someurl.com")! } + var path: String { "" } + + var urlParametersType: RequestUrlParametersType? { + switch self { + case .single: + return .init(["filter": 1]) + + case .arrayIndividual: + return .init(["filter": [1, 2, 3], "drama": 0], arrayEncoding: .individual) + + case .arraySeparated: + return .init(["filter": [1, 2, 3]], arrayEncoding: .commaSeparated) + } + } + } + + func testMultipleKeyParamaterURLCreation() async throws { + let urlRequest1 = try TestRouter.single.asRequest() + XCTAssertEqual("http://someurl.com/?filter=1", urlRequest1.url?.absoluteString ?? "") + + let urlRequest2 = try TestRouter.arrayIndividual.asRequest() + XCTAssertEqual("http://someurl.com/?drama=0&filter=1&filter=2&filter=3", urlRequest2.url?.absoluteString ?? "") + + let urlRequest3 = try TestRouter.arraySeparated.asRequest() + XCTAssertEqual("http://someurl.com/?filter=1,2,3", urlRequest3.url?.absoluteString ?? "") + } +} From 37bc09d139810680bcc08c7af677136bf813ad02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Mon, 8 May 2023 20:24:12 +0200 Subject: [PATCH 03/79] chore: introduction of ArrayType --- .../Core/RequestUrlParametersType.swift | 18 --------------- .../Core/Requestable+Convenience.swift | 23 ++++++++----------- Sources/Networking/Core/Requestable.swift | 2 +- Sources/Networking/Misc/ArrayType.swift | 18 +++++++++++++++ .../AssociatedArrayQueryTests.swift | 8 +++---- 5 files changed, 33 insertions(+), 36 deletions(-) delete mode 100644 Sources/Networking/Core/RequestUrlParametersType.swift create mode 100644 Sources/Networking/Misc/ArrayType.swift diff --git a/Sources/Networking/Core/RequestUrlParametersType.swift b/Sources/Networking/Core/RequestUrlParametersType.swift deleted file mode 100644 index 149987db..00000000 --- a/Sources/Networking/Core/RequestUrlParametersType.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// RequestableUrlParametersType.swift -// -// -// Created by Dominika Gajdová on 08.05.2023. -// - -import Foundation - -public struct RequestUrlParametersType { - let parameters: [String: Any] - let arrayEncoding: ArrayEncoding - - init(_ parameters: [String : Any], arrayEncoding: ArrayEncoding = .individual) { - self.parameters = parameters - self.arrayEncoding = arrayEncoding - } -} diff --git a/Sources/Networking/Core/Requestable+Convenience.swift b/Sources/Networking/Core/Requestable+Convenience.swift index 07ecfdbf..fbf360d2 100644 --- a/Sources/Networking/Core/Requestable+Convenience.swift +++ b/Sources/Networking/Core/Requestable+Convenience.swift @@ -27,7 +27,7 @@ public extension Requestable { } /// The default value is `nil`. - var urlParametersType: RequestUrlParametersType? { + var urlParameters: [String: Any]? { nil } @@ -54,11 +54,8 @@ public extension Requestable { } // encode url parameters - if let urlParametersType { - urlComponents.queryItems = buildQueryItems( - urlParameters: urlParametersType.parameters, - arrayEncoding: urlParametersType.arrayEncoding - ) + if let urlParameters { + urlComponents.queryItems = buildQueryItems(urlParameters: urlParameters) } return urlComponents @@ -110,27 +107,27 @@ public extension Requestable { // MARK: Private utils private extension Requestable { - func buildQueryItems(urlParameters: [String: Any], arrayEncoding: ArrayEncoding) -> [URLQueryItem] { + func buildQueryItems(urlParameters: [String: Any]) -> [URLQueryItem] { urlParameters .map { key, value -> [URLQueryItem] in - buildQueryItems(key: key, value: value, arrayEncoding: arrayEncoding) + buildQueryItems(key: key, value: value) } .flatMap { $0 } } - func buildQueryItems(key: String, value: Any, arrayEncoding: ArrayEncoding) -> [URLQueryItem] { - if let values = value as? [Any] { + func buildQueryItems(key: String, value: Any) -> [URLQueryItem] { + if let arrayType = value as? ArrayType { var queryItems: [URLQueryItem] = [] - switch arrayEncoding { + switch arrayType.arrayEncoding { case .commaSeparated: queryItems = [URLQueryItem( name: key, - value: values.map { String(describing: $0) }.joined(separator: ",") + value: arrayType.values.map { String(describing: $0) }.joined(separator: ",") )] case .individual: - for parameter in values { + for parameter in arrayType.values { queryItems.append(URLQueryItem( name: key, value: String(describing: parameter) diff --git a/Sources/Networking/Core/Requestable.swift b/Sources/Networking/Core/Requestable.swift index ffbabe73..0b66719f 100644 --- a/Sources/Networking/Core/Requestable.swift +++ b/Sources/Networking/Core/Requestable.swift @@ -22,7 +22,7 @@ public protocol Requestable: EndpointIdentifiable { var method: HTTPMethod { get } /// The GET url parameters which are encoded into url. - var urlParametersType: RequestUrlParametersType? { get } + var urlParameters: [String: Any]? { get } /// The HTTP request headers. var headers: [String: String]? { get } diff --git a/Sources/Networking/Misc/ArrayType.swift b/Sources/Networking/Misc/ArrayType.swift new file mode 100644 index 00000000..d7ecc001 --- /dev/null +++ b/Sources/Networking/Misc/ArrayType.swift @@ -0,0 +1,18 @@ +// +// File.swift +// +// +// Created by Dominika Gajdová on 08.05.2023. +// + +import Foundation + +public struct ArrayType { + let values: [Any] + let arrayEncoding: ArrayEncoding + + public init(_ values: [Any], arrayEncoding: ArrayEncoding = .individual) { + self.values = values + self.arrayEncoding = arrayEncoding + } +} diff --git a/Tests/NetworkingTests/AssociatedArrayQueryTests.swift b/Tests/NetworkingTests/AssociatedArrayQueryTests.swift index d269f147..0f25b96f 100644 --- a/Tests/NetworkingTests/AssociatedArrayQueryTests.swift +++ b/Tests/NetworkingTests/AssociatedArrayQueryTests.swift @@ -17,16 +17,16 @@ final class AssociatedArrayQueryTests: XCTestCase { var baseURL: URL { URL(string: "http://someurl.com")! } var path: String { "" } - var urlParametersType: RequestUrlParametersType? { + var urlParameters: [String: Any]? { switch self { case .single: - return .init(["filter": 1]) + return ["filter": 1] case .arrayIndividual: - return .init(["filter": [1, 2, 3], "drama": 0], arrayEncoding: .individual) + return ["filter": ArrayType([1, 2, 3],arrayEncoding: .individual), "drama": 0] case .arraySeparated: - return .init(["filter": [1, 2, 3]], arrayEncoding: .commaSeparated) + return ["filter": ArrayType([1, 2, 3],arrayEncoding: .commaSeparated)] } } } From 7190f9c9ec2f0df917dba373aba9e19f79a8bdc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Tue, 9 May 2023 08:35:55 +0200 Subject: [PATCH 04/79] chore: rename arraytype to arrayparameter, add test --- .../Core/Requestable+Convenience.swift | 4 +-- .../{ArrayType.swift => ArrayParameter.swift} | 2 +- .../AssociatedArrayQueryTests.swift | 26 ++++++++++++++++--- 3 files changed, 26 insertions(+), 6 deletions(-) rename Sources/Networking/Misc/{ArrayType.swift => ArrayParameter.swift} (91%) diff --git a/Sources/Networking/Core/Requestable+Convenience.swift b/Sources/Networking/Core/Requestable+Convenience.swift index fbf360d2..b5265a99 100644 --- a/Sources/Networking/Core/Requestable+Convenience.swift +++ b/Sources/Networking/Core/Requestable+Convenience.swift @@ -105,7 +105,7 @@ public extension Requestable { } } -// MARK: Private utils +// MARK: Build Query Items private extension Requestable { func buildQueryItems(urlParameters: [String: Any]) -> [URLQueryItem] { urlParameters @@ -116,7 +116,7 @@ private extension Requestable { } func buildQueryItems(key: String, value: Any) -> [URLQueryItem] { - if let arrayType = value as? ArrayType { + if let arrayType = value as? ArrayParameter { var queryItems: [URLQueryItem] = [] switch arrayType.arrayEncoding { diff --git a/Sources/Networking/Misc/ArrayType.swift b/Sources/Networking/Misc/ArrayParameter.swift similarity index 91% rename from Sources/Networking/Misc/ArrayType.swift rename to Sources/Networking/Misc/ArrayParameter.swift index d7ecc001..7769cf4d 100644 --- a/Sources/Networking/Misc/ArrayType.swift +++ b/Sources/Networking/Misc/ArrayParameter.swift @@ -7,7 +7,7 @@ import Foundation -public struct ArrayType { +public struct ArrayParameter { let values: [Any] let arrayEncoding: ArrayEncoding diff --git a/Tests/NetworkingTests/AssociatedArrayQueryTests.swift b/Tests/NetworkingTests/AssociatedArrayQueryTests.swift index 0f25b96f..e12c9512 100644 --- a/Tests/NetworkingTests/AssociatedArrayQueryTests.swift +++ b/Tests/NetworkingTests/AssociatedArrayQueryTests.swift @@ -13,6 +13,7 @@ final class AssociatedArrayQueryTests: XCTestCase { case single case arrayIndividual case arraySeparated + case both var baseURL: URL { URL(string: "http://someurl.com")! } var path: String { "" } @@ -23,10 +24,13 @@ final class AssociatedArrayQueryTests: XCTestCase { return ["filter": 1] case .arrayIndividual: - return ["filter": ArrayType([1, 2, 3],arrayEncoding: .individual), "drama": 0] + return ["filter": ArrayParameter([1, 2, 3], arrayEncoding: .individual)] case .arraySeparated: - return ["filter": ArrayType([1, 2, 3],arrayEncoding: .commaSeparated)] + return ["filter": ArrayParameter([1, 2, 3], arrayEncoding: .commaSeparated)] + + case .both: + return ["filter": ArrayParameter([1, 2, 3], arrayEncoding: .individual), "data": 5] } } } @@ -36,9 +40,25 @@ final class AssociatedArrayQueryTests: XCTestCase { XCTAssertEqual("http://someurl.com/?filter=1", urlRequest1.url?.absoluteString ?? "") let urlRequest2 = try TestRouter.arrayIndividual.asRequest() - XCTAssertEqual("http://someurl.com/?drama=0&filter=1&filter=2&filter=3", urlRequest2.url?.absoluteString ?? "") + XCTAssertEqual("http://someurl.com/?filter=1&filter=2&filter=3", urlRequest2.url?.absoluteString ?? "") let urlRequest3 = try TestRouter.arraySeparated.asRequest() XCTAssertEqual("http://someurl.com/?filter=1,2,3", urlRequest3.url?.absoluteString ?? "") + + let urlRequest4 = try TestRouter.both.asRequest() + + if let url = urlRequest4.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let queryItems = components.queryItems, + let parameters = TestRouter.both.urlParameters + { + let result = parameters.allSatisfy { (key, value) in + queryItems.contains(where: { $0.name == key }) + } + + XCTAssertTrue(result) + } else { + XCTFail("Invalid request url and/or query parameters.") + } } } From e5e663e9828242d1d7702b05351d5b30007035be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Tue, 9 May 2023 08:58:43 +0200 Subject: [PATCH 05/79] chore: docs for array parameter --- Sources/Networking/Misc/ArrayParameter.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/Networking/Misc/ArrayParameter.swift b/Sources/Networking/Misc/ArrayParameter.swift index 7769cf4d..b8f3265c 100644 --- a/Sources/Networking/Misc/ArrayParameter.swift +++ b/Sources/Networking/Misc/ArrayParameter.swift @@ -7,6 +7,14 @@ import Foundation +/// Array parameter type for associated values with array encoding option. +/// +/// The following example shows the use. +/// +/// var urlParameters: [String: Any]? { +/// ["filter": ArrayParameter([1, 2, 3], arrayEncoding: .individual)] +/// } + public struct ArrayParameter { let values: [Any] let arrayEncoding: ArrayEncoding From eb47a4d6a154ba8975080f7c863743affcb48437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Tue, 9 May 2023 10:19:43 +0200 Subject: [PATCH 06/79] chore: formatting, ui buttons not displayed if task completed --- .../Scenes/Download/DownloadRow.swift | 38 +++++++++--------- .../Download/DownloadRowViewModel.swift | 10 +++-- .../Scenes/Download/DownloadsViewModel.swift | 40 ++++++++++--------- .../Networking/Core/DownloadAPIManager.swift | 8 ++-- 4 files changed, 50 insertions(+), 46 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift index b174ccef..cdb64152 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift @@ -15,7 +15,7 @@ struct DownloadRow: View { Text(viewModel.title) .padding(.bottom, 8) - Text("Status: \(viewModel.status)") + Text("Status: \(viewModel.statusTitle)") Text("\(String(format: "%.1f", viewModel.percentCompleted))% of \(String(format: "%.1f", viewModel.totalMegaBytes))MB") if let errorTitle = viewModel.errorTitle { @@ -26,23 +26,25 @@ struct DownloadRow: View { Text("FileURL: \(fileURL)") } - HStack { - Button { - viewModel.suspend() - } label: { - Text("Suspend") - } - - Button { - viewModel.resume() - } label: { - Text("Resume") - } - - Button { - viewModel.cancel() - } label: { - Text("Cancel") + if viewModel.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/DownloadRowViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift index ae61c7b4..0c9294e5 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift @@ -8,11 +8,12 @@ import SwiftUI import Networking -class DownloadRowViewModel: ObservableObject { +final class DownloadRowViewModel: ObservableObject { private let task: URLSessionTask @Published var title: String = "" - @Published var status: String = "" + @Published var status: URLSessionTask.State = .running + @Published var statusTitle: String = "" @Published var percentCompleted: Double = 0 @Published var totalMegaBytes: Double = 0 @Published var errorTitle: String? @@ -30,8 +31,9 @@ class DownloadRowViewModel: ObservableObject { for try await downloadState in stream { DispatchQueue.main.async { [weak self] in self?.percentCompleted = downloadState.fractionCompleted * 100 - self?.totalMegaBytes = Double(downloadState.totalBytesExpectedToWrite) / 1000_000 - self?.status = downloadState.taskState.title + self?.totalMegaBytes = Double(downloadState.totalBytesExpectedToWrite) / 1_000_000 + self?.status = downloadState.taskState + self?.statusTitle = downloadState.taskState.title self?.errorTitle = downloadState.error?.localizedDescription self?.fileURL = downloadState.downloadedFileURL?.absoluteString } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift index ee4d94dd..38ecf97d 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -7,32 +7,34 @@ import Foundation import Networking +import OSLog extension DownloadAPIManager { static var shared: DownloadAPIManager = { - var responseProcessors: [ResponseProcessing] = [ - LoggingInterceptor.shared, - StatusCodeProcessor.shared - ] - var errorProcessors: [ErrorProcessing] = [LoggingInterceptor.shared] - + var responseProcessors: [ResponseProcessing] = [ + LoggingInterceptor.shared, + StatusCodeProcessor.shared + ] + var errorProcessors: [ErrorProcessing] = [LoggingInterceptor.shared] + #if DEBUG - responseProcessors.append(EndpointRequestStorageProcessor.shared) - errorProcessors.append(EndpointRequestStorageProcessor.shared) + responseProcessors.append(EndpointRequestStorageProcessor.shared) + errorProcessors.append(EndpointRequestStorageProcessor.shared) #endif - - return DownloadAPIManager( - urlSessionConfiguration: .default, - requestAdapters: [ - LoggingInterceptor.shared - ], - responseProcessors: responseProcessors, - errorProcessors: errorProcessors - ) + + return DownloadAPIManager( + urlSessionConfiguration: .default, + requestAdapters: [ + LoggingInterceptor.shared + ], + responseProcessors: responseProcessors, + errorProcessors: errorProcessors + ) }() } -@MainActor final class DownloadsViewModel: ObservableObject { +@MainActor +final class DownloadsViewModel: ObservableObject { @Published var tasks: [URLSessionTask] = [] @Published var urlText: String = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" private let downloadAPIManager = DownloadAPIManager.shared @@ -57,7 +59,7 @@ extension DownloadAPIManager { tasks.append(task) } catch { - + os_log("❌ DownloadAPIManager failed to download \(self.urlText) with error: \(error.localizedDescription)") } } } diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index 881279e8..9a43688a 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -72,8 +72,7 @@ open class DownloadAPIManager: NSObject { continuation.yield(downloadState) - if - downloadState.error != nil || + if downloadState.error != nil || downloadState.downloadedFileURL != nil { continuation.finish() @@ -109,7 +108,6 @@ private extension DownloadAPIManager { } }() - /// 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() @@ -182,9 +180,9 @@ private extension DownloadAPIManager { var sleepDuration: UInt64 switch retryConfiguration.delay { case .constant(let timeInterval): - sleepDuration = UInt64(timeInterval) * 1000000000 + sleepDuration = UInt64(timeInterval) * 1_000_000_000 case .progressive(let timeInterval): - sleepDuration = UInt64(timeInterval) * UInt64(retryCount) * 1000000000 + sleepDuration = UInt64(timeInterval) * UInt64(retryCount) * 1_000_000_000 } try await Task.sleep(nanoseconds: sleepDuration) From 83314b14ac48d3df82442918ffd362d3fbca5b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Tue, 9 May 2023 10:41:09 +0200 Subject: [PATCH 07/79] chore: extract retry handling to a protocol --- Sources/Networking/Core/APIManager.swift | 33 ++---------- .../Networking/Core/DownloadAPIManager.swift | 50 ++++++----------- Sources/Networking/Core/Retryable.swift | 54 +++++++++++++++++++ 3 files changed, 73 insertions(+), 64 deletions(-) create mode 100644 Sources/Networking/Core/Retryable.swift 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 index 9a43688a..83877cbd 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -9,13 +9,12 @@ import Foundation import Combine /// Default Download API manager -open class DownloadAPIManager: NSObject { +open class DownloadAPIManager: NSObject, Retryable { private let requestAdapters: [RequestAdapting] private let responseProcessors: [ResponseProcessing] private let errorProcessors: [ErrorProcessing] private var urlSession: URLSession! private let sessionId: String - private var retryCounter = Counter() private var taskStateCancellables: [URLSessionTask: AnyCancellable] = [:] private let downloadStateDictSubject = CurrentValueSubject<[URLSessionTask: URLSessionTask.DownloadState], Never>([:]) private var downloadStateDict = [URLSessionTask: URLSessionTask.DownloadState]() { @@ -24,6 +23,7 @@ open class DownloadAPIManager: NSObject { } } + internal var retryCounter = Counter() public var allTasks: [URLSessionDownloadTask] { get async { await urlSession.allTasks.compactMap { $0 as? URLSessionDownloadTask } @@ -51,8 +51,11 @@ open class DownloadAPIManager: NSObject { delegateQueue: OperationQueue() ) } - - public func downloadRequest( +} + +// MARK: Public API +public extension DownloadAPIManager { + func downloadRequest( _ endpoint: Requestable, resumableData: Data? = nil, retryConfiguration: RetryConfiguration? @@ -62,7 +65,7 @@ open class DownloadAPIManager: NSObject { return try await downloadRequest(endpointRequest, resumableData: resumableData, retryConfiguration: retryConfiguration) } - public func progressStream(for task: URLSessionTask) -> AsyncStream { + func progressStream(for task: URLSessionTask) -> AsyncStream { AsyncStream { continuation in let cancellable = downloadStateDictSubject .sink(receiveValue: { dict in @@ -86,6 +89,7 @@ open class DownloadAPIManager: NSObject { } } +// MARK: Private private extension DownloadAPIManager { func downloadRequest( _ endpointRequest: EndpointRequest, @@ -126,7 +130,11 @@ private extension DownloadAPIManager { } 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) + try await sleepIfRetry( + for: error, + endpointRequest: endpointRequest, + retryConfiguration: retryConfiguration + ) return try await downloadRequest( endpointRequest, @@ -159,36 +167,9 @@ private extension DownloadAPIManager { } } } - - /// 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) * 1_000_000_000 - case .progressive(let timeInterval): - sleepDuration = UInt64(timeInterval) * UInt64(retryCount) * 1_000_000_000 - } - - try await Task.sleep(nanoseconds: sleepDuration) - } } +// MARK: URLSession Delegate extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { downloadStateDict[downloadTask]?.totalBytesWritten = totalBytesWritten @@ -206,6 +187,7 @@ extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { } } +// MARK: URLSessionTask + asyncResponse private extension URLSessionTask { func asyncResponse() async throws -> URLResponse { var cancellable: AnyCancellable? 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) + } +} From 880aec15e7a23c564b3af188d77deebefd293f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Fri, 12 May 2023 10:26:41 +0200 Subject: [PATCH 08/79] chore: code cleaup, refactor --- .../project.pbxproj | 28 +++++-- .../API/Routers/SampleDownloadRouter.swift | 14 ---- .../NetworkingSampleApp/ContentView.swift | 10 +-- .../DownloadAPIManager+instance.swift | 32 ++++++++ ...adRow.swift => DownloadProgressView.swift} | 16 ++-- .../Download/DownloadProgressViewModel.swift | 75 +++++++++++++++++++ .../Download/DownloadRowViewModel.swift | 67 ----------------- .../Scenes/Download/DownloadsView.swift | 2 +- .../Scenes/Download/DownloadsViewModel.swift | 24 ------ .../Networking/Core/DownloadAPIManager.swift | 14 ++-- .../Networking/Core/DownloadAPIManaging.swift | 34 +++++++++ .../Core/Requestable+Convenience.swift | 8 +- ...ift => URLSessionTask+DownloadState.swift} | 12 +-- 13 files changed, 189 insertions(+), 147 deletions(-) create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+instance.swift rename NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/{DownloadRow.swift => DownloadProgressView.swift} (71%) create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift delete mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift create mode 100644 Sources/Networking/Core/DownloadAPIManaging.swift rename Sources/Networking/Misc/{DownloadState.swift => URLSessionTask+DownloadState.swift} (66%) diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 41481a37..95f87370 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -21,14 +21,15 @@ 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 */; }; - 58C3E76329B7D6C1004FD1CD /* DownloadRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76229B7D6C1004FD1CD /* DownloadRow.swift */; }; - 58C3E76529B7D709004FD1CD /* DownloadRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76429B7D709004FD1CD /* DownloadRowViewModel.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+instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+instance.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 */; }; @@ -53,14 +54,15 @@ 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 = ""; }; - 58C3E76229B7D6C1004FD1CD /* DownloadRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadRow.swift; sourceTree = ""; }; - 58C3E76429B7D709004FD1CD /* DownloadRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadRowViewModel.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+instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadAPIManager+instance.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 = ""; }; @@ -101,6 +103,7 @@ 23A575B325F8B9DA00617551 /* NetworkingSampleApp */ = { isa = PBXGroup; children = ( + DD6E48742A0E2CC70025AD05 /* Extensions */, 58FB80CC29895A8D0031FC59 /* Resources */, 23EA9CE7292FB70A00B8E418 /* API */, 23A575ED25F8BF0E00617551 /* Scenes */, @@ -193,10 +196,10 @@ 58C3E75B29B78ED3004FD1CD /* Download */ = { isa = PBXGroup; children = ( + DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */, 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */, - 58C3E76229B7D6C1004FD1CD /* DownloadRow.swift */, 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */, - 58C3E76429B7D709004FD1CD /* DownloadRowViewModel.swift */, + 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */, ); path = Download; sourceTree = ""; @@ -218,6 +221,14 @@ path = Resources; sourceTree = ""; }; + DD6E48742A0E2CC70025AD05 /* Extensions */ = { + isa = PBXGroup; + children = ( + DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+instance.swift */, + ); + path = Extensions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -294,13 +305,14 @@ buildActionMask = 2147483647; files = ( 58E4E0EF29843B42000ACBC0 /* NetworkingSampleApp.swift in Sources */, + DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+instance.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 /* DownloadRowViewModel.swift in Sources */, + 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */, 23EA9CF9292FB70A00B8E418 /* SampleUserResponse.swift in Sources */, DDD3AD1F2950E794006CB777 /* SampleAuthRouter.swift in Sources */, DD887780293E33850065ED03 /* SampleErrorProcessor.swift in Sources */, @@ -309,11 +321,11 @@ 58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */, 23EA9CF4292FB70A00B8E418 /* SampleUserRouter.swift in Sources */, 23EA9CF5292FB70A00B8E418 /* SampleAPIConstants.swift in Sources */, - 58C3E76329B7D6C1004FD1CD /* DownloadRow.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 index b81914b6..850f4794 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleDownloadRouter.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleDownloadRouter.swift @@ -25,18 +25,4 @@ enum SampleDownloadRouter: Requestable { return "" } } - - var urlParameters: [String: Any]? { - switch self { - case .download: - return nil - } - } - - var method: HTTPMethod { - switch self { - case .download: - return .get - } - } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift index c9af2c20..9238bea9 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift @@ -7,7 +7,7 @@ import SwiftUI -enum NetworkingCase: String, Hashable, CaseIterable { +enum NetworkingFeature: String, Hashable, CaseIterable { case authorization case downloads } @@ -16,13 +16,13 @@ struct ContentView: View { var body: some View { NavigationStack { List { - ForEach(NetworkingCase.allCases, id: \.self) { screen in - NavigationLink(screen.rawValue.capitalized, value: screen) + 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: diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+instance.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+instance.swift new file mode 100644 index 00000000..1e45c7f1 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+instance.swift @@ -0,0 +1,32 @@ +// +// DownloadAPIManager+instance.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/Scenes/Download/DownloadRow.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift similarity index 71% rename from NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift rename to NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift index cdb64152..29a799bf 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRow.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift @@ -7,26 +7,26 @@ import SwiftUI -struct DownloadRow: View { - @StateObject var viewModel: DownloadRowViewModel +struct DownloadProgressView: View { + @StateObject var viewModel: DownloadProgressViewModel var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(viewModel.title) + Text(viewModel.state.title) .padding(.bottom, 8) - Text("Status: \(viewModel.statusTitle)") - Text("\(String(format: "%.1f", viewModel.percentCompleted))% of \(String(format: "%.1f", viewModel.totalMegaBytes))MB") + Text("Status: \(viewModel.state.statusTitle)") + Text("\(String(format: "%.1f", viewModel.state.percentCompleted))% of \(String(format: "%.1f", viewModel.state.totalMegaBytes))MB") - if let errorTitle = viewModel.errorTitle { + if let errorTitle = viewModel.state.errorTitle { Text("Error: \(errorTitle)") } - if let fileURL = viewModel.fileURL { + if let fileURL = viewModel.state.fileURL { Text("FileURL: \(fileURL)") } - if viewModel.status != .completed { + if viewModel.state.status != .completed { HStack { Button { viewModel.suspend() diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift new file mode 100644 index 00000000..39938e30 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift @@ -0,0 +1,75 @@ +// +// DownloadRowViewModel.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 07.03.2023. +// + +import SwiftUI +import Networking + +final class DownloadProgressViewModel: ObservableObject { + private let task: URLSessionTask + + @Published var state: DownloadProgressState = .init() + + init(task: URLSessionTask) { + self.task = task + } + + func onAppear() { + Task { + let stream = DownloadAPIManager.shared.progressStream(for: task) + + for try await downloadState in stream { + DispatchQueue.main.async { [weak self] in + 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 = self?.task.currentRequest?.url?.absoluteString ?? "-" + self?.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/DownloadRowViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift deleted file mode 100644 index 0c9294e5..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadRowViewModel.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// DownloadRowViewModel.swift -// NetworkingSampleApp -// -// Created by Matej Molnár on 07.03.2023. -// - -import SwiftUI -import Networking - -final class DownloadRowViewModel: ObservableObject { - private let task: URLSessionTask - - @Published var title: String = "" - @Published var status: URLSessionTask.State = .running - @Published var statusTitle: String = "" - @Published var percentCompleted: Double = 0 - @Published var totalMegaBytes: Double = 0 - @Published var errorTitle: String? - @Published var fileURL: String? - - init(task: URLSessionTask) { - self.task = task - title = task.currentRequest?.url?.absoluteString ?? "-" - } - - func onAppear() { - Task { - let stream = DownloadAPIManager.shared.progressStream(for: task) - - for try await downloadState in stream { - DispatchQueue.main.async { [weak self] in - self?.percentCompleted = downloadState.fractionCompleted * 100 - self?.totalMegaBytes = Double(downloadState.totalBytesExpectedToWrite) / 1_000_000 - self?.status = downloadState.taskState - self?.statusTitle = downloadState.taskState.title - self?.errorTitle = downloadState.error?.localizedDescription - self?.fileURL = downloadState.downloadedFileURL?.absoluteString - } - } - } - } - - func suspend() { - task.suspend() - } - - func resume() { - task.resume() - } - - func cancel() { - task.cancel() - } -} - -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 index 3e78b85a..5d0365ff 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift @@ -31,7 +31,7 @@ struct DownloadsView: View { ScrollView { LazyVStack { ForEach(viewModel.tasks, id: \.taskIdentifier) { task in - DownloadRow(viewModel: .init(task: task)) + DownloadProgressView(viewModel: .init(task: task)) } } .padding(.vertical, 5) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift index 38ecf97d..92623cc5 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -9,30 +9,6 @@ import Foundation import Networking import OSLog -extension DownloadAPIManager { - static var shared: DownloadAPIManager = { - 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 - ) - }() -} - @MainActor final class DownloadsViewModel: ObservableObject { @Published var tasks: [URLSessionTask] = [] diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index 83877cbd..633289df 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -54,18 +54,18 @@ open class DownloadAPIManager: NSObject, Retryable { } // MARK: Public API -public extension DownloadAPIManager { - func downloadRequest( +extension DownloadAPIManager: DownloadAPIManaging { + public func downloadRequest( _ endpoint: Requestable, resumableData: Data? = nil, retryConfiguration: RetryConfiguration? - ) async throws -> (URLSessionDownloadTask, Response) { + ) async throws -> DownloadResult { /// create identifiable request from endpoint let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) return try await downloadRequest(endpointRequest, resumableData: resumableData, retryConfiguration: retryConfiguration) } - func progressStream(for task: URLSessionTask) -> AsyncStream { + public func progressStream(for task: URLSessionTask) -> AsyncStream { AsyncStream { continuation in let cancellable = downloadStateDictSubject .sink(receiveValue: { dict in @@ -95,7 +95,7 @@ private extension DownloadAPIManager { _ endpointRequest: EndpointRequest, resumableData: Data?, retryConfiguration: RetryConfiguration? - ) async throws -> (URLSessionDownloadTask, Response) { + ) async throws -> DownloadResult { do { /// create original url request let originalRequest = try endpointRequest.endpoint.asRequest() @@ -172,8 +172,8 @@ private extension DownloadAPIManager { // MARK: URLSession Delegate extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - downloadStateDict[downloadTask]?.totalBytesWritten = totalBytesWritten - downloadStateDict[downloadTask]?.totalBytesExpectedToWrite = totalBytesExpectedToWrite + downloadStateDict[downloadTask]?.downloadedBytes = totalBytesWritten + downloadStateDict[downloadTask]?.totalBytes = totalBytesExpectedToWrite } public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { diff --git a/Sources/Networking/Core/DownloadAPIManaging.swift b/Sources/Networking/Core/DownloadAPIManaging.swift new file mode 100644 index 00000000..d229e4d0 --- /dev/null +++ b/Sources/Networking/Core/DownloadAPIManaging.swift @@ -0,0 +1,34 @@ +// +// DownloadAPIManaging.swift +// +// +// Created by Dominika Gajdová on 12.05.2023. +// + +import Foundation + +// MARK: - Defines Download API managing +public typealias DownloadResult = (URLSessionDownloadTask, Response) + +public protocol DownloadAPIManaging { + var allTasks: [URLSessionDownloadTask] { get async } + + func downloadRequest( + _ endpoint: Requestable, + resumableData: Data?, + retryConfiguration: RetryConfiguration? + ) async throws -> DownloadResult + + 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 866c5c14..3cd26e75 100644 --- a/Sources/Networking/Core/Requestable+Convenience.swift +++ b/Sources/Networking/Core/Requestable+Convenience.swift @@ -48,13 +48,7 @@ public extension Requestable { public extension Requestable { func urlComponents() throws -> URLComponents { // url creation - let urlPath = { - if path.isEmpty { - return baseURL - } else { - return 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/Misc/DownloadState.swift b/Sources/Networking/Misc/URLSessionTask+DownloadState.swift similarity index 66% rename from Sources/Networking/Misc/DownloadState.swift rename to Sources/Networking/Misc/URLSessionTask+DownloadState.swift index 172ad31c..239d19e5 100644 --- a/Sources/Networking/Misc/DownloadState.swift +++ b/Sources/Networking/Misc/URLSessionTask+DownloadState.swift @@ -9,8 +9,8 @@ import Foundation public extension URLSessionTask { struct DownloadState { - public var totalBytesWritten: Int64 - public var totalBytesExpectedToWrite: Int64 + public var downloadedBytes: Int64 + public var totalBytes: Int64 public var taskState: URLSessionTask.State public var error: Error? public var downloadedFileURL: URL? @@ -19,16 +19,16 @@ public extension URLSessionTask { (error as? URLError)?.userInfo[NSURLSessionDownloadTaskResumeData] as? Data } public var fractionCompleted: Double { - guard totalBytesExpectedToWrite > 0 else { + guard totalBytes > 0 else { return 0 } - return Double(totalBytesWritten)/Double(totalBytesExpectedToWrite) + return Double(downloadedBytes)/Double(totalBytes) } public init(task: URLSessionTask) { - totalBytesWritten = task.countOfBytesReceived - totalBytesExpectedToWrite = task.countOfBytesExpectedToReceive + downloadedBytes = task.countOfBytesReceived + totalBytes = task.countOfBytesExpectedToReceive taskState = task.state error = task.error downloadedFileURL = nil From 3ac8b59c28fe1d3e777042336afb025579569c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Fri, 12 May 2023 11:12:09 +0200 Subject: [PATCH 09/79] chore: added documentation --- .../Networking/Core/DownloadAPIManager.swift | 141 +++++++----------- .../Networking/Core/DownloadAPIManaging.swift | 12 ++ .../Misc/URLSessionTask+asyncResponse.swift | 40 +++++ 3 files changed, 109 insertions(+), 84 deletions(-) create mode 100644 Sources/Networking/Misc/URLSessionTask+asyncResponse.swift diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index 633289df..7458e150 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -65,6 +65,8 @@ extension DownloadAPIManager: DownloadAPIManaging { 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 @@ -92,62 +94,65 @@ extension DownloadAPIManager: DownloadAPIManaging { // 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( + _ 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( + ) + + return try await downloadRequest( endpointRequest, resumableData: resumableData, retryConfiguration: retryConfiguration - ) - } catch { - /// error processing - throw await errorProcessors.process(error, for: endpointRequest) - } - } - } - + ) + } 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 downloadStateDict[task] == nil { @@ -175,46 +180,14 @@ extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { downloadStateDict[downloadTask]?.downloadedBytes = totalBytesWritten downloadStateDict[downloadTask]?.totalBytes = totalBytesExpectedToWrite } - + public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { downloadStateDict[downloadTask]?.downloadedFileURL = location updateTasks() } - + public func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { downloadStateDict[task]?.error = error updateTasks() } } - -// MARK: URLSessionTask + asyncResponse -private 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) - } - - if let response { - continuation.resume(returning: response) - } - } - } - }, - onCancel: { [cancellable] in - cancellable?.cancel() - }) - } -} diff --git a/Sources/Networking/Core/DownloadAPIManaging.swift b/Sources/Networking/Core/DownloadAPIManaging.swift index d229e4d0..01c118c7 100644 --- a/Sources/Networking/Core/DownloadAPIManaging.swift +++ b/Sources/Networking/Core/DownloadAPIManaging.swift @@ -10,15 +10,27 @@ import Foundation // MARK: - Defines Download API managing public typealias DownloadResult = (URLSessionDownloadTask, Response) +/// A definition of an API layer with methods for handling data downloading. public protocol DownloadAPIManaging { + /// List of all currently ongoing download tasks. var allTasks: [URLSessionDownloadTask] { get async } + /// 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 } diff --git a/Sources/Networking/Misc/URLSessionTask+asyncResponse.swift b/Sources/Networking/Misc/URLSessionTask+asyncResponse.swift new file mode 100644 index 00000000..2a6032cb --- /dev/null +++ b/Sources/Networking/Misc/URLSessionTask+asyncResponse.swift @@ -0,0 +1,40 @@ +// +// File.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) + } + + if let response { + continuation.resume(returning: response) + } + } + } + }, + onCancel: { [cancellable] in + cancellable?.cancel() + }) + } +} From 29296ea3809ab12c6cb79212ac64e53eb53f71a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 25 May 2023 10:06:47 +0200 Subject: [PATCH 10/79] chore: code cleanup, thread safe dictionary --- .../project.pbxproj | 8 +- ...> DownloadAPIManager+SharedInstance.swift} | 2 +- .../Download/DownloadProgressView.swift | 89 +++++++++++-------- .../Download/DownloadProgressViewModel.swift | 33 ++++--- .../Scenes/Download/DownloadsView.swift | 4 +- .../Scenes/Download/DownloadsViewModel.swift | 16 ++-- .../Networking/Core/DownloadAPIManager.swift | 62 ++++++++----- .../Misc/ThreadSafeDictionary.swift | 37 ++++++++ ...ift => URLSessionTask+AsyncResponse.swift} | 5 +- 9 files changed, 163 insertions(+), 93 deletions(-) rename NetworkingSampleApp/NetworkingSampleApp/Extensions/{DownloadAPIManager+instance.swift => DownloadAPIManager+SharedInstance.swift} (95%) create mode 100644 Sources/Networking/Misc/ThreadSafeDictionary.swift rename Sources/Networking/Misc/{URLSessionTask+asyncResponse.swift => URLSessionTask+AsyncResponse.swift} (94%) diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 95f87370..fbc5bfa5 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -29,7 +29,7 @@ 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+instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+instance.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 */; }; @@ -62,7 +62,7 @@ 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+instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadAPIManager+instance.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 = ""; }; @@ -224,7 +224,7 @@ DD6E48742A0E2CC70025AD05 /* Extensions */ = { isa = PBXGroup; children = ( - DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+instance.swift */, + DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */, ); path = Extensions; sourceTree = ""; @@ -305,7 +305,7 @@ buildActionMask = 2147483647; files = ( 58E4E0EF29843B42000ACBC0 /* NetworkingSampleApp.swift in Sources */, - DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+instance.swift in Sources */, + DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */, DDE8884529476AC300DD3BFF /* SampleRefreshTokenRequest.swift in Sources */, DDD3AD212951F527006CB777 /* SampleAuthorizationManager.swift in Sources */, 23EA9CF7292FB70A00B8E418 /* SampleUserAuthResponse.swift in Sources */, diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+instance.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift similarity index 95% rename from NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+instance.swift rename to NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift index 1e45c7f1..0364dacb 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+instance.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift @@ -1,5 +1,5 @@ // -// DownloadAPIManager+instance.swift +// DownloadAPIManager+SharedInstance.swift // NetworkingSampleApp // // Created by Dominika Gajdová on 12.05.2023. diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift index 29a799bf..162f9f92 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift @@ -1,5 +1,5 @@ // -// DownloadRow.swift +// DownloadProgressView.swift // NetworkingSampleApp // // Created by Matej Molnár on 07.03.2023. @@ -12,41 +12,10 @@ struct DownloadProgressView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - 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)") - } - - if viewModel.state.status != .completed { - HStack { - Button { - viewModel.suspend() - } label: { - Text("Suspend") - } - - Button { - viewModel.resume() - } label: { - Text("Resume") - } - - Button { - viewModel.cancel() - } label: { - Text("Cancel") - } - } - } + content + } + .task { + await viewModel.startObservingDownloadProgress() } .padding(10) .background( @@ -55,8 +24,52 @@ struct DownloadProgressView: View { .shadow(radius: 10) ) .padding(15) - .onAppear { - viewModel.onAppear() + } +} + +// 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 index 39938e30..c1b0885f 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift @@ -1,13 +1,14 @@ // -// DownloadRowViewModel.swift +// DownloadProgressViewModel.swift // NetworkingSampleApp // // Created by Matej Molnár on 07.03.2023. // -import SwiftUI +import Foundation import Networking +@MainActor final class DownloadProgressViewModel: ObservableObject { private let task: URLSessionTask @@ -17,23 +18,19 @@ final class DownloadProgressViewModel: ObservableObject { self.task = task } - func onAppear() { - Task { - let stream = DownloadAPIManager.shared.progressStream(for: task) + func startObservingDownloadProgress() async { + let stream = DownloadAPIManager.shared.progressStream(for: task) - for try await downloadState in stream { - DispatchQueue.main.async { [weak self] in - 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 = self?.task.currentRequest?.url?.absoluteString ?? "-" - self?.state = newState - } - } + 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 } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift index 5d0365ff..abd14cf5 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift @@ -18,9 +18,7 @@ struct DownloadsView: View { .textFieldStyle(.roundedBorder) Button { - Task { - await viewModel.download() - } + viewModel.startDownload() } label: { Text("Download") } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift index 92623cc5..3d483899 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -14,14 +14,20 @@ final class DownloadsViewModel: ObservableObject { @Published var tasks: [URLSessionTask] = [] @Published var urlText: String = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" private let downloadAPIManager = DownloadAPIManager.shared - - func onAppear() { + + func startDownload() { + Task { + await downloadItem() + } + Task { - tasks = await downloadAPIManager.allTasks + await downloadItem() } } - - func download() async { +} + +private extension DownloadsViewModel { + func downloadItem() async { guard let url = URL(string: urlText) else { return } diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index 7458e150..f2d7606e 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -13,17 +13,14 @@ open class DownloadAPIManager: NSObject, Retryable { private let requestAdapters: [RequestAdapting] private let responseProcessors: [ResponseProcessing] private let errorProcessors: [ErrorProcessing] - private var urlSession: URLSession! private let sessionId: String - private var taskStateCancellables: [URLSessionTask: AnyCancellable] = [:] private let downloadStateDictSubject = CurrentValueSubject<[URLSessionTask: URLSessionTask.DownloadState], Never>([:]) - private var downloadStateDict = [URLSessionTask: URLSessionTask.DownloadState]() { - didSet { - downloadStateDictSubject.send(downloadStateDict) - } - } + private var urlSession: URLSession! + 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 } @@ -50,6 +47,11 @@ open class DownloadAPIManager: NSObject, Retryable { delegate: self, delegateQueue: OperationQueue() ) + + Task { + /// Publish initial download states value. + downloadStateDictSubject.send(await downloadStateDict.getValues()) + } } } @@ -77,9 +79,7 @@ extension DownloadAPIManager: DownloadAPIManaging { continuation.yield(downloadState) - if downloadState.error != nil || - downloadState.downloadedFileURL != nil - { + if downloadState.error != nil || downloadState.downloadedFileURL != nil { continuation.finish() } }) @@ -155,20 +155,29 @@ private extension DownloadAPIManager { /// which then leads to a task state update from `progressStream`. func updateTasks() { Task { - for task in await allTasks where downloadStateDict[task] == nil { + 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. - downloadStateDict[task] = .init(task: task) + 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. - taskStateCancellables[task] = task + let cancellable = task .publisher(for: \.state) .sink { [weak self] state in - self?.downloadStateDict[task]?.taskState = state + guard let self else { + return + } - if state == .completed { - self?.taskStateCancellables[task] = nil + 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) } } } @@ -177,17 +186,26 @@ private extension DownloadAPIManager { // MARK: URLSession Delegate extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - downloadStateDict[downloadTask]?.downloadedBytes = totalBytesWritten - downloadStateDict[downloadTask]?.totalBytes = totalBytesExpectedToWrite + 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) { - downloadStateDict[downloadTask]?.downloadedFileURL = location - updateTasks() + 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?) { - downloadStateDict[task]?.error = error - updateTasks() + Task { + await downloadStateDict.update(task: task, for: \.error, with: error) + downloadStateDictSubject.send(await downloadStateDict.getValues()) + updateTasks() + } } } 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 similarity index 94% rename from Sources/Networking/Misc/URLSessionTask+asyncResponse.swift rename to Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift index 2a6032cb..05295001 100644 --- a/Sources/Networking/Misc/URLSessionTask+asyncResponse.swift +++ b/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift @@ -1,5 +1,5 @@ // -// File.swift +// URLSessionTask+AsyncResponse.swift // // // Created by Dominika Gajdová on 12.05.2023. @@ -35,6 +35,7 @@ extension URLSessionTask { }, onCancel: { [cancellable] in cancellable?.cancel() - }) + } + ) } } From a677982e2a1b7ba3d273e791c48ca1fad3d84794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 25 May 2023 10:10:47 +0200 Subject: [PATCH 11/79] chore: code cleanup --- Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift b/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift index 05295001..8451765d 100644 --- a/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift +++ b/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift @@ -25,9 +25,7 @@ extension URLSessionTask { .sink { (response, error) in if let error { continuation.resume(throwing: error) - } - - if let response { + } else if let response { continuation.resume(returning: response) } } From d6599eabb8ab3a04d359c4782803e27b9808e6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 25 May 2023 15:55:16 +0200 Subject: [PATCH 12/79] chore: add support for manual url session invalidation for download manager --- Sources/Networking/Core/DownloadAPIManager.swift | 8 ++++++++ Sources/Networking/Core/DownloadAPIManaging.swift | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index f2d7606e..dfe46d0c 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -57,6 +57,14 @@ open class DownloadAPIManager: NSObject, Retryable { // 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, diff --git a/Sources/Networking/Core/DownloadAPIManaging.swift b/Sources/Networking/Core/DownloadAPIManaging.swift index 01c118c7..cc7cc7ec 100644 --- a/Sources/Networking/Core/DownloadAPIManaging.swift +++ b/Sources/Networking/Core/DownloadAPIManaging.swift @@ -15,6 +15,11 @@ 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. From 4f25236e5d46f94c4e9c0c84d4e543fe40be206d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 25 May 2023 16:57:49 +0200 Subject: [PATCH 13/79] chore: added recommendation doc to download managing --- Sources/Networking/Core/DownloadAPIManaging.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Networking/Core/DownloadAPIManaging.swift b/Sources/Networking/Core/DownloadAPIManaging.swift index cc7cc7ec..9edf7e1a 100644 --- a/Sources/Networking/Core/DownloadAPIManaging.swift +++ b/Sources/Networking/Core/DownloadAPIManaging.swift @@ -11,6 +11,8 @@ import Foundation 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 } From fcea64f0b03ec03aafae8dba44a79b09caf7848d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Tue, 30 May 2023 17:32:16 +0200 Subject: [PATCH 14/79] chore: keep only one download in the sample viewmodel --- .../Scenes/Download/DownloadsViewModel.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift index 3d483899..ec1c414e 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -19,10 +19,6 @@ final class DownloadsViewModel: ObservableObject { Task { await downloadItem() } - - Task { - await downloadItem() - } } } From 3dfc0839699d1c8306105e553cce29ec04c9033d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Wed, 31 May 2023 08:39:44 +0200 Subject: [PATCH 15/79] code cleanup --- .../NetworkingSampleApp/API/SampleAPIConstants.swift | 1 + .../Scenes/Download/DownloadsViewModel.swift | 2 +- Sources/Networking/Core/DownloadAPIManager.swift | 11 +++-------- 3 files changed, 5 insertions(+), 9 deletions(-) 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/Scenes/Download/DownloadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift index ec1c414e..ba616989 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -12,7 +12,7 @@ import OSLog @MainActor final class DownloadsViewModel: ObservableObject { @Published var tasks: [URLSessionTask] = [] - @Published var urlText: String = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" + @Published var urlText: String = SampleAPIConstants.videoUrl private let downloadAPIManager = DownloadAPIManager.shared func startDownload() { diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index dfe46d0c..dabc7c17 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -15,7 +15,7 @@ open class DownloadAPIManager: NSObject, Retryable { private let errorProcessors: [ErrorProcessing] private let sessionId: String private let downloadStateDictSubject = CurrentValueSubject<[URLSessionTask: URLSessionTask.DownloadState], Never>([:]) - private var urlSession: URLSession! + private var urlSession = URLSession(configuration: .default) private var taskStateCancellables = ThreadSafeDictionary() private var downloadStateDict = ThreadSafeDictionary() @@ -39,7 +39,7 @@ open class DownloadAPIManager: NSObject, Retryable { self.requestAdapters = requestAdapters self.responseProcessors = responseProcessors self.errorProcessors = errorProcessors - + super.init() urlSession = URLSession( @@ -47,11 +47,6 @@ open class DownloadAPIManager: NSObject, Retryable { delegate: self, delegateQueue: OperationQueue() ) - - Task { - /// Publish initial download states value. - downloadStateDictSubject.send(await downloadStateDict.getValues()) - } } } @@ -75,7 +70,7 @@ extension DownloadAPIManager: DownloadAPIManaging { return try await downloadRequest(endpointRequest, resumableData: resumableData, retryConfiguration: retryConfiguration) } - /// Creates an async stream of download state updates for a given task. ] + /// 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 From f00c16c15a320bc599ca8a4e4c889c8f66bb52d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Mon, 5 Jun 2023 16:15:07 +0200 Subject: [PATCH 16/79] Update Sources/Networking/Core/DownloadAPIManaging.swift Co-authored-by: Tony Ngo --- Sources/Networking/Core/DownloadAPIManaging.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Networking/Core/DownloadAPIManaging.swift b/Sources/Networking/Core/DownloadAPIManaging.swift index 9edf7e1a..bf3dbd5a 100644 --- a/Sources/Networking/Core/DownloadAPIManaging.swift +++ b/Sources/Networking/Core/DownloadAPIManaging.swift @@ -20,7 +20,7 @@ public protocol DownloadAPIManaging { /// 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) + func invalidateSession(shouldFinishTasks: Bool) /// Initiates a download request for a given endpoint, with optional resumable data and retry configuration. /// - Parameters: From 475af9795677b61fe39bb83ee71262f7cd33083e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Mon, 5 Jun 2023 16:26:42 +0200 Subject: [PATCH 17/79] chore: added docc files --- README.md | 14 +++++++------- .../Networking/Documentation.docc/Documentation.md | 5 +++++ 2 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 Sources/Networking/Documentation.docc/Documentation.md diff --git a/README.md b/README.md index 5d249257..15c7c7ec 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,15 @@ The lightweight library for API calls management. The library is built upon URL ## Schedule [X] init library with sample app - [ ] logger modifier (in draft) + [X] logger modifier (in draft) - [ ] data storing modifier + [X] data storing modifier - [ ] authentication modifier with defaul solution with access + refresh tokens + [X] authentication modifier with default solution with access + refresh tokens - [ ] multipeer connection modifier (low priority, last one to do) + [X] multipeer connection modifier (low priority, last one to do) - [ ] APIManager ui tests + [ ] APIManager unit tests [ ] more complex samples in sample app (parallel tasks, retry, ...) @@ -22,6 +22,6 @@ The lightweight library for API calls management. The library is built upon URL [ ] sample usage of storaged api calls for UI testing - [ ] use library in iWeather example app + [X] use library in iWeather example app - [ ] other data types - file upload/download with progres etc + [ ] other data types - file upload/download with progress etc diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md new file mode 100644 index 00000000..0445290f --- /dev/null +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -0,0 +1,5 @@ +# Networking + +A networking layer using native `UrlSession`. + +## Overview From 5010fe6d914bd6f24b2c9eea1565cba5e3ba38e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Mon, 5 Jun 2023 16:27:58 +0200 Subject: [PATCH 18/79] fix: argument label --- Sources/Networking/Core/DownloadAPIManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index dabc7c17..1f0d8914 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -52,7 +52,7 @@ open class DownloadAPIManager: NSObject, Retryable { // MARK: Public API extension DownloadAPIManager: DownloadAPIManaging { - public func invalidateSession(_ shouldFinishTasks: Bool = false) { + public func invalidateSession(shouldFinishTasks: Bool = false) { if shouldFinishTasks { urlSession.invalidateAndCancel() } else { From 015ef0511836e5aca738ae94afdbfc1b9e743f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Tue, 6 Jun 2023 14:16:14 +0200 Subject: [PATCH 19/79] chore: intro to documentation --- .../Documentation.docc/Documentation.md | 148 +++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index 0445290f..aac5e4e8 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -1,5 +1,151 @@ # Networking -A networking layer using native `UrlSession`. +A networking layer using native `UrlSession` and Swift concurrency. ## Overview +Heavily inspired by Moya, the networking layer's philosophy is focused on creating individual endpoint routers, transforming them into a valid URLRequest objects and applying optional interceptors and processors in the network call pipeline. + +## Router +By conforming to the ``Requestable`` protocol, you can define endpoint definitions containing the elementary HTTP request components necessary to create valid HTTP requests. +
**Recommendation:** Follow the `Router` naming convention to explicitly indicate the usage of a router pattern. + +### Example +``` +enum UserRouter { + case getUser + case updateUser(UpdateUserRequest) +} + +extension UserRouter: Requestable { + // The base URL address used for the HTTP call. + var baseURL: URL { + URL(string: Constants.baseHost)! + } + + // Path will be appended to the base URL. + var path: String { + switch self { + case .getUser, .updateUser: + return "/user" + } + } + + // HTTPMethod used for each endpoint. + var method: HTTPMethod { + switch self { + case .getUser: + return .get + case .updateUser: + return .post + } + } + + // Optional body data encoded in JSON by default. + var dataType: RequestDataType? { + switch self { + case .getUser: + return nil + case let .updateUser(data): + return .encodable(data) + } + } + + // Optional authentication requirement if AuthorizationInterceptor is used. + var isAuthenticationRequired: Bool { + switch self { + case .getUser, .updateUser: + return true + } + } +} +``` + +Some of the properties have default implementations defined in the `Requestable+Convenience` extension. + +## APIManager +APIManager is responsible for the creation and management of a network call. It conforms to the ``APIManaging`` protocol which allows you to define your own custom APIManager if needed. + +There are two ways to initialise the ``APIManager`` object: +1. Using URLSession as the response provider. +``` +init( + urlSession: URLSession = .init(configuration: .default), + requestAdapters: [RequestAdapting] = [], + responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], + errorProcessors: [ErrorProcessing] = [] +) +``` + +2. Using custom response provider by conforming to ``ResponseProviding``. +``` +init( + responseProvider: ResponseProviding, + requestAdapters: [RequestAdapting] = [], + responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], + errorProcessors: [ErrorProcessing] = [] +) +``` + +There are two methods provided by the ``APIManaging`` protocol: + +1. Result is URLSession's default (data, response) tuple. +``` +func request( + _ endpoint: Requestable, + retryConfiguration: RetryConfiguration? +) async throws -> Response +``` +2. Result is custom decodable object. +``` +func request( + _ endpoint: Requestable, + decoder: JSONDecoder, + retryConfiguration: RetryConfiguration? +) async throws -> DecodableResponse +``` + +### Example +In the most simple form, the network request looks like this: + +``` +try await apiManager.request(UserRouter.getUser) +``` + +If you specify object type, the APIManager will automatically perform the decoding (given the received JSON correctly maps to the decodable). You can also specify a custom json decoder. + +``` +let userResponse: UserResponse = try await apiManager.request(UserRouter.getUser) +``` + +Provide a custom after failure ``RetryConfiguration``, specifying the count of retries, delay and a handler that determines whether the request should be tried again. Otherwise, ``RetryConfiguration/default`` configuration is used. + +``` +let retryConfiguration = RetryConfiguration(retries: 2, delay: .constant(1)) { error in + // custom logic here +} +let userResponse: UserResponse = try await apiManager.request( + UserRouter.getUser, + retryConfiguration: retryConfiguration +) +``` + +## DownloadAPIManager +DownloadAPIManager is responsible for the creation and management of a network file download. It conforms to the ``DownloadAPIManaging`` protocol which allows you to define your own custom DownloadAPIManager if needed. + + + +## Retry ability + +## Interceptors + +### Authorization + +### Logging + +## Processors + +### Status Code + +### Storage + +### Multipeer Connectivity From 7ce74074a81e19ff5a427a409c0976841130eb3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 8 Jun 2023 10:15:08 +0200 Subject: [PATCH 20/79] chore: download manager docc --- .../Documentation.docc/Documentation.md | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index aac5e4e8..419187eb 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -7,7 +7,7 @@ Heavily inspired by Moya, the networking layer's philosophy is focused on creati ## Router By conforming to the ``Requestable`` protocol, you can define endpoint definitions containing the elementary HTTP request components necessary to create valid HTTP requests. -
**Recommendation:** Follow the `Router` naming convention to explicitly indicate the usage of a router pattern. +
**Recommendation:** Follow the `Router` naming convention to explicitly indicate the usage of a router pattern. ### Example ``` @@ -85,6 +85,7 @@ init( errorProcessors: [ErrorProcessing] = [] ) ``` +Adapters and processors are passed during initialisation and cannot be changed afterwards. There are two methods provided by the ``APIManaging`` protocol: @@ -130,13 +131,52 @@ let userResponse: UserResponse = try await apiManager.request( ``` ## DownloadAPIManager -DownloadAPIManager is responsible for the creation and management of a network file download. It conforms to the ``DownloadAPIManaging`` protocol which allows you to define your own custom DownloadAPIManager if needed. +DownloadAPIManager is responsible for the creation and management of a network file download. It conforms to the ``DownloadAPIManaging`` protocol which allows you to define your own custom DownloadAPIManager if needed. Multiple parallel downloads are supported. +The initialisation is equivalent to APIManager, except the session is created for the user based on a given ``URLSessionConfiguration``: +``` +init( + urlSessionConfiguration: URLSessionConfiguration = .default, + requestAdapters: [RequestAdapting] = [], + responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], + errorProcessors: [ErrorProcessing] = [] +) +``` + +Adapters and processors are passed during initialisation and cannot be changed afterwards. + +The DownloadAPIManager contains a public property that enables you to keep track of current tasks in progress. +``` +var allTasks: [URLSessionDownloadTask] { get async } +``` +There are three methods provided by the ``DownloadAPIManaging`` protocol: + +1. Request download for a given endpoint. Returns a standard (URLSessionDownloadTask, Response) result for the HTTP handshake. This result is not the actual downloaded file, but the HTTP response received after the download is initiated. +``` +func downloadRequest( + _ endpoint: Requestable, + resumableData: Data? = nil, + retryConfiguration: RetryConfiguration? +) async throws -> DownloadResult +``` + +2. Get progress async stream for a given task to observe task download progress and state. +``` +func progressStream(for task: URLSessionTask) -> AsyncStream +``` + +The `DownloadState` struct provides you with information about the download itself, including bytes downloaded, total byte size of the file being downloaded or the error if any occurs. +3. Invalidate download session in case DownloadAPIManager is not used as singleton to prevent memory leaks. +``` +func invalidateSession(shouldFinishTasks: Bool = false) +``` +DownloadAPIManager is not deallocated from memory since URLSession is holding a reference to it. If you wish to use new instances of the DownloadAPIManager, don't forget to invalidate the session if it is not needed anymore. ## Retry ability +Both APIManager and DownloadAPIManager allow for configurable retry mechanism. -## Interceptors +## Adapters ### Authorization @@ -146,6 +186,6 @@ DownloadAPIManager is responsible for the creation and management of a network f ### Status Code -### Storage +### Storage ### Multipeer Connectivity From 4daf68eb0573a0cba2f99fbe9b1efe5b1cf1b69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 8 Jun 2023 13:46:52 +0200 Subject: [PATCH 21/79] chore: authorization docc --- .../Documentation.docc/Documentation.md | 68 ++++++++++++++++-- .../Resources/interceptors-diagram.png | Bin 0 -> 19380 bytes 2 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 Sources/Networking/Documentation.docc/Resources/interceptors-diagram.png diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index 419187eb..d332617f 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -3,7 +3,7 @@ A networking layer using native `UrlSession` and Swift concurrency. ## Overview -Heavily inspired by Moya, the networking layer's philosophy is focused on creating individual endpoint routers, transforming them into a valid URLRequest objects and applying optional interceptors and processors in the network call pipeline. +Heavily inspired by Moya, the networking layer's philosophy is focused on creating individual endpoint routers, transforming them into a valid URLRequest objects and applying optional adapters and processors in the network call pipeline. ## Router By conforming to the ``Requestable`` protocol, you can define endpoint definitions containing the elementary HTTP request components necessary to create valid HTTP requests. @@ -133,7 +133,7 @@ let userResponse: UserResponse = try await apiManager.request( ## DownloadAPIManager DownloadAPIManager is responsible for the creation and management of a network file download. It conforms to the ``DownloadAPIManaging`` protocol which allows you to define your own custom DownloadAPIManager if needed. Multiple parallel downloads are supported. -The initialisation is equivalent to APIManager, except the session is created for the user based on a given ``URLSessionConfiguration``: +The initialisation is equivalent to APIManager, except the session is created for the user based on a given `URLSessionConfiguration`: ``` init( urlSessionConfiguration: URLSessionConfiguration = .default, @@ -174,15 +174,71 @@ func invalidateSession(shouldFinishTasks: Bool = false) DownloadAPIManager is not deallocated from memory since URLSession is holding a reference to it. If you wish to use new instances of the DownloadAPIManager, don't forget to invalidate the session if it is not needed anymore. ## Retry ability -Both APIManager and DownloadAPIManager allow for configurable retry mechanism. +Both APIManager and DownloadAPIManager allow for configurable retry mechanism. -## Adapters +``` +let retryConfiguration = RetryConfiguration(retries: 2, delay: .constant(1)) { error in + // custom logic here that determines whether the request should be retried + // e.g you can only retry with 5xx http error codes +} +``` + +## Interceptors +![Interceptors diagram](interceptors-diagram.png) + +There are three types you can leverage: +1. ``RequestAdapting`` +2. ``ResponseProcessing`` +3. ``RequestInterceptor`` + +## Request Interceptors +Adapters are request transformable components that perform operations on the URLRequest before it is dispatched. They are used to further customise HTTP requests before they are carried out by editing the URLRequest (e.g updating headers). -### Authorization ### Logging +Networking provides a default ``LoggingInterceptor`` which internally uses `os_log` to pretty print requests/responses. You can utilise it to get logging console output either for requests, responses or both. + +``` +APIManager( + // + requestAdapters: [LoggingInterceptor.shared], + responseProcessors: [LoggingInterceptor.shared], + errorProcessors: [LoggingInterceptor.shared] + // +) +``` + +### Authorization +Networking provides a default authorization handling for OAuth scenarios. Use the default ``AuthorizationTokenInterceptor`` with the APIManager to obtain the behaviour of JWT Bearer authorization header injection and access token expiration refresh flow. + +Start by implementing an authorization manager by conforming to ``AuthorizationManaging``. This manager requires you to provide storage defined by ``AuthorizationStorageManaging`` (where OAuth credentials will be stored) and a refresh method that will perform the refresh token network call to obtain a new OAuth pair. Optionally, you can provide custom implementations for ``AuthorizationManaging/authorizeRequest(_:)-6azlk`` (by default, this method sets the authorization header) or access token getter (by default, this method returns the access token saved in provided storage). + +``` +let authorizationManager = CustomAuthorizationManager() +let authorizationInterceptor = AuthorizationTokenInterceptor(authorizationManager: authorizationManager) +APIManager( + // + requestAdapters: [authorizationInterceptor], + responseProcessors: [authorizationInterceptor], + // +) +``` + +``` +final class CustomAuthorizationManager: AuthorizationManaging { + let storage: AuthorizationStorageManaging = CustomAuthorizationStorageManager() + + /// For refresh token logic, create a new instance of APIManager + /// without injecting `AuthorizationTokenInterceptor` to avoid cycling in refreshes + private let apiManager: APIManager = APIManager() + + func refreshAuthorizationData(with refreshToken: String) async throws -> Networking.AuthorizationData { + // perform an network request to obtain refresh OAuth credentials + } +} +``` -## Processors +## RequestProcessing ### Status Code diff --git a/Sources/Networking/Documentation.docc/Resources/interceptors-diagram.png b/Sources/Networking/Documentation.docc/Resources/interceptors-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..6ce87071ce6bca8cb051917631cbc059db4de82c GIT binary patch literal 19380 zcmeIacTiJ#)c75+E5+4y(al|0xAjuA=n7g z5-CY&0Td-5y@UWE6e$rx2ogddA>?=2eV+H3`OWjr`_DV`E3bP90c04fAdEQl#MwE zT$F-ex3~zxcPh;S|7;DqU~>TkDofk5?z;^Hva+TeH&u;VIzT^BK=~4maU%yoUIr8jq<*s{2-G9BKZ3|la z{)g3Zs|R^8s|`&?r3+Xr1Di$6D+me6s}NX^1>zbO_$zKGDj@+1G`V>+eyN53$LV>6 zt&M+Q1A%h)g0}o~8g&sQ^UqoLE~zvBoSj$uSMXo+{5==`ofQ8rg@2dAze@qI!2j1% z;h}yBs4ChkcKQX)N{dp7T$|3MnQ0j*z1u)_AJ7czAcx!t_1iOh-;JD=#qt)hkQCW?M5R93bNtC;~6F z@rd91RjcazJr67GNy^^YNZSwE61DI}!P`SDNR$YKYK!hMV<^EK2bNfy4l~%t-*L0B zHbQLhOVTZ%3FiwSneIXlS8+w2IE>#Q5jC9l$p*yK`8!^|UAQPw+S8~Fh*5t9ZU1hc zOh?XS1&vtj`sM4c7~49ywA{R{SSrIl=Cya~HXq|nxubwwdspHHk8i2}fijXdtIWMk z-93mEZV9tu-meE*WfndJDP@BWzS>{?NoXeFnK^4UtS{{zWO~LhiAZ|wD6xjuY3$q> zI4A!>9-*L_=O<+#qx(~m`E?tR(fB<@IkmC3@p&@tJ8}xm8UeMd!cw=5NAzC;$yoxb zNU4oPxFEjCwmT#yncK%qL>m5*)O3Bnl)=wZXBzLfp)xQLO%u~`{7rSYJ>C?$9y757 zbZgQc5ZVd|ZHwDjV>#=?Mm?mcKjtgCgsC5Q@^=_)2fBg=x}phm1+^h{Q89S2l>rbzdmd%Ce+seVnn)^yF6^ida`hf zQhLoER#zFwLw56qJAoV8<3Sxy+#6}Dd6Km}+9R#{syl4Jy!bnSU>hK)0SJ0-wrr2D z+K$iQDzB=5oT(ezJJY!*JRoDnpy(r@?Nw*+Rmpv~XY(@$%p*P`@;5-vmdyLSNf(f< zxy@D|1!CnysG8W?vCY+5QgbFb?Qo2k4-zlM879!b#oXt`}L2{X9Sq?pR@1xK%2fS zaJ9ul_CD*XdKtm47a{YHS?0h3u?#2HDj{Gu0uJU zxL9HrGg=6Wuxy@Vce|A0$7DNbgZa9N^jYBXo@piT14>F=UqqXEFcT%>VnY1%>548< z;||jg36(0bQ&0J!=rrSnDnIcE7sZVuVoc(S2w zF+=y#GTLl?0^wtVLxnFiyu7@$Zv7!1c*>NTu9T%Qk)whJiI^XiEC?=Xm?k#yt()SI zR|15tquCqwq-zlUrIBc_;LnLmt!i{hXJvW(Sv@<@71R}u!2ooNDZkK;S@gOIbUT>S zHrBA#0ULE|wq}{D3cI0Q(9kg2*E&t2R+mkR>bYq3#cp$XO1NRsD2oeK#u`PpX!mQw zY7?>oGhCV5LE}o*JAfeWky7oS@GxO;iw)tOj9peTT?eH08MXLvhj$F&lg{!?weI5p zth>Oa{X<`HrABakC%1XQ*dsBtATKcp8+_PqOZ-OO+G8fHdZFTFLy#~w9G4kTe0fsc zeV`EM*%VhJISTSi^}NfiW?|`KP2G|ZZ%>e(vuJ6Av@(r=avl|xWqDz}b#-;mP%!K} z?*|rL4)?W{olnp&nRMc^Zy)XpPgBMX5HiZbEqheXUCKDu)v?SQaZ6qgpLdZ4foyFy z2l6s)JX_@~_2xZ%1etRyeJ%gBF69TBwH!wu_%(m90He{H(Q#6uzbS6d*gaE;R(23g2Y#`>4+Ag6D2(J2pdp}b@}hv z+52+HwD(DNGE?W9tt=(}!K_7`_oumS%Yd^YB6SNAp_@Nc}7Ind} zjGAxzWSHb*ZERd+^JOI_S%sL}$OVAZtrc#evrTrvAMCXDDu$v97qkKUxUo zgf?}PR%Eq4593yjKPhEs_ro2yZ#mmM_=wx;_PW)n$K0|87QYc{V3FHqiS7LL72<)N znP8+5yT_DFvYAONd0j09@^M~}+FVoG%M&kkR!J$1XiNeaaZXG+Yam;6U`7t@Tq4>= z7YmCfh$xihF`g*}KIcWr>WVNdvWQ}=Vw!pDW;|;juawzXtZ1wB8MTkogDnRRJXtWl z{&{4%Zwm>RI6tf(eZdu%W1)x(%+4AZL^{L1PoN%AvF;6_Fya|fJF$+V?M#@k_N&q; zqDGONqPYKt)7eM=1i_U@YD#PSiO{g!TmN9FQsdDQm-N$Bd-@)gME-!EAeY<9>glPB7p zK}CK~wR!8A3Vr7s!H>c}At#eN^@=#x+Rnz9d^2qXI-$6kF(C)X_26C`q`aBcc%IyA zu44*2C)W8qV4v<+M7HtLr>`0*?1e>cZWW1vCua*^>ocbDUT%o7sb%Y{e$fN93;8?G zkL|k}UXJy)N3fmyU{c9}4Gu@oajHLw7+l5zbi_8Tx5TEdMLt!_wU{jG9~Q$dMq>`1O%$ zY0xXh;Hc8LVH7WCObKNxE2xSx_@-kTR1@3?Y2J&b@nQ_b%Z-+kx4`x2eU}TaRe$Pp z%PeE1it%73TLqnu)FjSfll<6uk|4tnm9<20Dw>fHa&T_l3viUvz+Q&L=((eB?>##3 zO!FGqc94~@;xKoSpdK0$TKEAzHthW8>=}cOC^SQj*To%fG^8G0V@3>fmcsYjlgryV zM)I<0XPdD(&?nHdF=dB<@xP-2%z|7??hBpEC(hgK^+@SCG*iau>6MeJ_Rp3#TVJdx zTmC^+ZQ#JNMjhWqfdllnbp-#6SJ4mlC{13Nd-1$^tbOt|TE$Ibn$k#Xss=wlmuFIR zp>|ax?d`K&P{Sbqkv}YTgAAj0pLDl(>+!a?c7A!3ewy88@?bt<6KAS;E0U^JWVQ z9Fkus$-zA#U$i;u@C+;N(WJH)ZqJRL3Kz%k%Ht)u;B>d=k5cD0su+*oU(?m(A5HTe z&kDBXKkJCsqrFg)f9DlwR5*l~a7Y1_+i}{sRHP8ML0f5!>N{O?=f}z#LfxLKQ~08g zzUD~^&bUSQ>TzvW+Tm~H%3W_5eOvQN6PJ26B#TF$BLewE*0+D+xJQd`RmC^AVMgOd zd01L?@u)%5_6eQuxU;&l_|3D$nKcJAnlISeNIRMB`;_(WBMJO~+aTHCN7~plY>e8P zN$gH~$j%Vuf)_iCD(6fea^UB3+s*nq8yJg5DgC>Mrw`+2alVGKpX|)U%g~gsS>YM^ zvd_4tAE5+;#@TssIhWhPmpu>j}tKqjkUgWWJ- zHnu_{moC(c7HCJrj0Q1dPD`?+MVD+8Bnk@q&bc(XkXD05W zk?Z-T2O>N|mibXFHS!Ubox}z4rn`&B~#Z+G;H`j`3V;G<*sxb|p0|y&#mtT>i1#XQw51 zzJZs^E&1E+Z%|EK|3OCuSG;XY^-pitB6M^{gpAfMU~m~Rw$Yb@w`RGk_{123{u<`q z1XZaUK9r2_Fss^#0Y6AwysKIQ>ikm}X|Ri0-Y}-8B+6-6%R#))Xda=e#LXiT3Boc< zX?nxzqXrkq`eIXh*O~}O$Iv!2Bm{}L-@&eQ<%hgvQJ&4W-f6Pj0ZV|3c?5}&;GDPm zS?va;RpcSN>?)~COx$24)=L}wsw+emO+K{A=tm`4y;(@HV%7QU#A2)rHYi*aOnhZy zIbRDEybb+LrBULIZ@Kbb1Z<3>AwGur^AE~Rs;^G%@m~G(SMd)fHz9`7!E2GrOdx)N zd>IwYmsg5rLr&J?Hx{@X;mU#PvN}nV*S&>Lho7A>R0v4DUygp>Je#;qO{~++^B7s~ zjH!n!Cy*h&JWR0*wm9QM{bfc=!0N*;F1OZbsM+mHmZOQ0BZ5o#L^d~7%aIIe9-2kaMeCZ^-gen)*eyL!w@Wy& zb)y??s$*=TtBxp@9f)N%*p&N{nUZ)-+tlaGB}GFk36CnrDZw39lcl(9Xj z?fK=!koNY)Cx#cg#QWecRuEO*ha)!nGuygXjFi7}9sEKDTl%pkawxmie*E%W>2&KE ze?Ou+uVc(~>1kTYmht|2m8R2O2dGE>tj97j10eR5HmMg741K;6?Vo?8*FW8zRtFX~RDdAu~i*v{n5PD!j_gzVV- z{ecuxMt*pYQRb}w;6gil0&I#xv4tTD8SP zdEy~t;;KdBvc+3M8!E2vZgKLFwothJVkr#89*8Om8dfv?@sQ4c@D;AD6kIuR0q>$U z^vf(o?|J%k`{_1QkD0RA7sG)+$cZ8{+qb7&WetT(r$bf-{Z5aQfk{YTSq~PF(Y<;; znuop|MdT;H!Ll{n&0Put!z&1|%+Qh>{u?Or!6re+YSr=C*ZOltCg=3VFh&KGJLCSNJ-Ldu&N6EzsVdFN7U9)yP|vUFWX z3uB@1$6HypDS_52Jws+esxObccK2hMuO+&N<{{?_={9hB4K2H<6i)PMjSO?hoEh-z zWAldlUrpRD`W%FsbZLXwbUxNgx{iZAyP$-;vn$iV&fKEbJLGgE~oqU zL(Sb=b(Tc_M~ApJ0dBDpHU)Ovtd-3>=yk?EkudTbC)c5`F#c3lyGO<|6f&~7EJBY^ zDmof;Z?sJ5WLIna61gPq6Mdp`Nu=&KF#AvqdyrWVZ@b2RMi0tKrPM3e#Le*!^%}Ep z6?uJ>9{+PCrRpB`EnKx+SUz>vlppZ0P#)=(H^ws0*0MDH;J{o9-SHv#=AD%`!xL>! zlwI$SHqi&ztZ07+<4@JfuBD8K1P@iYdz$8qy`E|h4Tp+f8)nL&v(g8^;T2AOExRUM zB8I=l_ddX|^#=j!mD2)5}3c7D7ZjgP%!jBKjUaW z7o%*WV_avz(!>liMh^w;dyaqKkW&wl?Z^S2XC)KVMf2=qDen&X`VF2wAtAVB1t{0* z|1oEtT&Tjn=0LC!U)eJM!OIznDVjVmo=R&t&K?-()L98sO!0i0%X+8Dk}l0x$dJ`2 z<>XjXGM{o@h%N@j$&!PhZ4RLZLuT^98u$SjJj2AUXpEbr=5}z%8bhEV!5Z2A$v)4l^~~93 zve>w#*da7CNL}4Ji1^jy*~cl5eC-USHWuu~f#Yq5X0%>B>}T`p)w~NM^z$C=wt5e;bLRK$#B|^_&&6HVi#K7d;RswT~o~E{vF~X{7PqP$)_>TX#cf8$i|=YjmzgB zFJZWe5)s*JY~^Ruf?Etjl^qvZ1pG+Hi+V()yR$L9HOSVapi(U_)@J1_?Flyh<2q_^ z|4nOt$-}LsoC9LlZ=!ab;Bv|Q6zVeC51TTBZRm#ADl z8?S|??rP#>b=fC)b7LzBstFkkzkBVcxt^nfA-#i!L9OsPB_o0z$FAE=;Rca?);L=O#pz*#Su{Hso{ z?@DQrurDM{BbnV0wdNjci+drgpM3-S{-Q?UzKj3WaOyl~=~wbiC%fL3`iQ|bjYAf9 z`u$JPK87DO7+5Y2f?6(IBOmQJ)1o^(?+C)g2F0cDLpnlF5xy>+2m;%xt=V`d1=0Q-1vBrOVJV`1ENsivaea5Qp9Nk9^0QA} zn=I~aPvZ4>RWp8R$E;6JX{aTvdD(4iL|g|uIA3A;-=3TMe!a=Y0_=k3we1SdVBJ++ z9~Vlp1{^^iMvQ1Ig z^nkNJPX*X}cit67W}T9G?5Fc{pjnR5^$GWthC;;yjnH#4w`30}`H6%@c5vX=fwL<@ zlLTR3-e^b(G}I^M^G~xEM#2g0#~Q5e!o0{){i)X}=|?XF;&h*HU)@@I02#Oe=f@N! z7hJ0yO|wzRNC}R+XVY=4EsHv`EeLwp_y##tG;_{q1WO!UOGKSmsx7SX%;QzZPP_a{ zHAAp>C?qN;gHbB|H?Mk{?62+)UWkRW%ylsd^A6OBiBDT~e3Moplu)SbilanXMAL8s zS$%DB>0O<_Mfa}XP>hnZTaWyf)!YWY98&U;Y}@F!pz?e5QVm7F#~z%__98n_K5BC? zjss5|;ltP}bHi9W?r4dFMxUlE9{M)k69FN$ues;?JA=*!GUATI-R9cXu6uX7r`j;~ zpi_xsZ6d%H2kHY8nQ~upz)!Qa6>eUxCFFs(NA}g%Q=B=Dx$P7`D$;_;4-YY9_`V>| zUSsN++HEUGcr$81rxA%EhBLTyfgv}J)P)0^p5P<rGFx1c0TU4I8K*SE6aG<~oNERZmaA9Y^ zh8wlKq&NbISZbDhPAb+b3k_kzjvoDPyX)AkYRHbvm)FTHy z8^5rdf0~EH_~+EGA2ND{k2;jF7P`u=79G$Udit@OkiaiKP~fCSF#Ms!rnoiv*yyg_ zcsq@|yk7GxcD_D|FF1ZAOg9Wx)6k6*!kI`7yfx_1%WZB;shok2Y`WS2`7a+diQFbqTTNG;9`n=|1b;K|X z=dBxj`<6=_Hh1zx89&Qd@BO1U@bx_j@{CuXgpoSiJ z+F_p~4$X2}-YE45%$z-_JFG-^AvW__sbNjJ3_E{lw7=OW9hH zLyA)z4Rm8{t(*q@*Q|B-oR|Q{uO+scFM1;MH5foB7t4^aM$`3ftE^nC-%URS8eN`# zpJVGyqg}CzzC<)>l8=ldUQ)!S2Alb31)-7{xs5ojaL&$D8l1%ZC96x%JNpKLO%QFR zf9OFR`vIRhRmJO(v-qj{+xJtNyM5hkzZI3;nwuhDWPtIvZtrtV8G)O}zQCNZ-PY(= z^wc12-MFph-I3^ptKXwtuMYTTf~p1}ypXu-X|2(rXw%81LM@g#%1~>F4&7be>yxc= z&bi0^SJ8Kwj+YiIv>|!tRrl9_Fz@O%osr+t3|-*^yANc_D&_r8yQn-#P2Ly|`9kOj z3F4h=d-CXIIj0#MpBZ94cU+d7N?TWqE z8`*|52!U;zVyYSvCArQkB14NiC$7q(8;Z?orNb;(&4U4!$Jedh;TLm88L=Jv9J6vXugs8;50hEF>pW#b+~{VWeMHMw|5 zIFPYt;*hnnoWAr2yf^ij9sKEae~FP&=LclfC9oPJj_H|SQ{d@2?E$%a3S-mg_AyK& zhQqE{hZ3@&tQ?~~^x)4rK};T&1u=*wFlx|I##1M$1()-7=n91{krU1#L#D(jdqgp=BJ}1E_U%m7 z>+sOB)T|!)bunJ&_v_~LnSEwITSk*5yHsx>+G+&Ar(>6EkA8j`rOo9Tb}A- zhTHnI2}@p!fUJ(+Wh-CH2(kp#aTO#Z$mWF#Q`gM8uAz@X76aSY8S^bw*ho;n%A&y! zGgat#4%RN`UD@)>rk~X=DJORyn3?%}PHndOk660Ts!N8Px2#fzFcMqzH0J%v584)i z(gMQm&j|2^;-7S>HN-HeA4g9lEW1ntr`Bjj7I}SwzJ>8q!S(e>zN3#Pq0|SGrY@la zF(3uG&3nhtjak&Fid7{S%Kv71scWPdqkvHa%-vG2?mKCp+mKIQ_^tCrviZC!kV{(` z^l80M4VeG-PR1q|oHy)cZe$^@UC1?vX5U|_dXq{RrUUOlqrKw3KB^k8F+c1vEU!3hEM9Zzy?aGqoa0IFV;!5-6K~r#v%Q4Qp`opfRy9#+LxR8V z3L9CEZloQbe5q=M8V@-*Isafd2SM&S?Vwt3lkCaWHw;wZD+0M9p}>jLQoeyVc?($F zyLS0f;gQPW^jYw}5N{6Ub>y4u{milmAN5~VzV5~5RI=;YyKa@nWyGk@gHkO}Lt`Y- zLdfC>@h5$VPiW=6d7j|V_NDIL2ie>EeGk$H&^qO|Oi^^9H34_6>9-q6?&d*3ikr!l zw}cMu{`)SpL3apxj(DiluQje;?~>M8Ik4goq1fZB$9}`X0cH(trT5$rXWjZW?p|*T zjd}(qhRz@KpRanO`Ey%kWc<@HGS$NgouRVhhS5OUX-+~RXR;qO*9NI5cG<=RKlY7Tx0 zdDnrbktX8zulBX+?vYv(XE{3%ed4EVtLj!9%ldwYJP^B=CQ7E%%v_bPb3XT;GjR6r zCzTevHa?WZ7W=BhrB{I`$VEt-)QwHNtRB}>tJbA#Iv#O+nPSmg zN3h~*3`fI>jc3VuXz&BfIFQ8*y$zz^%ozVo);zr!ZO`|IWMHfVuQWp!jLm2tVoOjc z*)dEqV{1Jv$)m{xcHP{3hDfu2B7o0EL%9ug{t1kcbBo=>YZ`!S!SYu=wYMd@E86%5 zp>jo~kBU{m?AOiq3uX^I%QkT$hZr%Gb`XbyJD3VF3R%&f9vGn)&+R&cU3HYuu@+Yv zG}q&Du-;$HV155xX0o$z!KvjJ&iCA$%IXfh^6IdhZ|m94BYTFQ+Z<=SR|;}S-cJif zFgoL3yuRdR%&-5oF*heVjxaqTa%5kFKv|WnmO~`r&%^FtNm(>=c}Ns{ET?li)IGe9 zA%4<%0u-+AcBlHdYIhCz$gtKY($a+z{d20FP_R$x>*Qk(z^Oj(DZPFUKBoL5q2_d( zzJu<`6WUaq{8GPyG1uGXZ8!-Ml&9=Up%+xm>anPsgE4X9nA*!6@UNk_+YgRPK2Y3Z z9o~j{elfw>cB2I;N^Zz!l-Z4E9Ast6F2?(2A?ZTwReq-td7^lt_q~>=SRA%uVa5Hz zSZEN;&OTGQ=mhWGv+5%H-2c|v?8xG?*nU%^X&` zK{(Je_0*lU-WYu8W^znesmK2a_Z;?nP|Y{EG&oj|hEosKjyi)opMpEiDi?(lC1X)a z&;1@g?(H7cEc8r0u5f{i472x*2#b>+1;@VeOzu^oLfs_h%90C&&awsKn0O9hR6v2< z8eS31-pZmor#=r0wtSDY=3K!!3^@i|SQB~N%x#!9rN&koS`YA2jTc@9*bU|&}J`_Z3cptJ)uEQe7D&}8^0vJj)Xz0Lek_A&G+PXF#if)fjn1fhl7z)-p zI9|Mj_72udV5B+~y+3;Bxri*5r$1E*|}?Dz_1yWZuk z*gu1{wsItt*2MS)+ieYX`Eu4U7Hb?_dfq4eBp&+d4tO8j8oZ^b=kSFVTya*x%+Nqv zb==7yL(|od@_5zczml1STIe57)8eNFOvwut#%iMWt%Xm;)232BjKf)|$zBlB9eOu7 zr0)dOuW`{!0L{%|&g!3)8cnJj6RLPsI01H-LDCfeP?h`;65&l-xk&fBMI6`W znjK3oqZQ@IT}J{Am8T^0YmvHNJh(sV!owjMGOF2Qu$qYK+)hWAx+pK|{4RNC%aoZ@ z>ZR_SvE+{dadzLT;mzx&d=rEk$JejzngXUn!$MiHlIlJrpqE{UYd=Gh=~?;1mRU&e zRGzHl7gEM+DOGWOLi(-YdTL_#TX-qp9t<<|Pbd|?j<$-tH??)mU4!>Je0ER{R#&JH zfw(xb?9y8Zy?R+w!*9s3{LNSGCBYN~s`-rb+@zG`kR!SzJ*5{4j$Ub7UlQ*2WF_CW zm3toiSi^j@cvvn=5>Yj`A`TrBxQ*@3WPGS(>Y=sg@0-fJtxy{J<;Ar?hJ4&PGNd)V zmY6ptoSzZ!YaN(>Edy_OTNDVUEkljFR)^)!JEjeW)#Qu`J3#z5W4_&dRV&c{@J=*DPnYgNQ0xDEKIHfh3hf z%VpNGY7Eys;(t8yuH#p?Mv@q1C@sIab4GO$#j@UPR7;&c|4n#-cU}WYVeMHgk-8&d zw*MynYj2H{Xr#Z?*V9g;5Z*YSR?vAURF$cs53Nu_$SGBSuPW_o+KU|QyrJ;HNerbQ zq3Y|F)`ibk9c?HM4jgTAS0M^yRoo>-Xilk2(wLE;CX*YbC|yu$7Hh8=Sbb}5ys%?HrOqC}P~I{;Z4*PZ-8s zx9)rzJB^^VYBFlP@;KT8!`4vvf<8%xs9b?@_z3kHOKyxEJ10Sood$K42ZE_J4dKR| zuSXdcqkZZY*mS7bbB-hXB}>+Br`A;2Ka76)G`%HPp4-3U z2+Yy5`a`hOIdPpOUn-QVpm^YY_%T?R$hWK9KkKL}#%7Wh6dU6H7-BXNjylNt)gyQA zOO6%|ciOW&rDA`v*7V{YkB?jryD9Z1co&2`x;?g`9EHY>N?zA-hg6#EYnb)AumTDVj!k)An^xyfHQ!smBeaCdw(1uy1qV1WOgEPP04sZF4 zc|Y^H3>*aaezWqcL0^kyVBAvnTBU5`E*0(e8yQE`Myj=X2bZRQ)mNK^Q}W*|wCbN| zJ4*vW!Y;M6uHi;6x@LM@fGBO(M!W(qpczv}$ga52iaTdB-MsY-1H_hZr(6gxChE!l zswOc%Ss~wiem30OX)K!XZTdsAZb+w}Yad6bYe_Ic_>=^-4I0f5hve<r7CSVWTv9sXdz*AF1e8+y>x%VKX&r?Dis`d~!lpfC!@!%Yyr(2v`M5;+G2Vpe z5VQlcL?a|1Jktwi`jR>MViSL?Yg%@f3p4HBkEV$nG~@QXF{!OC)si{A7g^flJbk_E z_X@6^>_G-S+>gO~jbh)(GLj2%axsbi(Wlq;bXcb(7~xA_l%C9vZe3CK+DYlU^}B2N z##uz?Nc~1(0oILw-p0SssxU6N3ua7`rL~l~qO0XXpwa2Uo%}zPO%v6N52hAK!J0;bRl>znFkRvXWf5Fy6Xy0NI~fTrm#Jw*|wl6{?_*QK~J>{Tvv zyswpwHnjF~KC*&qhn?~}#fdY?DCN{b{K9hr6Jp0=C_(%q7$^Dh_m@xx8KN`eB`W21 zCOjT0z^OOI|Z^=nJNED5_?czWllDvaVML+^n?Ek{a1pVJuPKZ{@TY=0nL^w&F}=di0eJ zQYOsyvO_-=nLG_!6XP!bX`-c8po_WDQLvG5xd)2u!c81b`QRt`xs$6+sJ=T;Vv!Ns z2YGl*?Tc%D*?zWq+)O&Bf=x-5;!htex9g}6uF6x~{+8HfGPQGilUE~Slo$PQ_hfU3 zLv5;4LAk~ZQa{M7A=q2;{cT$60=rmBPZtk;1w{idxsr>@3U&G98U>$(8E)1B9)Ziy zXfq>*6T_@7PYgOSaza8}BS*>$NwB%sm|*!as?cPL2v?A*+_%%ewhNjbAB3B6;tthA z<|dTh_ju$3iI2j`SJywTIC{3$@q{*QPlF$eA*|w9RNMwbMX?Sjp{e5wCy+2el(f%8GO2?EzJeP92n|Wrx932?TFiq~e zl6o!CU3(oizy&Hf*en)nPfOO3(oufz2EeZ;sxkf0Zv&uSHzXHXT$IL>( zR*^62xUrrYwHa@5WrYPXrA;2Iiolfk<Cdo0@iD*WJ}6V72GvEG_-Pt1v&UlP8D z-YR*$-N&6?xMR`jidO#e)vMfhn22zO52m>XeDO5Fd)UB;R`DKkXfzO6y28)FI(Kyl z_n^p=MWOf{c%j)4EMUx2G&W3 znwKQX_g@+>O5uZMv@s}9`?e=eT?o3VgwtWL5M8vqu<{g&a@M)ey4F$%yfDAGPLdE)sXXRW%$PjgUM z9AlFq2deUqgAyd7v4(F;JCj`7GnK~AjBZu~{e8#rgChV1@%QmBZ}VRU{})I4zhP2T z9gZ0dCd)R`91=m}NVqysS?a1pk@DE9#*9G1;wKfIBQ_Ud;rrSM6#%@P1xU%TrXIz{ zZLv*?qaOfP&QhQLRJaB67eEAlECXS%0Pd*=5ZMY-HZWx0ZiCX=g__W9^pyWjU z43>L@{oQMQ;Q^5%pg`7ZQwg9zHp~j(`Fz7is>-Bo|K2eE z0TXQisP7v9^;rN=pZg}%2WRER)NuD;8ksR;&=w86F2E*U1+YF<0P6z-SYPcX*7u6s zAm*GN4_O=ilPeX0n1_v7fZI?gA)|-o$Df zb7Mwl0g-pMRXmFH%>PWk&i+$l9=^WLGV^BE#lp9RX$f4l`+u3gES4hWFI@BnMq^=< zlLdI9?$?_vG5?YG`XBtcVgf#?Bl13fvr2Ij+coCzaJk65p8yz)1B{;1BLLQQAk-~= zo!i7*^OCmojV@)7OGbaB#*~b%HtXY)#6V}15DG%TdKuRMj`oDqne!}6d{dP^z;6|g ziJ4=blC5%fVktL3DKmf-#sDUE+8oXOhHD8cLw`Ra?o3owks<^5z~ECJF$qE9+kH8sm7A6xFq19aR*=7O zJ`Z52f1^q-+X7bh_^-{>w{F)zPniFu<=X$%pMN*^{~I>=a)KJ@AUcS w!=zmRzWMiY=E#4N{wKEn$JadlpL(;SjDX>tj~@(lZYpN^yX__XMV~wW16xFz>% literal 0 HcmV?d00001 From 28e4b3836ac3f860ccd03726ac4d02cd602a2963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 8 Jun 2023 14:07:44 +0200 Subject: [PATCH 22/79] chore: status code processor docc --- .../Documentation.docc/Documentation.md | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index d332617f..b584bc9a 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -184,16 +184,20 @@ let retryConfiguration = RetryConfiguration(retries: 2, delay: .constant(1)) { e ``` ## Interceptors +Interceptors are useful pieces of code that modify request/response in the network request pipeline. ![Interceptors diagram](interceptors-diagram.png) There are three types you can leverage: -1. ``RequestAdapting`` -2. ``ResponseProcessing`` -3. ``RequestInterceptor`` - -## Request Interceptors +``RequestAdapting`` Adapters are request transformable components that perform operations on the URLRequest before it is dispatched. They are used to further customise HTTP requests before they are carried out by editing the URLRequest (e.g updating headers). +``ResponseProcessing`` +Processors are modifying the URLResponse received after a successful network request. + +``RequestInterceptor`` +Interceptors do both adapting and processing. + +## Request Interceptors ### Logging Networking provides a default ``LoggingInterceptor`` which internally uses `os_log` to pretty print requests/responses. You can utilise it to get logging console output either for requests, responses or both. @@ -238,10 +242,20 @@ final class CustomAuthorizationManager: AuthorizationManaging { } ``` -## RequestProcessing +## Processors ### Status Code +Each ``Requestable`` endpoint definition contains an ``Requestable/acceptableStatusCodes-9q0ur`` range of acceptable status codes. By default, these are set to `200..<400`. Networking provides a default status code processor that makes sure the received response's HTTP code is an acceptable one, otherwise an ``NetworkError/unacceptableStatusCode(statusCode:acceptedStatusCodes:response:)`` error is thrown. + +``` +APIManager( + // + responseProcessors: [StatusCodeProcessor.shared], + // +) +``` ### Storage + ### Multipeer Connectivity From 78d55de0c264877e7d8d6881a69993a97ec69dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 8 Jun 2023 14:49:11 +0200 Subject: [PATCH 23/79] chore: storage processor docc --- README.md | 10 ++++++---- .../Networking/Documentation.docc/Documentation.md | 12 +++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 15c7c7ec..560e9561 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,16 @@ The lightweight library for API calls management. The library is built upon URL [X] multipeer connection modifier (low priority, last one to do) + [X] use library in iWeather example app + + [X] file download with progress + [ ] APIManager unit tests [ ] more complex samples in sample app (parallel tasks, retry, ...) [ ] tests for library objects if any are missing - [ ] sample usage of storaged api calls for UI testing - - [X] use library in iWeather example app + [ ] sample usage of storaged api calls for UI testing - [ ] other data types - file upload/download with progress etc + [ ] file upload with progress diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index b584bc9a..0468ff1d 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -197,6 +197,8 @@ Processors are modifying the URLResponse received after a successful network req ``RequestInterceptor`` Interceptors do both adapting and processing. +By conforming to these protocols, you can create your own adaptors/processors/interceptors. In the following part, interceptors provided by Networking are introduced. + ## Request Interceptors ### Logging @@ -256,6 +258,14 @@ APIManager( ``` ### Storage +Networking provides an ``EndpointRequestStorageProcessor`` which allows for requests and responses to be saved locally into the file system. +Initialise by optionally providing a `FileManager` instance, `JSONEncoder` to be used during request/response data encoding and a configuration. The configuration allows you to set a `storedSessionsLimit` and optionally a multiPeerSharing configuration if you wish to utilize the multipeer connectivity feature for sharing the ``EndpointRequestStorageModel`` with devices using the `MultipeerConnectivity` framework. -### Multipeer Connectivity +``` +init( + fileManager: FileManager = .default, + jsonEncoder: JSONEncoder? = nil, + config: Config = .default +) +``` From f5a61514cd04cd80f86fb1ab19efa9f743431710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 8 Jun 2023 14:59:26 +0200 Subject: [PATCH 24/79] chore: edits --- Sources/Networking/Documentation.docc/Documentation.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index 0468ff1d..67caa809 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -187,15 +187,19 @@ let retryConfiguration = RetryConfiguration(retries: 2, delay: .constant(1)) { e Interceptors are useful pieces of code that modify request/response in the network request pipeline. ![Interceptors diagram](interceptors-diagram.png) -There are three types you can leverage: +There are three types you can leverage:
+ ``RequestAdapting`` + Adapters are request transformable components that perform operations on the URLRequest before it is dispatched. They are used to further customise HTTP requests before they are carried out by editing the URLRequest (e.g updating headers). ``ResponseProcessing`` + Processors are modifying the URLResponse received after a successful network request. ``RequestInterceptor`` -Interceptors do both adapting and processing. + +Interceptors handle both adapting and processing. By conforming to these protocols, you can create your own adaptors/processors/interceptors. In the following part, interceptors provided by Networking are introduced. From 486b39882e2070561d57298376a1b8d5daf1ed20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Fri, 9 Jun 2023 10:35:33 +0200 Subject: [PATCH 25/79] chore: mention associated array query parameters --- .../Documentation.docc/Documentation.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index 67caa809..174a83c0 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -273,3 +273,25 @@ init( config: Config = .default ) ``` + +## Associated array query parameters +When specifying urlParameters in the endpoint definition, use an ``ArrayParameter`` to define multiple values for a single URL query parameter. The struct lets you decide which ``ArrayEncoding`` will be used during the creation of the URL. + +There are two currently supported encodings: + +1. Comma separated +``` +http://example.com?filter=1,2,3 +``` + +2. Individual (default) +``` +http://example.com?filter=1&filter=2&filter=3 +``` + +### Example +``` +var urlParameters: [String: Any]? { + ["filter": ArrayParameter([1, 2, 3], arrayEncoding: .individual)] +} +``` From aa85743a4f29993237a5ac68edf3e388fc834500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Sat, 10 Jun 2023 17:03:07 +0200 Subject: [PATCH 26/79] chore: added swift code notation --- .../Documentation.docc/Documentation.md | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index 174a83c0..ee267ce1 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -10,7 +10,7 @@ By conforming to the ``Requestable`` protocol, you can define endpoint definitio
**Recommendation:** Follow the `Router` naming convention to explicitly indicate the usage of a router pattern. ### Example -``` +```swift enum UserRouter { case getUser case updateUser(UpdateUserRequest) @@ -67,7 +67,7 @@ APIManager is responsible for the creation and management of a network call. It There are two ways to initialise the ``APIManager`` object: 1. Using URLSession as the response provider. -``` +```swift init( urlSession: URLSession = .init(configuration: .default), requestAdapters: [RequestAdapting] = [], @@ -77,7 +77,7 @@ init( ``` 2. Using custom response provider by conforming to ``ResponseProviding``. -``` +```swift init( responseProvider: ResponseProviding, requestAdapters: [RequestAdapting] = [], @@ -90,14 +90,14 @@ Adapters and processors are passed during initialisation and cannot be changed a There are two methods provided by the ``APIManaging`` protocol: 1. Result is URLSession's default (data, response) tuple. -``` +```swift func request( _ endpoint: Requestable, retryConfiguration: RetryConfiguration? ) async throws -> Response ``` 2. Result is custom decodable object. -``` +```swift func request( _ endpoint: Requestable, decoder: JSONDecoder, @@ -108,19 +108,19 @@ func request( ### Example In the most simple form, the network request looks like this: -``` +```swift try await apiManager.request(UserRouter.getUser) ``` If you specify object type, the APIManager will automatically perform the decoding (given the received JSON correctly maps to the decodable). You can also specify a custom json decoder. -``` +```swift let userResponse: UserResponse = try await apiManager.request(UserRouter.getUser) ``` Provide a custom after failure ``RetryConfiguration``, specifying the count of retries, delay and a handler that determines whether the request should be tried again. Otherwise, ``RetryConfiguration/default`` configuration is used. -``` +```swift let retryConfiguration = RetryConfiguration(retries: 2, delay: .constant(1)) { error in // custom logic here } @@ -134,7 +134,7 @@ let userResponse: UserResponse = try await apiManager.request( DownloadAPIManager is responsible for the creation and management of a network file download. It conforms to the ``DownloadAPIManaging`` protocol which allows you to define your own custom DownloadAPIManager if needed. Multiple parallel downloads are supported. The initialisation is equivalent to APIManager, except the session is created for the user based on a given `URLSessionConfiguration`: -``` +```swift init( urlSessionConfiguration: URLSessionConfiguration = .default, requestAdapters: [RequestAdapting] = [], @@ -146,13 +146,13 @@ init( Adapters and processors are passed during initialisation and cannot be changed afterwards. The DownloadAPIManager contains a public property that enables you to keep track of current tasks in progress. -``` +```swift var allTasks: [URLSessionDownloadTask] { get async } ``` There are three methods provided by the ``DownloadAPIManaging`` protocol: 1. Request download for a given endpoint. Returns a standard (URLSessionDownloadTask, Response) result for the HTTP handshake. This result is not the actual downloaded file, but the HTTP response received after the download is initiated. -``` +```swift func downloadRequest( _ endpoint: Requestable, resumableData: Data? = nil, @@ -161,14 +161,14 @@ func downloadRequest( ``` 2. Get progress async stream for a given task to observe task download progress and state. -``` +```swift func progressStream(for task: URLSessionTask) -> AsyncStream ``` The `DownloadState` struct provides you with information about the download itself, including bytes downloaded, total byte size of the file being downloaded or the error if any occurs. 3. Invalidate download session in case DownloadAPIManager is not used as singleton to prevent memory leaks. -``` +```swift func invalidateSession(shouldFinishTasks: Bool = false) ``` DownloadAPIManager is not deallocated from memory since URLSession is holding a reference to it. If you wish to use new instances of the DownloadAPIManager, don't forget to invalidate the session if it is not needed anymore. @@ -176,7 +176,7 @@ DownloadAPIManager is not deallocated from memory since URLSession is holding a ## Retry ability Both APIManager and DownloadAPIManager allow for configurable retry mechanism. -``` +```swift let retryConfiguration = RetryConfiguration(retries: 2, delay: .constant(1)) { error in // custom logic here that determines whether the request should be retried // e.g you can only retry with 5xx http error codes @@ -208,7 +208,7 @@ By conforming to these protocols, you can create your own adaptors/processors/in ### Logging Networking provides a default ``LoggingInterceptor`` which internally uses `os_log` to pretty print requests/responses. You can utilise it to get logging console output either for requests, responses or both. -``` +```swift APIManager( // requestAdapters: [LoggingInterceptor.shared], @@ -223,7 +223,7 @@ Networking provides a default authorization handling for OAuth scenarios. Use th Start by implementing an authorization manager by conforming to ``AuthorizationManaging``. This manager requires you to provide storage defined by ``AuthorizationStorageManaging`` (where OAuth credentials will be stored) and a refresh method that will perform the refresh token network call to obtain a new OAuth pair. Optionally, you can provide custom implementations for ``AuthorizationManaging/authorizeRequest(_:)-6azlk`` (by default, this method sets the authorization header) or access token getter (by default, this method returns the access token saved in provided storage). -``` +```swift let authorizationManager = CustomAuthorizationManager() let authorizationInterceptor = AuthorizationTokenInterceptor(authorizationManager: authorizationManager) APIManager( @@ -234,7 +234,7 @@ APIManager( ) ``` -``` +```swift final class CustomAuthorizationManager: AuthorizationManaging { let storage: AuthorizationStorageManaging = CustomAuthorizationStorageManager() @@ -253,7 +253,7 @@ final class CustomAuthorizationManager: AuthorizationManaging { ### Status Code Each ``Requestable`` endpoint definition contains an ``Requestable/acceptableStatusCodes-9q0ur`` range of acceptable status codes. By default, these are set to `200..<400`. Networking provides a default status code processor that makes sure the received response's HTTP code is an acceptable one, otherwise an ``NetworkError/unacceptableStatusCode(statusCode:acceptedStatusCodes:response:)`` error is thrown. -``` +```swift APIManager( // responseProcessors: [StatusCodeProcessor.shared], @@ -266,7 +266,7 @@ Networking provides an ``EndpointRequestStorageProcessor`` which allows for requ Initialise by optionally providing a `FileManager` instance, `JSONEncoder` to be used during request/response data encoding and a configuration. The configuration allows you to set a `storedSessionsLimit` and optionally a multiPeerSharing configuration if you wish to utilize the multipeer connectivity feature for sharing the ``EndpointRequestStorageModel`` with devices using the `MultipeerConnectivity` framework. -``` +```swift init( fileManager: FileManager = .default, jsonEncoder: JSONEncoder? = nil, @@ -280,17 +280,17 @@ When specifying urlParameters in the endpoint definition, use an ``ArrayParamete There are two currently supported encodings: 1. Comma separated -``` +```swift http://example.com?filter=1,2,3 ``` 2. Individual (default) -``` +```swift http://example.com?filter=1&filter=2&filter=3 ``` ### Example -``` +```swift var urlParameters: [String: Any]? { ["filter": ArrayParameter([1, 2, 3], arrayEncoding: .individual)] } From 9f2f83bc165619f9bf6f841bb5d3564b650ad873 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 15:03:38 +0800 Subject: [PATCH 27/79] chore: add helper objects --- .../Core/Upload/UploadTask+State.swift | 39 +++++++++++++++++++ .../Networking/Core/Upload/UploadTask.swift | 21 ++++++++++ 2 files changed, 60 insertions(+) create mode 100644 Sources/Networking/Core/Upload/UploadTask+State.swift create mode 100644 Sources/Networking/Core/Upload/UploadTask.swift diff --git a/Sources/Networking/Core/Upload/UploadTask+State.swift b/Sources/Networking/Core/Upload/UploadTask+State.swift new file mode 100644 index 00000000..8fcc5700 --- /dev/null +++ b/Sources/Networking/Core/Upload/UploadTask+State.swift @@ -0,0 +1,39 @@ +// +// UploadTask+State.swift +// +// +// Created by Tony Ngo on 12.06.2023. +// + +import Foundation + +extension UploadTask { + /// The upload task's state. + public struct State { + /// Number of bytes sent. + public let sentBytes: Int64 + + /// Number of bytes expected to send. + public let totalBytes: Int64 + + /// An error produced by the task. + public var error: Error? + + /// A response produced by the task. + public var response: Response? + + /// The internal state of the `URLSessionTask`. + let taskState: URLSessionTask.State + } +} + +extension UploadTask.State { + /// Initializes the state from a `URLSessionTask` + init(task: URLSessionTask) { + sentBytes = task.countOfBytesSent + totalBytes = task.countOfBytesExpectedToSend + taskState = task.state + error = task.error + } +} + diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift new file mode 100644 index 00000000..47695411 --- /dev/null +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -0,0 +1,21 @@ +// +// UploadTask.swift +// +// +// Created by Tony Ngo on 12.06.2023. +// + +import Combine +import Foundation + +/// Represents and manages an upload task and provides its state. +public struct UploadTask { + /// The session task this object represents. + let task: URLSessionUploadTask + + /// The request associated with this task. + let endpointRequest: EndpointRequest + + /// Use this publisher to emit a new state of the task. + let statePublisher: CurrentValueSubject +} From 885c14de6799fa35ebbffc1133d0fc6537711176 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 15:08:30 +0800 Subject: [PATCH 28/79] feat: provide identity to task --- Sources/Networking/Core/Upload/UploadTask.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 47695411..28b0b60e 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -10,6 +10,8 @@ import Foundation /// Represents and manages an upload task and provides its state. public struct UploadTask { + public typealias ID = String + /// The session task this object represents. let task: URLSessionUploadTask @@ -19,3 +21,12 @@ public struct UploadTask { /// Use this publisher to emit a new state of the task. let statePublisher: CurrentValueSubject } + +extension UploadTask: Identifiable { + /// An unique task identifier. + /// + /// The identifier value is equal to the internal request's identifier that this task is associated with. + public var id: ID { + endpointRequest.id + } +} From 07b9deb527d9ab5bacbd2d429b4c29207b333a8b Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 15:25:49 +0800 Subject: [PATCH 29/79] chore: define uploading manager protocol --- .../Core/Upload/UploadAPIManaging.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Sources/Networking/Core/Upload/UploadAPIManaging.swift diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift new file mode 100644 index 00000000..f99e2692 --- /dev/null +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -0,0 +1,47 @@ +// +// UploadAPIManaging.swift +// +// +// Created by Tony Ngo on 12.06.2023. +// + +import Combine +import Foundation + +public protocol UploadAPIManaging { + typealias StateStream = AsyncPublisher> + + /// Currently ongoing upload tasks. + var allTasks: [UploadTask] { get async } + + /// Initiates a data upload request for the specified endpoint. + /// - Parameters: + /// - data: The data to send to the server. + /// - endpoint: The API endpoint to where data will be sent. + /// - Returns: An `UploadTask` that represents this request. + func upload( + data: Data, + to endpoint: Requestable + ) async throws -> UploadTask + + /// Initiates a file upload request for the specified endpoint. + /// - Parameters: + /// - fileUrl: The file's URL to send to the server. + /// - endpoint: The API endpoint to where data will be sent. + /// - Returns: An `UploadTask` that represents this request. + func upload( + fromFile fileUrl: URL, + to endpoint: Requestable + ) async throws -> UploadTask + + /// Provides a stream of upload task's states for the specified `UploadTask.ID`. + /// - Parameter uploadTaskId: The identifier of the task to observe. + /// - Returns: An asynchronous stream of upload state. + func stateStream(for uploadTaskId: UploadTask.ID) async -> StateStream + + /// Invalidates the session with the option to wait for all outstanding (active) tasks. + /// + /// The internal implementation uses Apple's delegate pattern which retains a strong reference to the delegate. You must call this method to allow the manager to be released from the memory, otherwise your app will be leaking until your app exits or the session is invalidated. + /// - Parameter shouldFinishTasks: Determines whether all outstanding tasks should finish before invalidating the session or be immediately cancelled. + func invalidateSession(shouldFinishTasks: Bool) +} From 729db5a3acfcfb1c9639926773acc72d4954d7c2 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 15:49:43 +0800 Subject: [PATCH 30/79] chore: provide default manager implementation --- .../Core/Upload/UploadAPIManager.swift | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 Sources/Networking/Core/Upload/UploadAPIManager.swift diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift new file mode 100644 index 00000000..d13c539d --- /dev/null +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -0,0 +1,152 @@ +// +// UploadAPIManager.swift +// +// +// Created by Tony Ngo on 12.06.2023. +// + +import Combine +import Foundation + +/// Default upload API manager +open class UploadAPIManager: NSObject { + public var allTasks: [UploadTask] { + get async { + let activeTasks = await urlSession.allTasks.compactMap { $0 as? URLSessionUploadTask } + return await uploadTasks + .getValues() + .values + // Values may contain inactive tasks + .filter { activeTasks.contains($0.task) } + } + } + + private var uploadTasks = ThreadSafeDictionary() + + private lazy var urlSession = URLSession( + configuration: urlSessionConfiguration, + delegate: self, + delegateQueue: nil + ) + + private let requestAdapters: [RequestAdapting] + private let responseProcessors: [ResponseProcessing] + private let errorProcessors: [ErrorProcessing] + private let urlSessionConfiguration: URLSessionConfiguration + private let sessionId: String + + public init( + urlSessionConfiguration: URLSessionConfiguration = .default, + requestAdapters: [RequestAdapting] = [], + responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], + errorProcessors: [ErrorProcessing] = [] + ) { + self.urlSessionConfiguration = urlSessionConfiguration + self.requestAdapters = requestAdapters + self.responseProcessors = responseProcessors + self.errorProcessors = errorProcessors + self.sessionId = Date.now.ISO8601Format() + super.init() + } +} + +// MARK: - URLSessionDelegate, URLSessionTaskDelegate +extension UploadAPIManager: URLSessionDelegate, URLSessionTaskDelegate {} + +// MARK: - Public API +extension UploadAPIManager: UploadAPIManaging { + public func invalidateSession(shouldFinishTasks: Bool) { + if shouldFinishTasks { + urlSession.finishTasksAndInvalidate() + } else { + urlSession.invalidateAndCancel() + } + } + + public func upload( + data: Data, + to endpoint: Requestable + ) async throws -> UploadTask { + let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + return try await uploadRequest(.data(data), request: endpointRequest) + } + + public func upload( + fromFile fileUrl: URL, + to endpoint: Requestable + ) async throws -> UploadTask { + let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + return try await uploadRequest(.file(fileUrl), request: endpointRequest) + } + + public func stateStream(for uploadTaskId: UploadTask.ID) async -> StateStream { + // TODO: Provide stream + Empty().eraseToAnyPublisher().values + } +} + +private extension UploadAPIManager { + enum Uploadable { + case data(Data) + case file(URL) + } + + func uploadRequest( + _ uploadable: Uploadable, + request: EndpointRequest + ) async throws -> UploadTask { + do { + let urlRequest = try await prepare(request) + + let task = upload(uploadable, for: urlRequest) { _, _, _ in + // TODO: Handle request completion + } + + let uploadTask = UploadTask( + task: task, + endpointRequest: request, + statePublisher: .init(UploadTask.State(task: task)) + ) + + // Store the task for future processing + await uploadTasks.set(value: uploadTask, for: request.id) + task.resume() + return uploadTask + } catch { + do { + return try await uploadRequest(uploadable, request: request) + } catch { + throw await errorProcessors.process(error, for: request) + } + } + } + + func upload( + _ uploadable: Uploadable, + for request: URLRequest, + completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + ) -> URLSessionUploadTask { + switch uploadable { + case let .data(data): + return urlSession.uploadTask( + with: request, + from: data, + completionHandler: completionHandler + ) + case let .file(fileUrl): + return urlSession.uploadTask( + with: request, + fromFile: fileUrl, + completionHandler: completionHandler + ) + } + } + + func prepare(_ request: EndpointRequest) async throws -> URLRequest { + let originalRequest = try request.endpoint.asRequest() + let adaptedRequest = try await requestAdapters.adapt(originalRequest, for: request) + return adaptedRequest + } +} + + From 379aaa3e52059a12e3e1d5abeb2677caf2026a1d Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 16:08:51 +0800 Subject: [PATCH 31/79] feat: provide and update upload task states asynchronously --- .../Core/Upload/UploadAPIManager.swift | 33 ++++++++++++++++--- .../Networking/Core/Upload/UploadTask.swift | 13 ++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index d13c539d..5ab790af 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -51,9 +51,23 @@ open class UploadAPIManager: NSObject { } // MARK: - URLSessionDelegate, URLSessionTaskDelegate -extension UploadAPIManager: URLSessionDelegate, URLSessionTaskDelegate {} +extension UploadAPIManager: URLSessionTaskDelegate { + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + Task { + await uploadTask(for: task)? + .statePublisher + .send(UploadTask.State(task: task)) + } + } +} -// MARK: - Public API +// MARK: - UploadAPIManaging extension UploadAPIManager: UploadAPIManaging { public func invalidateSession(shouldFinishTasks: Bool) { if shouldFinishTasks { @@ -80,8 +94,12 @@ extension UploadAPIManager: UploadAPIManaging { } public func stateStream(for uploadTaskId: UploadTask.ID) async -> StateStream { - // TODO: Provide stream - Empty().eraseToAnyPublisher().values + let uploadTask = await uploadTasks + .getValues() + .values + .first { $0.id == uploadTaskId } + + return uploadTask?.stateStream ?? Empty().eraseToAnyPublisher().values } } @@ -147,6 +165,13 @@ private extension UploadAPIManager { let adaptedRequest = try await requestAdapters.adapt(originalRequest, for: request) return adaptedRequest } + + func uploadTask(for task: URLSessionTask) async -> UploadTask? { + await uploadTasks + .getValues() + .values + .first { $0.taskIdentifier == task.taskIdentifier } + } } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 28b0b60e..daeebdf9 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -22,6 +22,19 @@ public struct UploadTask { let statePublisher: CurrentValueSubject } +extension UploadTask { + /// The identifier of the underlying `URLSessionUploadTask`. + var taskIdentifier: Int { + task.taskIdentifier + } + + /// An asynchronous sequence of the upload task' state. + var stateStream: AsyncPublisher> { + statePublisher.eraseToAnyPublisher().values + } +} + +// MARK: - Identifiable extension UploadTask: Identifiable { /// An unique task identifier. /// From da76db319534f2cfd207cd660288e8c885dcb341 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 16:12:34 +0800 Subject: [PATCH 32/79] feat: provide and update upload task state on completion --- .../Core/Upload/UploadAPIManager.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 5ab790af..69e19b3e 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -116,8 +116,28 @@ private extension UploadAPIManager { do { let urlRequest = try await prepare(request) - let task = upload(uploadable, for: urlRequest) { _, _, _ in - // TODO: Handle request completion + let task = upload( + uploadable, + for: urlRequest + ) { [uploadTasks, responseProcessors] data, response, error in + Task { + guard let uploadTask = await uploadTasks.getValue(for: request.id) else { + return + } + + var state = UploadTask.State(task: uploadTask.task) + if let data, let response { + state.response = try await responseProcessors.process( + (data, response), + with: urlRequest, + for: request + ) + } else if let error { + state.error = error + } + + uploadTask.statePublisher.send(state) + } } let uploadTask = UploadTask( From 5e27b809b3088a4079637d1274512629472f0b3e Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 16:30:19 +0800 Subject: [PATCH 33/79] chore: add helpful properties on upload task state --- .../Core/Upload/UploadTask+State.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Sources/Networking/Core/Upload/UploadTask+State.swift b/Sources/Networking/Core/Upload/UploadTask+State.swift index 8fcc5700..547587f0 100644 --- a/Sources/Networking/Core/Upload/UploadTask+State.swift +++ b/Sources/Networking/Core/Upload/UploadTask+State.swift @@ -27,6 +27,37 @@ extension UploadTask { } } +public extension UploadTask.State { + /// The amount of data sent indicated by values from 0 to 1. + var fractionCompleted: Double { + totalBytes > 0 ? Double(sentBytes) / Double(totalBytes) : 0 + } + + var cancelled: Bool { + (error as? URLError)?.code == .cancelled + } + + var timedOut: Bool { + (error as? URLError)?.code == .timedOut + } + + var isRunning: Bool { + taskState == .running + } + + var isSuspended: Bool { + taskState == .suspended + } + + var isCanceling: Bool { + taskState == .canceling + } + + var isCompleted: Bool { + taskState == .completed + } +} + extension UploadTask.State { /// Initializes the state from a `URLSessionTask` init(task: URLSessionTask) { From 6d5e318f92d015827a37d33881fa758e442a4033 Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Mon, 12 Jun 2023 20:37:24 +0200 Subject: [PATCH 34/79] [chore] Clean up readme --- README.md | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 560e9561..4e62a8d8 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,11 @@ -# **WIP - TBD** - # Networking -The lightweight library for API calls management. The library is built upon URL session using swift concurrency. - -## Schedule - [X] init library with sample app +The streamlined library for efficient API call management. This lightweight solution leverages the power of Swift concurrency by building upon URL sessions. - [X] logger modifier (in draft) +The library is thoughtfully documented using the DocC documentation format, ensuring comprehensive and accessible documentation for developers. +## Supported features + [X] logger modifier [X] data storing modifier - + [X] multipeer connection modifier [X] authentication modifier with default solution with access + refresh tokens - - [X] multipeer connection modifier (low priority, last one to do) - - [X] use library in iWeather example app - [X] file download with progress - - [ ] APIManager unit tests - - [ ] more complex samples in sample app (parallel tasks, retry, ...) - - [ ] tests for library objects if any are missing - - [ ] sample usage of storaged api calls for UI testing - - [ ] file upload with progress From a66f0e9a1340009efe9bb9a078931fe2dd98ebfa Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 16:39:47 +0800 Subject: [PATCH 35/79] feat: add upload feature example --- .../project.pbxproj | 38 +++++++++- .../API/Routers/SampleUploadRouter.swift | 42 ++++++++++ .../NetworkingSampleApp/ContentView.swift | 8 ++ .../Scenes/Upload/UploadItem.swift | 13 ++++ .../Scenes/Upload/UploadItemView.swift | 76 +++++++++++++++++++ .../Scenes/Upload/UploadItemViewModel.swift | 42 ++++++++++ .../Scenes/Upload/UploadService.swift | 55 ++++++++++++++ .../Scenes/Upload/UploadsView.swift | 62 +++++++++++++++ .../Scenes/Upload/UploadsViewModel.swift | 45 +++++++++++ 9 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItem.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index fbc5bfa5..5eb8bc70 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -27,6 +27,13 @@ 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 */; }; + B52674BA2A370C15006D3B9C /* SampleUploadRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */; }; + B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674BC2A370D1D006D3B9C /* UploadService.swift */; }; + B52674BF2A370D33006D3B9C /* UploadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674BE2A370D33006D3B9C /* UploadItem.swift */; }; + B52674C12A370DFF006D3B9C /* UploadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */; }; + B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */; }; + B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C42A37102D006D3B9C /* UploadsView.swift */; }; + B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C62A371046006D3B9C /* UploadItemView.swift */; }; 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 */; }; @@ -60,6 +67,13 @@ 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 = ""; }; + B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUploadRouter.swift; sourceTree = ""; }; + B52674BC2A370D1D006D3B9C /* UploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadService.swift; sourceTree = ""; }; + B52674BE2A370D33006D3B9C /* UploadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItem.swift; sourceTree = ""; }; + B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsViewModel.swift; sourceTree = ""; }; + B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemViewModel.swift; sourceTree = ""; }; + B52674C42A37102D006D3B9C /* UploadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsView.swift; sourceTree = ""; }; + B52674C62A371046006D3B9C /* UploadItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemView.swift; 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 = ""; }; @@ -126,8 +140,9 @@ 23A575ED25F8BF0E00617551 /* Scenes */ = { isa = PBXGroup; children = ( - 58C3E75B29B78ED3004FD1CD /* Download */, 58FB80C5298521DA0031FC59 /* Authorization */, + 58C3E75B29B78ED3004FD1CD /* Download */, + B52674BB2A370D0D006D3B9C /* Upload */, ); path = Scenes; sourceTree = ""; @@ -168,6 +183,7 @@ children = ( DDD3AD1E2950E794006CB777 /* SampleAuthRouter.swift */, 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */, + B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */, 23EA9CE9292FB70A00B8E418 /* SampleUserRouter.swift */, ); path = Routers; @@ -221,6 +237,19 @@ path = Resources; sourceTree = ""; }; + B52674BB2A370D0D006D3B9C /* Upload */ = { + isa = PBXGroup; + children = ( + B52674BE2A370D33006D3B9C /* UploadItem.swift */, + B52674C62A371046006D3B9C /* UploadItemView.swift */, + B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */, + B52674BC2A370D1D006D3B9C /* UploadService.swift */, + B52674C42A37102D006D3B9C /* UploadsView.swift */, + B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */, + ); + path = Upload; + sourceTree = ""; + }; DD6E48742A0E2CC70025AD05 /* Extensions */ = { isa = PBXGroup; children = ( @@ -312,6 +341,7 @@ 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */, 23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */, 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */, + B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */, 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */, 23EA9CF9292FB70A00B8E418 /* SampleUserResponse.swift in Sources */, DDD3AD1F2950E794006CB777 /* SampleAuthRouter.swift in Sources */, @@ -320,11 +350,17 @@ 23EA9CFA292FB70A00B8E418 /* SampleUserAuthRequest.swift in Sources */, 58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */, 23EA9CF4292FB70A00B8E418 /* SampleUserRouter.swift in Sources */, + B52674BF2A370D33006D3B9C /* UploadItem.swift in Sources */, 23EA9CF5292FB70A00B8E418 /* SampleAPIConstants.swift in Sources */, 58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */, DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */, + B52674BA2A370C15006D3B9C /* SampleUploadRouter.swift in Sources */, + B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */, 58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */, 58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */, + B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */, + B52674C12A370DFF006D3B9C /* UploadsViewModel.swift in Sources */, + B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */, DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */, 23EA9CF8292FB70A00B8E418 /* SampleUsersResponse.swift in Sources */, ); diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift new file mode 100644 index 00000000..cf907254 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift @@ -0,0 +1,42 @@ +// +// SampleUploadRouter.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 12.06.2023. +// + +import Foundation +import Networking +import UniformTypeIdentifiers + +enum SampleUploadRouter: Requestable { + case image + case file(URL) + + var baseURL: URL { + fatalError("Provide your API base URL for upload") + } + + var headers: [String : String]? { + switch self { + case .image: + return ["Content-Type": "image/png"] + case let .file(url): + return ["Content-Type": url.mimeType] + } + } + + var path: String { + fatalError("Provide your API endpoint path for upload") + } + + var method: HTTPMethod { + .post + } +} + +private extension URL { + var mimeType: String { + UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift index 9238bea9..6d7d037f 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift @@ -5,11 +5,13 @@ // Created by Matej Molnár on 28.01.2023. // +import Networking import SwiftUI enum NetworkingFeature: String, Hashable, CaseIterable { case authorization case downloads + case uploads } struct ContentView: View { @@ -27,6 +29,12 @@ struct ContentView: View { AuthorizationView() case .downloads: DownloadsView() + case .uploads: + UploadsView(viewModel: UploadsViewModel( + uploadService: UploadService( + uploadManager: UploadAPIManager() + ) + )) } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItem.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItem.swift new file mode 100644 index 00000000..aa329893 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItem.swift @@ -0,0 +1,13 @@ +// +// UploadItem.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 12.06.2023. +// + +import Foundation + +struct UploadItem: Hashable { + let id: String + let fileName: String +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift new file mode 100644 index 00000000..d8735949 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift @@ -0,0 +1,76 @@ +// +// UploadItemView.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 12.06.2023. +// + +import SwiftUI + +struct UploadItemView: View { + @ObservedObject var viewModel: UploadItemViewModel + + var body: some View { + VStack(alignment: .leading) { + HStack { + HStack { + Text(viewModel.fileName) + .font(.subheadline) + Text(viewModel.isCancelled ? "Cancelled" : viewModel.formattedProgress) + .font(.footnote) + .foregroundColor(.gray) + } + + Spacer() + + if !viewModel.isCancelled && !viewModel.isRetryable { + HStack { + Button(action: { + // TODO: allow pause/resume + }, label: { + Image(systemName: viewModel.isPaused ? "play" : "pause") + .symbolVariant(.circle.fill) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.blue) + }) + .buttonStyle(.plain) + .contentShape(Circle()) + + Button(action: { + // TODO: allow cancel + }, label: { + Image(systemName: "x") + .symbolVariant(.circle.fill) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.red) + }) + .buttonStyle(.plain) + .contentShape(Circle()) + } + } else if viewModel.isRetryable { + Button(action: { + // TODO: Allow retry + }, label: { + Image(systemName: "repeat") + .symbolVariant(.circle.fill) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.blue) + }) + .buttonStyle(.plain) + .contentShape(Circle()) + } + } + + if !viewModel.isCancelled { + ProgressView(value: viewModel.progress, total: viewModel.totalProgress) + .progressViewStyle(.linear) + } + } + .animation(.easeOut(duration: 0.3), value: viewModel.progress) + .padding(.vertical, 8) + .task { await viewModel.observeProgress() } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift new file mode 100644 index 00000000..18e555a9 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift @@ -0,0 +1,42 @@ +// +// UploadItemViewModel.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 12.06.2023. +// + +import Foundation + +@MainActor +final class UploadItemViewModel: ObservableObject { + @Published private(set) var progress: Double = 0 + @Published private(set) var formattedProgress: String = "" + @Published private(set) var isPaused = false + @Published private(set) var isCancelled = false + @Published private(set) var isRetryable = false + + let fileName: String + let totalProgress = 100.0 + + private let item: UploadItem + private let uploadService: UploadService + + init(item: UploadItem, uploadService: UploadService) { + self.item = item + self.fileName = item.fileName + self.uploadService = uploadService + } +} + +extension UploadItemViewModel { + func observeProgress() async { + let uploadStateStream = await uploadService.uploadStateStream(for: item.id) + for await state in uploadStateStream { + progress = state.fractionCompleted * 100 + formattedProgress = String(format: "%.2f", progress) + "%" + isPaused = state.isSuspended + isCancelled = state.cancelled + isRetryable = state.cancelled || state.timedOut + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift new file mode 100644 index 00000000..d5de90a1 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -0,0 +1,55 @@ +// +// UploadService.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 12.06.2023. +// + +import Foundation +import Networking + +final class UploadService { + private let uploadManager: UploadAPIManaging + + init(uploadManager: UploadAPIManaging) { + self.uploadManager = uploadManager + } + + deinit { + uploadManager.invalidateSession(shouldFinishTasks: false) + } +} + +extension UploadService { + func uploadImage(_ data: Data, fileName: String) async throws -> UploadItem { + let task = try await uploadManager.upload( + data: data, + to: SampleUploadRouter.image + ) + return UploadItem( + id: task.id, + fileName: fileName + ) + } + + func uploadFile(_ fileUrl: URL) async throws -> UploadItem { + let task = try await uploadManager.upload( + fromFile: fileUrl, + to: SampleUploadRouter.file(fileUrl) + ) + return UploadItem( + id: task.id, + fileName: fileUrl.lastPathComponent + ) + } + + func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream { + await uploadManager.stateStream(for: uploadTaskId) + } +} + +private extension UploadAPIManaging { + func task(with id: String) async -> UploadTask? { + await allTasks.first { $0.id == id } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift new file mode 100644 index 00000000..4851827f --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -0,0 +1,62 @@ +// +// UploadsView.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 12.06.2023. +// + +import SwiftUI +import PhotosUI + +struct UploadsView: View { + @ObservedObject var viewModel: UploadsViewModel + @State var isPhotosPickerPresented = false + @State var isFileImporterPresented = false + @State var selectedPhotoPickerItem: PhotosPickerItem? + + var body: some View { + List { + Section("Upload") { + Button("Photo") { isPhotosPickerPresented = true } + .photosPicker( + isPresented: $isPhotosPickerPresented, + selection: $selectedPhotoPickerItem, + matching: .images + ) + .onChange(of: selectedPhotoPickerItem) { photo in + Task { + if let data = try? await photo?.loadTransferable(type: Data.self) { + await viewModel.uploadImage( + data, + fileName: selectedPhotoPickerItem?.supportedContentTypes.first?.preferredFilenameExtension + ) + } + } + } + + Button("File") { isFileImporterPresented = true } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [.mp3, .mpeg4Movie] + ) { result in + Task { + if let fileUrl = try? result.get() { + await viewModel.uploadFile(at: fileUrl) + } + } + } + } + + if !viewModel.uploadItemViewModels.isEmpty { + Section("Upload progress") { + VStack { + ForEach(viewModel.uploadItemViewModels.indices, id: \.self) { index in + let viewModel = viewModel.uploadItemViewModels[index] + UploadItemView(viewModel: viewModel) + } + } + } + } + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift new file mode 100644 index 00000000..51c585a1 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -0,0 +1,45 @@ +// +// UploadsViewModel.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 12.06.2023. +// + +import Foundation + +@MainActor +final class UploadsViewModel: ObservableObject { + @Published var error: Error? + @Published var uploadItemViewModels: [UploadItemViewModel] = [] + + private let uploadService: UploadService + + init(uploadService: UploadService) { + self.uploadService = uploadService + } +} + +extension UploadsViewModel { + func uploadImage(_ imageData: Data, fileName: String?) async { + do { + let uploadItem = try await uploadService.uploadImage( + imageData, + fileName: fileName ?? "" + ) + uploadItemViewModels.append(UploadItemViewModel(item: uploadItem, uploadService: uploadService)) + } catch { + print("Failed to upload with error", error) + self.error = error + } + } + + func uploadFile(at fileUrl: URL) async { + do { + let uploadItem = try await uploadService.uploadFile(fileUrl) + uploadItemViewModels.append(UploadItemViewModel(item: uploadItem, uploadService: uploadService)) + } catch { + print("Failed to upload with error", error) + self.error = error + } + } +} From d31f7253f068adbddc2765b6b75a0072a3c8b6f6 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 16:51:37 +0800 Subject: [PATCH 36/79] feat: allow client to pause/resume/cancel upload tasks --- .../Networking/Core/Upload/UploadTask.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index daeebdf9..c73a210c 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -22,6 +22,35 @@ public struct UploadTask { let statePublisher: CurrentValueSubject } +public extension UploadTask { + /// Resumes the task. + /// Has no effect if the task is not in the suspended state. + func resume() { + if task.state == .suspended { + task.resume() + statePublisher.send(State(task: task)) + } + } + + /// Pauses the task. + /// + /// Call `resume()` to resume the upload. + /// - Note: While paused (suspended state), the task is still subject to timeouts. + func pause() { + task.suspend() + statePublisher.send(State(task: task)) + } + + /// Cancels the task. + /// + /// Calling this method will produce a `NSURLErrorCancelled` error + /// and set the task to the `URLSessionTask.State.cancelled` state. + func cancel() { + task.cancel() + statePublisher.send(State(task: task)) + } +} + extension UploadTask { /// The identifier of the underlying `URLSessionUploadTask`. var taskIdentifier: Int { From 9d1f6fd90cd66225de70d4cedfc13e1e94be5ebd Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 16:52:05 +0800 Subject: [PATCH 37/79] feat: add pause/resume/cancel task example --- .../Scenes/Upload/UploadItemView.swift | 4 ++-- .../Scenes/Upload/UploadItemViewModel.swift | 24 +++++++++++++++++++ .../Scenes/Upload/UploadService.swift | 12 ++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift index d8735949..b3ed6f46 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift @@ -26,7 +26,7 @@ struct UploadItemView: View { if !viewModel.isCancelled && !viewModel.isRetryable { HStack { Button(action: { - // TODO: allow pause/resume + viewModel.isPaused ? viewModel.resume() : viewModel.pause() }, label: { Image(systemName: viewModel.isPaused ? "play" : "pause") .symbolVariant(.circle.fill) @@ -38,7 +38,7 @@ struct UploadItemView: View { .contentShape(Circle()) Button(action: { - // TODO: allow cancel + viewModel.cancel() }, label: { Image(systemName: "x") .symbolVariant(.circle.fill) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift index 18e555a9..2442d3a6 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift @@ -39,4 +39,28 @@ extension UploadItemViewModel { isRetryable = state.cancelled || state.timedOut } } + + func pause() { + Task { + await uploadService.pause(taskId: item.id) + isPaused = true + isRetryable = false + } + } + + func resume() { + Task { + await uploadService.resume(taskId: item.id) + isPaused = false + isRetryable = false + } + } + + func cancel() { + Task { + await uploadService.cancel(taskId: item.id) + isCancelled = true + isRetryable = true + } + } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index d5de90a1..464028c2 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -46,6 +46,18 @@ extension UploadService { func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream { await uploadManager.stateStream(for: uploadTaskId) } + + func pause(taskId: String) async { + await uploadManager.task(with: taskId)?.pause() + } + + func resume(taskId: String) async { + await uploadManager.task(with: taskId)?.resume() + } + + func cancel(taskId: String) async { + await uploadManager.task(with: taskId)?.cancel() + } } private extension UploadAPIManaging { From c16793afc1f293393bdfd13070524beb598877fa Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 12 Jun 2023 16:58:24 +0800 Subject: [PATCH 38/79] chore: cleanup code --- .../Scenes/Upload/UploadItemView.swift | 67 ++++++++++--------- .../Scenes/Upload/UploadsViewModel.swift | 2 +- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift index b3ed6f46..45c11611 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift @@ -25,42 +25,26 @@ struct UploadItemView: View { if !viewModel.isCancelled && !viewModel.isRetryable { HStack { - Button(action: { - viewModel.isPaused ? viewModel.resume() : viewModel.pause() - }, label: { - Image(systemName: viewModel.isPaused ? "play" : "pause") - .symbolVariant(.circle.fill) - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.blue) - }) - .buttonStyle(.plain) - .contentShape(Circle()) + button( + symbol: viewModel.isPaused ? "play" : "pause", + color: .blue, + action: { viewModel.isPaused ? viewModel.resume() : viewModel.pause() } + ) - Button(action: { - viewModel.cancel() - }, label: { - Image(systemName: "x") - .symbolVariant(.circle.fill) - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.red) - }) - .buttonStyle(.plain) - .contentShape(Circle()) + button( + symbol: "x", + color: .red, + action: { viewModel.cancel() } + ) } } else if viewModel.isRetryable { - Button(action: { - // TODO: Allow retry - }, label: { - Image(systemName: "repeat") - .symbolVariant(.circle.fill) - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.blue) - }) - .buttonStyle(.plain) - .contentShape(Circle()) + button( + symbol: "repeat", + color: .blue, + action: { + // TODO: Allow retry + } + ) } } @@ -74,3 +58,20 @@ struct UploadItemView: View { .task { await viewModel.observeProgress() } } } + +private extension UploadItemView { + func button(symbol: String, color: Color, action: @escaping () -> Void) -> some View { + Button( + action: action, + label: { + Image(systemName: symbol) + .symbolVariant(.circle.fill) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(color) + } + ) + .buttonStyle(.plain) + .contentShape(Circle()) + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index 51c585a1..e28b4001 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -10,7 +10,7 @@ import Foundation @MainActor final class UploadsViewModel: ObservableObject { @Published var error: Error? - @Published var uploadItemViewModels: [UploadItemViewModel] = [] + @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] private let uploadService: UploadService From a933f1e1650da04bfeac5d63ceb6a8420ebbd4c7 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Tue, 13 Jun 2023 13:32:14 +0800 Subject: [PATCH 39/79] feat: add retry behavior --- .../Scenes/Upload/UploadItemView.swift | 6 +- .../Scenes/Upload/UploadItemViewModel.swift | 9 +- .../Scenes/Upload/UploadService.swift | 13 +- .../Core/Upload/UploadAPIManager.swift | 117 +++++++++++++++--- .../Core/Upload/UploadAPIManaging.swift | 17 ++- .../Networking/Core/Upload/UploadTask.swift | 21 ++++ .../Networking/Core/Upload/Uploadable.swift | 14 +++ 7 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 Sources/Networking/Core/Upload/Uploadable.swift diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift index 45c11611..aab0fa4b 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift @@ -41,14 +41,12 @@ struct UploadItemView: View { button( symbol: "repeat", color: .blue, - action: { - // TODO: Allow retry - } + action: { viewModel.retry() } ) } } - if !viewModel.isCancelled { + if !viewModel.isCancelled && !viewModel.isRetryable { ProgressView(value: viewModel.progress, total: viewModel.totalProgress) .progressViewStyle(.linear) } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift index 2442d3a6..8f94f6ad 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift @@ -36,7 +36,7 @@ extension UploadItemViewModel { formattedProgress = String(format: "%.2f", progress) + "%" isPaused = state.isSuspended isCancelled = state.cancelled - isRetryable = state.cancelled || state.timedOut + isRetryable = state.cancelled || state.timedOut || state.error != nil } } @@ -63,4 +63,11 @@ extension UploadItemViewModel { isRetryable = true } } + + func retry() { + Task { + try await uploadService.retry(item) + await observeProgress() + } + } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 464028c2..48b0a94e 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -24,7 +24,8 @@ extension UploadService { func uploadImage(_ data: Data, fileName: String) async throws -> UploadItem { let task = try await uploadManager.upload( data: data, - to: SampleUploadRouter.image + to: SampleUploadRouter.image, + retryConfiguration: .default ) return UploadItem( id: task.id, @@ -35,7 +36,8 @@ extension UploadService { func uploadFile(_ fileUrl: URL) async throws -> UploadItem { let task = try await uploadManager.upload( fromFile: fileUrl, - to: SampleUploadRouter.file(fileUrl) + to: SampleUploadRouter.file(fileUrl), + retryConfiguration: .default ) return UploadItem( id: task.id, @@ -58,6 +60,13 @@ extension UploadService { func cancel(taskId: String) async { await uploadManager.task(with: taskId)?.cancel() } + + func retry(_ uploadItem: UploadItem) async throws { + try await uploadManager.retry( + taskId: uploadItem.id, + retryConfiguration: .default + ) + } } private extension UploadAPIManaging { diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 69e19b3e..24b91ebf 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -79,18 +79,48 @@ extension UploadAPIManager: UploadAPIManaging { public func upload( data: Data, - to endpoint: Requestable + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) - return try await uploadRequest(.data(data), request: endpointRequest) + return try await uploadRequest( + .data(data), + request: endpointRequest, + retryConfiguration: retryConfiguration + ) } public func upload( fromFile fileUrl: URL, - to endpoint: Requestable + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) - return try await uploadRequest(.file(fileUrl), request: endpointRequest) + return try await uploadRequest( + .file(fileUrl), + request: endpointRequest, + retryConfiguration: retryConfiguration + ) + } + + public func retry( + taskId: String, + retryConfiguration: RetryConfiguration? + ) async throws { + // Get stored upload task to invoke the request with the same arguments + guard let existingUploadTask = await uploadTasks.getValue(for: taskId) else { + throw NetworkError.unknown + } + + // Removes the existing task from internal storage so that the `uploadRequest` + // invocation treats the request/task as new + await uploadTasks.set(value: nil, for: taskId) + + try await uploadRequest( + existingUploadTask.uploadable, + request: existingUploadTask.endpointRequest, + retryConfiguration: retryConfiguration + ) } public func stateStream(for uploadTaskId: UploadTask.ID) async -> StateStream { @@ -104,14 +134,11 @@ extension UploadAPIManager: UploadAPIManaging { } private extension UploadAPIManager { - enum Uploadable { - case data(Data) - case file(URL) - } - + @discardableResult func uploadRequest( _ uploadable: Uploadable, - request: EndpointRequest + request: EndpointRequest, + retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { do { let urlRequest = try await prepare(request) @@ -119,7 +146,7 @@ private extension UploadAPIManager { let task = upload( uploadable, for: urlRequest - ) { [uploadTasks, responseProcessors] data, response, error in + ) { [unowned self, uploadTasks, responseProcessors, errorProcessors] data, response, error in Task { guard let uploadTask = await uploadTasks.getValue(for: request.id) else { return @@ -132,19 +159,65 @@ private extension UploadAPIManager { with: urlRequest, for: request ) + + uploadTask.statePublisher.send(state) + + // Publishing value and completion one after another might cause the completion + // cancelling the whole stream before the client processed the emitted value. + try await Task.sleep(nanoseconds: 20_000_000) + uploadTask.statePublisher.send(completion: .finished) + + // Cleanup on successful task completion + await uploadTask.resetRetryCounter() + await uploadTasks.set(value: nil, for: request.id) } else if let error { - state.error = error - } + do { + try await uploadTask.sleepIfRetry( + for: error, + retryConfiguration: retryConfiguration + ) + + try await self.uploadRequest( + uploadTask.uploadable, + request: uploadTask.endpointRequest, + retryConfiguration: retryConfiguration + ) + } catch { + state.error = await errorProcessors.process( + error, + for: uploadTask.endpointRequest + ) - uploadTask.statePublisher.send(state) + uploadTask.statePublisher.send(state) + + // Publishing value and completion one after another might cause the completion + // cancelling the whole stream before the client processed the emitted value. + try await Task.sleep(nanoseconds: 20_000_000) + uploadTask.statePublisher.send(completion: .finished) + } + } } } - let uploadTask = UploadTask( - task: task, - endpointRequest: request, - statePublisher: .init(UploadTask.State(task: task)) - ) + // Get any stored upload task and update its internal URLSessionUploadTask, or create a new one + let uploadTask: UploadTask + if let existingUploadTask = await uploadTasks.getValue(for: request.id) { + uploadTask = UploadTask( + task: task, + endpointRequest: existingUploadTask.endpointRequest, + uploadable: existingUploadTask.uploadable, + statePublisher: existingUploadTask.statePublisher, + retryCounter: existingUploadTask.retryCounter + ) + } else { + uploadTask = UploadTask( + task: task, + endpointRequest: request, + uploadable: uploadable, + statePublisher: .init(UploadTask.State(task: task)), + retryCounter: Counter() + ) + } // Store the task for future processing await uploadTasks.set(value: uploadTask, for: request.id) @@ -152,7 +225,11 @@ private extension UploadAPIManager { return uploadTask } catch { do { - return try await uploadRequest(uploadable, request: request) + return try await uploadRequest( + uploadable, + request: request, + retryConfiguration: retryConfiguration + ) } catch { throw await errorProcessors.process(error, for: request) } diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index f99e2692..0e4a9db8 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -18,23 +18,36 @@ public protocol UploadAPIManaging { /// - Parameters: /// - data: The data to send to the server. /// - endpoint: The API endpoint to where data will be sent. + /// - retryConfiguration: An optional configuration for retry behavior. /// - Returns: An `UploadTask` that represents this request. func upload( data: Data, - to endpoint: Requestable + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? ) async throws -> UploadTask /// Initiates a file upload request for the specified endpoint. /// - Parameters: /// - fileUrl: The file's URL to send to the server. /// - endpoint: The API endpoint to where data will be sent. + /// - retryConfiguration: An optional configuration for retry behavior. /// - Returns: An `UploadTask` that represents this request. func upload( fromFile fileUrl: URL, - to endpoint: Requestable + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? ) async throws -> UploadTask + /// Retries the upload task with the specified identifier. + /// - Parameters: + /// - taskId: The upload task's identifier to retry. + /// - retryConfiguration: An optional configuration for retry behavior. + func retry(taskId: String, retryConfiguration: RetryConfiguration?) async throws + /// Provides a stream of upload task's states for the specified `UploadTask.ID`. + /// + /// The stream stops providing updates whenever the internal stream produces an error, + /// i.e., `UploadTask.State.error` is non-nil. In such case, you can call `retry(taskId:)` to re-activate the stream for the specified `uploadTaskId`. /// - Parameter uploadTaskId: The identifier of the task to observe. /// - Returns: An asynchronous stream of upload state. func stateStream(for uploadTaskId: UploadTask.ID) async -> StateStream diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index c73a210c..f1a1b16f 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -18,8 +18,14 @@ public struct UploadTask { /// The request associated with this task. let endpointRequest: EndpointRequest + /// The uploadable data associated with this task. + let uploadable: Uploadable + /// Use this publisher to emit a new state of the task. let statePublisher: CurrentValueSubject + + /// The counter that counts number of retries for this task. + let retryCounter: Counter } public extension UploadTask { @@ -63,6 +69,21 @@ extension UploadTask { } } +// MARK: - Retryable +extension UploadTask: Retryable { + func sleepIfRetry(for error: Error, retryConfiguration: RetryConfiguration?) async throws { + try await sleepIfRetry( + for: error, + endpointRequest: endpointRequest, + retryConfiguration: retryConfiguration + ) + } + + func resetRetryCounter() async { + await retryCounter.reset(for: endpointRequest.id) + } +} + // MARK: - Identifiable extension UploadTask: Identifiable { /// An unique task identifier. diff --git a/Sources/Networking/Core/Upload/Uploadable.swift b/Sources/Networking/Core/Upload/Uploadable.swift new file mode 100644 index 00000000..bec9dc84 --- /dev/null +++ b/Sources/Networking/Core/Upload/Uploadable.swift @@ -0,0 +1,14 @@ +// +// Uploadable.swift +// +// +// Created by Tony Ngo on 13.06.2023. +// + +import Foundation + +/// Represents a data type that can be uploaded. +enum Uploadable { + case data(Data) + case file(URL) +} From 4d026679997996370f813b86e1adbb39c0762c0f Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Tue, 13 Jun 2023 13:33:42 +0800 Subject: [PATCH 40/79] refactor: do not retry on internal errors thrown --- .../Networking/Core/Upload/UploadAPIManager.swift | 12 +----------- .../Networking/Core/Upload/UploadTask+State.swift | 1 - 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 24b91ebf..5ff513fa 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -224,15 +224,7 @@ private extension UploadAPIManager { task.resume() return uploadTask } catch { - do { - return try await uploadRequest( - uploadable, - request: request, - retryConfiguration: retryConfiguration - ) - } catch { - throw await errorProcessors.process(error, for: request) - } + throw await errorProcessors.process(error, for: request) } } @@ -270,5 +262,3 @@ private extension UploadAPIManager { .first { $0.taskIdentifier == task.taskIdentifier } } } - - diff --git a/Sources/Networking/Core/Upload/UploadTask+State.swift b/Sources/Networking/Core/Upload/UploadTask+State.swift index 547587f0..c0553ddb 100644 --- a/Sources/Networking/Core/Upload/UploadTask+State.swift +++ b/Sources/Networking/Core/Upload/UploadTask+State.swift @@ -67,4 +67,3 @@ extension UploadTask.State { error = task.error } } - From ef2f519a56b837ec9c13b011b3766edfb32b12ba Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Tue, 13 Jun 2023 17:17:00 +0800 Subject: [PATCH 41/79] refactor: separate upload request into logical blocks --- .../Scenes/Upload/UploadsView.swift | 1 + .../Core/Upload/UploadAPIManager.swift | 170 ++++++++++-------- .../Networking/Core/Upload/UploadTask.swift | 29 ++- 3 files changed, 125 insertions(+), 75 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index 4851827f..4492373a 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -58,5 +58,6 @@ struct UploadsView: View { } } } + .navigationTitle("Uploads") } } diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 5ff513fa..47e2adb9 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -10,6 +10,7 @@ import Foundation /// Default upload API manager open class UploadAPIManager: NSObject { + // MARK: - Public Properties public var allTasks: [UploadTask] { get async { let activeTasks = await urlSession.allTasks.compactMap { $0 as? URLSessionUploadTask } @@ -21,6 +22,7 @@ open class UploadAPIManager: NSObject { } } + // MARK: - Private Properties private var uploadTasks = ThreadSafeDictionary() private lazy var urlSession = URLSession( @@ -35,6 +37,7 @@ open class UploadAPIManager: NSObject { private let urlSessionConfiguration: URLSessionConfiguration private let sessionId: String + // MARK: - Initialization public init( urlSessionConfiguration: URLSessionConfiguration = .default, requestAdapters: [RequestAdapting] = [], @@ -50,7 +53,7 @@ open class UploadAPIManager: NSObject { } } -// MARK: - URLSessionDelegate, URLSessionTaskDelegate +// MARK: - URLSessionTaskDelegate extension UploadAPIManager: URLSessionTaskDelegate { public func urlSession( _ session: URLSession, @@ -133,6 +136,7 @@ extension UploadAPIManager: UploadAPIManaging { } } +// MARK: - Private API private extension UploadAPIManager { @discardableResult func uploadRequest( @@ -143,93 +147,111 @@ private extension UploadAPIManager { do { let urlRequest = try await prepare(request) - let task = upload( - uploadable, + let sessionUploadTask = sessionUploadTask( + with: uploadable, for: urlRequest - ) { [unowned self, uploadTasks, responseProcessors, errorProcessors] data, response, error in - Task { - guard let uploadTask = await uploadTasks.getValue(for: request.id) else { - return - } + ) { [weak self] data, response, error in + self?.handleUploadTaskCompletion( + urlRequest: urlRequest, + endpointRequest: request, + retryConfiguration: retryConfiguration, + data: data, + response: response, + error: error + ) + } - var state = UploadTask.State(task: uploadTask.task) - if let data, let response { - state.response = try await responseProcessors.process( - (data, response), - with: urlRequest, - for: request - ) + let uploadTask = await existingUploadTaskOrNew( + for: sessionUploadTask, + request: request, + uploadable: uploadable + ) - uploadTask.statePublisher.send(state) + // Store the task for future processing + await uploadTasks.set(value: uploadTask, for: request.id) + sessionUploadTask.resume() + return uploadTask + } catch { + throw await errorProcessors.process(error, for: request) + } + } - // Publishing value and completion one after another might cause the completion - // cancelling the whole stream before the client processed the emitted value. - try await Task.sleep(nanoseconds: 20_000_000) - uploadTask.statePublisher.send(completion: .finished) + /// Returns any stored upload task and updates its internal URLSessionUploadTask, or creates a new one. + func existingUploadTaskOrNew( + for sessionUploadTask: URLSessionUploadTask, + request: EndpointRequest, + uploadable: Uploadable + ) async -> UploadTask { + guard var existingUploadTask = await uploadTasks.getValue(for: request.id) else { + return UploadTask( + sessionUploadTask: sessionUploadTask, + endpointRequest: request, + uploadable: uploadable + ) + } + existingUploadTask.task = sessionUploadTask + return existingUploadTask + } - // Cleanup on successful task completion - await uploadTask.resetRetryCounter() - await uploadTasks.set(value: nil, for: request.id) - } else if let error { - do { - try await uploadTask.sleepIfRetry( - for: error, - retryConfiguration: retryConfiguration - ) + func handleUploadTaskCompletion( + urlRequest: URLRequest, + endpointRequest: EndpointRequest, + retryConfiguration: RetryConfiguration?, + data: Data?, + response: URLResponse?, + error: Error? + ) { + Task { + guard let uploadTask = await uploadTasks.getValue(for: endpointRequest.id) else { + return + } - try await self.uploadRequest( - uploadTask.uploadable, - request: uploadTask.endpointRequest, - retryConfiguration: retryConfiguration - ) - } catch { - state.error = await errorProcessors.process( - error, - for: uploadTask.endpointRequest - ) + var state = UploadTask.State(task: uploadTask.task) + if let data, let response { + state.response = try await responseProcessors.process( + (data, response), + with: urlRequest, + for: endpointRequest + ) - uploadTask.statePublisher.send(state) + try await uploadTask.complete(with: state) - // Publishing value and completion one after another might cause the completion - // cancelling the whole stream before the client processed the emitted value. - try await Task.sleep(nanoseconds: 20_000_000) - uploadTask.statePublisher.send(completion: .finished) - } + // Cleanup on successful task completion + await uploadTask.resetRetryCounter() + await uploadTasks.set(value: nil, for: endpointRequest.id) + } else if let error { + do { + // URLError.Code.cancelled is thrown if the URLSessionTask is cancelled. + // Consider this action intentional, thus the request won't be retried. + guard !state.cancelled else { + throw error } - } - } - // Get any stored upload task and update its internal URLSessionUploadTask, or create a new one - let uploadTask: UploadTask - if let existingUploadTask = await uploadTasks.getValue(for: request.id) { - uploadTask = UploadTask( - task: task, - endpointRequest: existingUploadTask.endpointRequest, - uploadable: existingUploadTask.uploadable, - statePublisher: existingUploadTask.statePublisher, - retryCounter: existingUploadTask.retryCounter - ) - } else { - uploadTask = UploadTask( - task: task, - endpointRequest: request, - uploadable: uploadable, - statePublisher: .init(UploadTask.State(task: task)), - retryCounter: Counter() - ) - } + try await uploadTask.sleepIfRetry( + for: error, + retryConfiguration: retryConfiguration + ) - // Store the task for future processing - await uploadTasks.set(value: uploadTask, for: request.id) - task.resume() - return uploadTask - } catch { - throw await errorProcessors.process(error, for: request) + try await self.uploadRequest( + uploadTask.uploadable, + request: uploadTask.endpointRequest, + retryConfiguration: retryConfiguration + ) + } catch { + state.error = await errorProcessors.process( + error, + for: uploadTask.endpointRequest + ) + + // No cleanup in case the task will be retried. + try await uploadTask.complete(with: state) + } + } } } - func upload( - _ uploadable: Uploadable, + func sessionUploadTask( + with uploadable: Uploadable, for request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void ) -> URLSessionUploadTask { diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index f1a1b16f..31b01a68 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -13,7 +13,7 @@ public struct UploadTask { public typealias ID = String /// The session task this object represents. - let task: URLSessionUploadTask + var task: URLSessionUploadTask /// The request associated with this task. let endpointRequest: EndpointRequest @@ -67,6 +67,33 @@ extension UploadTask { var stateStream: AsyncPublisher> { statePublisher.eraseToAnyPublisher().values } + + /// Completes the upload task by emitting the latest state and completing the state stream. + /// - Parameters: + /// - state: The latest state to emit before completing the task. + /// - delay: The delay between the emitting the `state` and completion in nanoseconds. Defaults to 0.2 seconds. + func complete(with state: State, delay: TimeInterval = 20_000_000) async throws { + statePublisher.send(state) + + // Publishing value and completion one after another might cause the completion + // cancelling the whole stream before the client can process the emitted value. + try await Task.sleep(nanoseconds: UInt64(delay)) + statePublisher.send(completion: .finished) + } +} + +extension UploadTask { + init( + sessionUploadTask: URLSessionUploadTask, + endpointRequest: EndpointRequest, + uploadable: Uploadable + ) { + self.task = sessionUploadTask + self.endpointRequest = endpointRequest + self.uploadable = uploadable + self.statePublisher = .init(State(task: sessionUploadTask)) + self.retryCounter = Counter() + } } // MARK: - Retryable From 74bf2b61151911be7d0783d81e6c5d50ca22ffed Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Tue, 13 Jun 2023 17:25:38 +0800 Subject: [PATCH 42/79] feat: add completed state example --- .../Scenes/Upload/UploadItemView.swift | 4 ++-- .../Scenes/Upload/UploadItemViewModel.swift | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift index aab0fa4b..0c7c9938 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift @@ -16,14 +16,14 @@ struct UploadItemView: View { HStack { Text(viewModel.fileName) .font(.subheadline) - Text(viewModel.isCancelled ? "Cancelled" : viewModel.formattedProgress) + Text(viewModel.stateTitle) .font(.footnote) .foregroundColor(.gray) } Spacer() - if !viewModel.isCancelled && !viewModel.isRetryable { + if !viewModel.isCancelled && !viewModel.isRetryable && !viewModel.isCompleted { HStack { button( symbol: viewModel.isPaused ? "play" : "pause", diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift index 8f94f6ad..c47e14e1 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift @@ -15,6 +15,14 @@ final class UploadItemViewModel: ObservableObject { @Published private(set) var isCancelled = false @Published private(set) var isRetryable = false + var stateTitle: String { + isCancelled + ? "Cancelled" + : isCompleted ? "Completed" : formattedProgress + } + + var isCompleted: Bool { progress == 100 } + let fileName: String let totalProgress = 100.0 From ce69ec8bccd49e0ab72383907d84a4dd0383bcfa Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Tue, 13 Jun 2023 18:19:53 +0800 Subject: [PATCH 43/79] docs: add stream completing for non present task --- Sources/Networking/Core/Upload/UploadAPIManaging.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 0e4a9db8..75919119 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -49,7 +49,7 @@ public protocol UploadAPIManaging { /// The stream stops providing updates whenever the internal stream produces an error, /// i.e., `UploadTask.State.error` is non-nil. In such case, you can call `retry(taskId:)` to re-activate the stream for the specified `uploadTaskId`. /// - Parameter uploadTaskId: The identifier of the task to observe. - /// - Returns: An asynchronous stream of upload state. + /// - Returns: An asynchronous stream of upload state. If there is no such upload task the return stream finishes immediately. func stateStream(for uploadTaskId: UploadTask.ID) async -> StateStream /// Invalidates the session with the option to wait for all outstanding (active) tasks. From c63415dcd247500764e1a547ae470627c0fbaac4 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 14:16:13 +0800 Subject: [PATCH 44/79] chore: add content disposition http header field --- Sources/Networking/Misc/HTTPHeader.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Networking/Misc/HTTPHeader.swift b/Sources/Networking/Misc/HTTPHeader.swift index 1272d79e..f46fb2a1 100644 --- a/Sources/Networking/Misc/HTTPHeader.swift +++ b/Sources/Networking/Misc/HTTPHeader.swift @@ -13,8 +13,9 @@ import Foundation public enum HTTPHeader { /// Constants that describe HTTP header keys. public enum HeaderField: String { - case contentType = "Content-Type" case authorization = "Authorization" + case contentDisposition = "Content-Disposition" + case contentType = "Content-Type" } /// Constants that describe values for HTTP header content type keys. From 65dc0967dcbc1e70eb863e2eff5fd1845a474a45 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 14:20:56 +0800 Subject: [PATCH 45/79] chore: define multiform data objects --- .../Core/Upload/MultiFormData+BodyPart.swift | 37 +++++++++++++++++++ .../Core/Upload/MultiFormData.swift | 12 ++++++ 2 files changed, 49 insertions(+) create mode 100644 Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift create mode 100644 Sources/Networking/Core/Upload/MultiFormData.swift diff --git a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift new file mode 100644 index 00000000..c8b274d6 --- /dev/null +++ b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift @@ -0,0 +1,37 @@ +// +// MultiFormData+BodyPart.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +public extension MultiFormData { + struct BodyPart { + let dataStream: InputStream + let name: String + let fileName: String? + let mimeType: String? + } +} + +extension MultiFormData.BodyPart { + var contentHeaders: [HTTPHeader.HeaderField: String] { + var disposition = "form-data; name=\"\(name)\"" + + if let fileName { + disposition += "; filename=\"\(fileName)\"" + } + + var headers: [HTTPHeader.HeaderField: String] = [ + .contentDisposition: disposition + ] + + if let mimeType { + headers[.contentType] = mimeType + } + + return headers + } +} diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift new file mode 100644 index 00000000..5f3d65c7 --- /dev/null +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -0,0 +1,12 @@ +// +// File.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +open class MultiFormData { + +} From bcc3ad8fdfc61f804ccdef108fef2183d1fe1675 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 15:38:01 +0800 Subject: [PATCH 46/79] feat: allow appending base form data types --- .../Core/Upload/MultiFormData.swift | 69 ++++++++++++++++++- .../Networking/Utils/URL+Convenience.swift | 19 +++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 Sources/Networking/Utils/URL+Convenience.swift diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index 5f3d65c7..fb7e3d4b 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -1,5 +1,5 @@ // -// File.swift +// MultiFormData.swift // // // Created by Tony Ngo on 18.06.2023. @@ -8,5 +8,70 @@ import Foundation open class MultiFormData { - + private(set) var bodyParts: [BodyPart] = [] + let boundary: String + + public init(boundary: String? = nil) { + self.boundary = boundary ?? "--boundary-\(UUID().uuidString)" + } +} + +// MARK: - Adding form data +public extension MultiFormData { + func append( + _ data: Data, + name: String, + fileName: String? = nil, + mimeType: String? = nil + ) { + let dataStream = InputStream(data: data) + append(dataStream: dataStream, name: name, fileName: fileName, mimeType: mimeType) + } + + func append( + from fileUrl: URL, + name: String, + fileName: String? = nil, + mimeType: String? = nil + ) throws { + let fileName = fileName ?? fileUrl.lastPathComponent + + guard !fileName.isEmpty && !fileUrl.pathExtension.isEmpty else { + throw EncodingError.invalidFileName(at: fileUrl) + } + + guard + !fileUrl.isDirectory && fileUrl.isFileURL, + let dataStream = InputStream(url: fileUrl) + else { + throw EncodingError.invalidFileUrl(fileUrl) + } + + append(dataStream: dataStream, name: name, fileName: fileName, mimeType: mimeType ?? fileUrl.mimeType) + } +} + +// MARK: - Private +private extension MultiFormData { + func append( + dataStream: InputStream, + name: String, + fileName: String? = nil, + mimeType: String? = nil + ) { + bodyParts.append(BodyPart( + dataStream: dataStream, + name: name, + fileName: fileName, + mimeType: mimeType + )) + } +} + +// MARK: - Errors +extension MultiFormData { + public enum EncodingError: LocalizedError { + case invalidFileUrl(URL) + case invalidFileName(at: URL) + } } diff --git a/Sources/Networking/Utils/URL+Convenience.swift b/Sources/Networking/Utils/URL+Convenience.swift new file mode 100644 index 00000000..a2b8e592 --- /dev/null +++ b/Sources/Networking/Utils/URL+Convenience.swift @@ -0,0 +1,19 @@ +// +// URL+Convenience.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation +import UniformTypeIdentifiers + +extension URL { + var mimeType: String { + UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" + } + + var isDirectory: Bool { + (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } +} From b3920d7e2137d424f4116051de9fd27a7d3eb289 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 17:44:11 +0800 Subject: [PATCH 47/79] chore: introduce default multi part data encoder --- .../Core/Upload/MultiFormData.swift | 4 + .../Core/Upload/MultiFormDataEncoder.swift | 167 ++++++++++++++++++ .../Core/Upload/MultiFormDataEncoding.swift | 13 ++ 3 files changed, 184 insertions(+) create mode 100644 Sources/Networking/Core/Upload/MultiFormDataEncoder.swift create mode 100644 Sources/Networking/Core/Upload/MultiFormDataEncoding.swift diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index fb7e3d4b..2c151c86 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -73,5 +73,9 @@ extension MultiFormData { public enum EncodingError: LocalizedError { case invalidFileUrl(URL) case invalidFileName(at: URL) + case dataStreamReadFailed(with: Error) + case dataStreamWriteFailed(at: URL) + case fileAlreadyExists(at: URL) + } } diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift new file mode 100644 index 00000000..056d85df --- /dev/null +++ b/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift @@ -0,0 +1,167 @@ +// +// MultiFormDataEncoder.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +open class MultiFormDataEncoder { + private let crlf = "\r\n" + + private let fileManager: FileManager + private let streamBufferSize: Int + + public init( + fileManager: FileManager = .default, + streamBufferSize: Int = 1024 + ) { + self.fileManager = fileManager + self.streamBufferSize = streamBufferSize + } +} + +// MARK: - MultiFormDataEncoding +extension MultiFormDataEncoder: MultiFormDataEncoding { + public func encode(_ multiFormData: MultiFormData) throws -> Data { + var encoded = Data() + + for bodyPart in multiFormData.bodyParts { + encoded.append("\(multiFormData.boundary)\(crlf)") + + let encodedHeaders = encode(bodyPart.contentHeaders) + encoded.append(encodedHeaders) + encoded.append("\(crlf)\(crlf)") + + let encodedData = try encode(bodyPart.dataStream) + encoded.append(encodedData) + encoded.append("\(crlf)") + } + + encoded.append("\(multiFormData.boundary)--\(crlf)") + return encoded + } + + public func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws { + guard fileUrl.isFileURL else { + throw MultiFormData.EncodingError.invalidFileUrl(fileUrl) + } + + guard !fileManager.fileExists(at: fileUrl) else { + throw MultiFormData.EncodingError.fileAlreadyExists(at: fileUrl) + } + + guard let outputStream = OutputStream(url: fileUrl, append: false) else { + throw MultiFormData.EncodingError.dataStreamWriteFailed(at: fileUrl) + } + + try encode(multiFormData, into: outputStream) + } +} + +private extension MultiFormDataEncoder { + func encode(_ multiFormData: MultiFormData, into outputStream: OutputStream) throws { + outputStream.open() + defer { outputStream.close() } + + for bodyPart in multiFormData.bodyParts { + let encodedBoundary = "\(multiFormData.boundary)\(crlf)".data + try write(encodedBoundary, into: outputStream) + + var encodedHeaders = encode(bodyPart.contentHeaders) + encodedHeaders.append("\(crlf)\(crlf)") + try write(encodedHeaders, into: outputStream) + + try write(bodyPart.dataStream, into: outputStream) + try write("\(crlf)".data, into: outputStream) + } + + try write("\(multiFormData.boundary)--\(crlf)".data, into: outputStream) + } + + func write(_ inputStream: InputStream, into outputStream: OutputStream) throws { + let buffer = UnsafeMutablePointer.allocate(capacity: streamBufferSize) + inputStream.open() + defer { + inputStream.close() + buffer.deallocate() + } + + while inputStream.hasBytesAvailable && outputStream.hasSpaceAvailable { + let bytesRead = inputStream.read(buffer, maxLength: streamBufferSize) + + if bytesRead == -1, let error = inputStream.streamError { + throw MultiFormData.EncodingError.dataStreamReadFailed(with: error) + } + + if bytesRead > 0 { + outputStream.write(buffer, maxLength: bytesRead) + } + } + } + + func write(_ data: Data, into outputStream: OutputStream) throws { + let inputStream = InputStream(data: data) + try write(inputStream, into: outputStream) + } + + func encode(_ dataStream: InputStream) throws -> Data { + let buffer = UnsafeMutablePointer.allocate(capacity: streamBufferSize) + dataStream.open() + + defer { + dataStream.close() + buffer.deallocate() + } + + var encoded = Data() + while dataStream.hasBytesAvailable { + let bytesRead = dataStream.read(buffer, maxLength: streamBufferSize) + + if bytesRead == -1, let error = dataStream.streamError { + throw MultiFormData.EncodingError.dataStreamReadFailed(with: error) + } + + if bytesRead > 0 { + encoded.append(buffer, count: bytesRead) + } + } + return encoded + } + + func encode(_ contentHeaders: [HTTPHeader.HeaderField: String]) -> Data { + var encoded = Data() + + // Encode headers in a deterministic manner for easier testing + let encodedHeaders = contentHeaders + .sorted(by: { $0.key.rawValue < $1.key.rawValue }) + .map { "\($0.key.rawValue): \($0.value)"} + .joined(separator: "\(crlf)") + + encoded.append(encodedHeaders) + return encoded + } +} + +private extension FileManager { + func fileExists(at fileUrl: URL) -> Bool { + if #available(macOS 13.0, iOS 16.0, *) { + return fileExists(atPath: fileUrl.path()) + } else { + return fileExists(atPath: fileUrl.path) + } + } +} + +private extension String { + var data: Data { + Data(self.utf8) + } +} + +private extension Data { + mutating func append(_ string: String) { + self.append(string.data) + } +} diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift new file mode 100644 index 00000000..27c045a2 --- /dev/null +++ b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift @@ -0,0 +1,13 @@ +// +// MultiFormDataEncoding.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +public protocol MultiFormDataEncoding { + func encode(_ multiFormData: MultiFormData) throws -> Data + func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws +} From 72836efbe5f6438bc237dfe737170e6d48d6cd7d Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 18:36:08 +0800 Subject: [PATCH 48/79] test: add encoder tests --- .../MultiFormDataEncoderTests.swift | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 Tests/NetworkingTests/MultiFormDataEncoderTests.swift diff --git a/Tests/NetworkingTests/MultiFormDataEncoderTests.swift b/Tests/NetworkingTests/MultiFormDataEncoderTests.swift new file mode 100644 index 00000000..2b9be5df --- /dev/null +++ b/Tests/NetworkingTests/MultiFormDataEncoderTests.swift @@ -0,0 +1,111 @@ +// +// MultiFormDataEncoderTests.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Networking +import XCTest + +final class MultiFormDataEncoderTests: XCTestCase { + private let fileManager = FileManager.default + + private var temporaryDirectoryUrl: URL { + URL( + fileURLWithPath: NSTemporaryDirectory(), + isDirectory: true + ).appendingPathComponent("multiformdata-encoder-tests") + } + + override func setUpWithError() throws { + try super.setUpWithError() + try fileManager.createDirectory( + atPath: temporaryDirectoryUrl.path, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + try fileManager.removeItem(at: temporaryDirectoryUrl) + } + + func test_encode_encodesDataAsExpected() throws { + let sut = makeSUT() + let formData = MultiFormData(boundary: "--boundary--123") + + let data1 = Data("Hello".utf8) + formData.append(data1, name: "first-data") + + let data2 = Data("World".utf8) + formData.append(data2, name: "second-data", fileName: "file.txt", mimeType: "text/plain") + + let encoded = try sut.encode(formData) + let expectedString = "--boundary--123\r\n" + + "Content-Disposition: form-data; name=\"first-data\"\r\n\r\n" + + "Hello\r\n" + + "--boundary--123\r\n" + + "Content-Disposition: form-data; name=\"second-data\"; filename=\"file.txt\"\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "World\r\n" + + "--boundary--123--\r\n" + + XCTAssertEqual(encoded, Data(expectedString.utf8)) + } + + func test_encode_encodesToFileAsExpected() throws { + let sut = makeSUT() + let formData = MultiFormData(boundary: "--boundary--123") + + let data = Data("Hello".utf8) + formData.append(data, name: "first-data") + + let tmpFileUrl = temporaryDirectoryUrl.appendingPathComponent(UUID().uuidString) + try sut.encode(formData, to: tmpFileUrl) + + let encoded = try Data(contentsOf: tmpFileUrl) + + let expectedString = "--boundary--123\r\n" + + "Content-Disposition: form-data; name=\"first-data\"\r\n\r\n" + + "Hello\r\n" + + "--boundary--123--\r\n" + + XCTAssertEqual(encoded, Data(expectedString.utf8)) + } + + func test_encode_throwsInvalidFileUrl() { + let sut = makeSUT() + let formData = MultiFormData() + let tmpFileUrl = URL(string: "invalid/path")! + + do { + try sut.encode(formData, to: tmpFileUrl) + XCTFail("Encoding should have failed.") + } catch MultiFormData.EncodingError.invalidFileUrl { + } catch { + XCTFail("Should have failed with MultiFormData.EncodingError.fileAlreadyExists") + } + } + + func test_encode_throwsFileAlreadyExists() { + let sut = makeSUT() + let formData = MultiFormData() + let tmpFileUrl = temporaryDirectoryUrl.appendingPathComponent("file") + try? sut.encode(formData, to: tmpFileUrl) + do { + try sut.encode(formData, to: tmpFileUrl) + XCTFail("Encoding should have failed.") + } catch MultiFormData.EncodingError.fileAlreadyExists { + } catch { + XCTFail("Should have failed with MultiFormData.EncodingError.fileAlreadyExists") + } + } +} + +private extension MultiFormDataEncoderTests { + func makeSUT(fileManager: FileManager = .default) -> MultiFormDataEncoder { + let sut = MultiFormDataEncoder(fileManager: fileManager) + return sut + } +} From 3d0118527981e96082580ad8d0a09983eebcc0ff Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 21:00:23 +0800 Subject: [PATCH 49/79] feat: count body part size --- .../Core/Upload/MultiFormData+BodyPart.swift | 1 + .../Core/Upload/MultiFormData.swift | 24 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift index c8b274d6..3757588d 100644 --- a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift @@ -11,6 +11,7 @@ public extension MultiFormData { struct BodyPart { let dataStream: InputStream let name: String + let size: UInt64 let fileName: String? let mimeType: String? } diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index 2c151c86..4c86436d 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -8,7 +8,12 @@ import Foundation open class MultiFormData { + public var size: UInt64 { + bodyParts.reduce(0) { $0 + $1.size } + } + private(set) var bodyParts: [BodyPart] = [] + let boundary: String public init(boundary: String? = nil) { @@ -25,12 +30,19 @@ public extension MultiFormData { mimeType: String? = nil ) { let dataStream = InputStream(data: data) - append(dataStream: dataStream, name: name, fileName: fileName, mimeType: mimeType) + append( + dataStream: dataStream, + name: name, + size: UInt64(data.count), + fileName: fileName, + mimeType: mimeType + ) } func append( from fileUrl: URL, name: String, + size: UInt64, fileName: String? = nil, mimeType: String? = nil ) throws { @@ -47,7 +59,13 @@ public extension MultiFormData { throw EncodingError.invalidFileUrl(fileUrl) } - append(dataStream: dataStream, name: name, fileName: fileName, mimeType: mimeType ?? fileUrl.mimeType) + append( + dataStream: dataStream, + name: name, + size: size, + fileName: fileName, + mimeType: mimeType ?? fileUrl.mimeType + ) } } @@ -56,12 +74,14 @@ private extension MultiFormData { func append( dataStream: InputStream, name: String, + size: UInt64, fileName: String? = nil, mimeType: String? = nil ) { bodyParts.append(BodyPart( dataStream: dataStream, name: name, + size: size, fileName: fileName, mimeType: mimeType )) From 9b2bafbe303a5f1d040c0d5c871e4bc64a79674e Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 21:40:29 +0800 Subject: [PATCH 50/79] feat: add multipart/form-data upload support --- .../Core/Upload/UploadAPIManager.swift | 41 +++++++++++++++++++ .../Core/Upload/UploadAPIManaging.swift | 34 +++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 47e2adb9..d1f66f62 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -31,6 +31,8 @@ open class UploadAPIManager: NSObject { delegateQueue: nil ) + private let multiFormDataEncoder: MultiFormDataEncoding + private let fileManager: FileManager private let requestAdapters: [RequestAdapting] private let responseProcessors: [ResponseProcessing] private let errorProcessors: [ErrorProcessing] @@ -40,11 +42,15 @@ open class UploadAPIManager: NSObject { // MARK: - Initialization public init( urlSessionConfiguration: URLSessionConfiguration = .default, + multiFormDataEncoder: MultiFormDataEncoding = MultiFormDataEncoder(), + fileManager: FileManager = .default, requestAdapters: [RequestAdapting] = [], responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], errorProcessors: [ErrorProcessing] = [] ) { self.urlSessionConfiguration = urlSessionConfiguration + self.multiFormDataEncoder = multiFormDataEncoder + self.fileManager = fileManager self.requestAdapters = requestAdapters self.responseProcessors = responseProcessors self.errorProcessors = errorProcessors @@ -106,6 +112,32 @@ extension UploadAPIManager: UploadAPIManaging { ) } + public func upload( + multiFormData: MultiFormData, + sizeThreshold: UInt64 = 10_000_000, + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? + ) async throws -> UploadTask { + let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + + if multiFormData.size < sizeThreshold { + let encodedMultiFormData = try multiFormDataEncoder.encode(multiFormData) + return try await uploadRequest( + .data(encodedMultiFormData), + request: endpointRequest, + retryConfiguration: retryConfiguration + ) + } else { + let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) + try multiFormDataEncoder.encode(multiFormData, to: temporaryFileUrl) + return try await uploadRequest( + .file(temporaryFileUrl), + request: endpointRequest, + retryConfiguration: retryConfiguration + ) + } + } + public func retry( taskId: String, retryConfiguration: RetryConfiguration? @@ -283,4 +315,13 @@ private extension UploadAPIManager { .values .first { $0.taskIdentifier == task.taskIdentifier } } + + func temporaryFileUrl(for request: EndpointRequest) throws -> URL { + let temporaryFileUrl = fileManager + .temporaryDirectory + .appendingPathComponent("ios-networking") + .appendingPathComponent(request.id) + try fileManager.createDirectory(at: temporaryFileUrl, withIntermediateDirectories: true) + return temporaryFileUrl + } } diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 75919119..639a7805 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -38,6 +38,24 @@ public protocol UploadAPIManaging { retryConfiguration: RetryConfiguration? ) async throws -> UploadTask + /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. + /// + /// If the size of the `MultiFormData` exceeds the given `sizeThreshold`, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. + /// + /// - Parameters: + /// - multiFormData: The multipart form data to upload. + /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. Defaults to 10MB. + /// - endpoint: The API endpoint to where data will be sent. + /// - retryConfiguration: An optional configuration for retry behavior. + /// + /// - Returns: An `UploadTask` that represents this request. + func upload( + multiFormData: MultiFormData, + sizeThreshold: UInt64, + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? + ) async throws -> UploadTask + /// Retries the upload task with the specified identifier. /// - Parameters: /// - taskId: The upload task's identifier to retry. @@ -58,3 +76,19 @@ public protocol UploadAPIManaging { /// - Parameter shouldFinishTasks: Determines whether all outstanding tasks should finish before invalidating the session or be immediately cancelled. func invalidateSession(shouldFinishTasks: Bool) } + +extension UploadAPIManaging { + func upload( + multiFormData: MultiFormData, + sizeThreshold: UInt64 = 10_000_000, + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? + ) async throws -> UploadTask { + try await upload( + multiFormData: multiFormData, + sizeThreshold: sizeThreshold, + to: endpoint, + retryConfiguration: retryConfiguration + ) + } +} From e1919dbf7a74a632bb800c91d2c04fff96780807 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 21:49:24 +0800 Subject: [PATCH 51/79] feat: remove upload task file on complete --- Sources/Networking/Core/Upload/UploadAPIManager.swift | 3 ++- Sources/Networking/Core/Upload/UploadTask.swift | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index d1f66f62..9b888e41 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -218,7 +218,8 @@ private extension UploadAPIManager { return UploadTask( sessionUploadTask: sessionUploadTask, endpointRequest: request, - uploadable: uploadable + uploadable: uploadable, + fileManager: fileManager ) } existingUploadTask.task = sessionUploadTask diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 31b01a68..1156cdc4 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -26,6 +26,9 @@ public struct UploadTask { /// The counter that counts number of retries for this task. let retryCounter: Counter + + /// The file manager associated with the task. + let fileManager: FileManager } public extension UploadTask { @@ -79,6 +82,10 @@ extension UploadTask { // cancelling the whole stream before the client can process the emitted value. try await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) + + if case let .file(url) = uploadable { + try? fileManager.removeItem(at: url) + } } } @@ -86,13 +93,15 @@ extension UploadTask { init( sessionUploadTask: URLSessionUploadTask, endpointRequest: EndpointRequest, - uploadable: Uploadable + uploadable: Uploadable, + fileManager: FileManager ) { self.task = sessionUploadTask self.endpointRequest = endpointRequest self.uploadable = uploadable self.statePublisher = .init(State(task: sessionUploadTask)) self.retryCounter = Counter() + self.fileManager = fileManager } } From 54ef2c2cbd253d2845241cabde8b6a9826d6e8d8 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 10:16:32 +0800 Subject: [PATCH 52/79] docs: add additional documentation --- .../Core/Upload/MultiFormData+BodyPart.swift | 11 ++++++++ .../Core/Upload/MultiFormData.swift | 28 +++++++++++++++++-- .../Core/Upload/MultiFormDataEncoder.swift | 9 ++++++ .../Core/Upload/MultiFormDataEncoding.swift | 9 ++++++ .../Core/Upload/UploadAPIManager.swift | 2 ++ .../Core/Upload/UploadAPIManaging.swift | 18 ++++++++++-- 6 files changed, 72 insertions(+), 5 deletions(-) diff --git a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift index 3757588d..878eb997 100644 --- a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift @@ -8,16 +8,27 @@ import Foundation public extension MultiFormData { + /// Represents an individual part of the `multipart/form-data`. struct BodyPart { + /// The input stream containing the data of the part's body. let dataStream: InputStream + + /// The name parameter of the `Content-Disposition` header field. let name: String + + /// The size of the part's body. let size: UInt64 + + /// An optional file parameter of the `Content-Disposition` header field. This value may be provided if the body part represents a content of a file. let fileName: String? + + /// An optional value of the `Content-Type` header field. let mimeType: String? } } extension MultiFormData.BodyPart { + /// Returns the body part's header fields and values based on the properties of the instance. var contentHeaders: [HTTPHeader.HeaderField: String] { var disposition = "form-data; name=\"\(name)\"" diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index 4c86436d..91887917 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -7,15 +7,24 @@ import Foundation +/// The `MultiFormData` class provides a convenient way to handle multipart form data. +/// It allows you to construct a multipart form data payload by adding multiple body parts, each representing a separate piece of data. open class MultiFormData { + /// The total size of the `multipart/form-data`. + /// It is calculated as the sum of sizes of all the body parts added to the MultiFormData instance. public var size: UInt64 { bodyParts.reduce(0) { $0 + $1.size } } - private(set) var bodyParts: [BodyPart] = [] + /// Represents the boundary string used to separate the different parts of the multipart form data. + /// It is a unique string that acts as a delimiter between each body part. + public let boundary: String - let boundary: String + private(set) var bodyParts: [BodyPart] = [] + /// Initializes a new instance of `MultiFormData` with an optional boundary string. + /// - Parameter boundary: A custom boundary string to be used for separating the body parts in the multipart form data. + /// If not provided, a unique boundary string is generated using a combination of "--boundary-" and a UUID. public init(boundary: String? = nil) { self.boundary = boundary ?? "--boundary-\(UUID().uuidString)" } @@ -23,6 +32,13 @@ open class MultiFormData { // MARK: - Adding form data public extension MultiFormData { + /// Adds a body part to the multipart form data payload using the specified `data`. + /// + /// - Parameters: + /// - data: The data to be added to the payload. + /// - name: The name parameter of the `Content-Disposition` header field associated with this body part. + /// - fileName: An optional filename parameter of the `Content-Disposition` header field associated with this body part. + /// - mimeType: An optional MIME type of the body part. func append( _ data: Data, name: String, @@ -39,6 +55,14 @@ public extension MultiFormData { ) } + /// Adds a body part to the multipart form data payload using data from a file specified by its URL. + /// + /// - Parameters: + /// - fileUrl: The URL of the file containing the data for the body part. + /// - name: The name parameter of the `Content-Disposition` header field associated with this body part. + /// - size: The size of the body part data. + /// - fileName: An optional filename parameter of the `Content-Disposition` header field associated with this body part. If not provided, the last path component of the fileUrl is used as the filename (if any). + /// - mimeType: An optional MIME type of the body part. If not provided, the MIME type is inferred from the file extension of the file. func append( from fileUrl: URL, name: String, diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift index 056d85df..b8d0264e 100644 --- a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift @@ -8,11 +8,20 @@ import Foundation open class MultiFormDataEncoder { + /// A string representing a carriage return and line feed. private let crlf = "\r\n" + /// An instance of `FileManager` used to manage files. private let fileManager: FileManager + + /// A read/write stream buffer size in bytes. private let streamBufferSize: Int + /// Creates a `MultiFormDataEncoder` instance with the specified file manager and stream buffer size. + /// + /// - Parameters: + /// - fileManager: A `FileManager` used for files management. + /// - streamBufferSize: A read/write stream buffer size in bytes. Defaults to 1KB. public init( fileManager: FileManager = .default, streamBufferSize: Int = 1024 diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift index 27c045a2..dbae8735 100644 --- a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift +++ b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift @@ -8,6 +8,15 @@ import Foundation public protocol MultiFormDataEncoding { + /// Encodes the specified `MultiFormData` object into a `Data` object. + /// - Parameter multiFormData: The `MultiFormData` object to encode. + /// - Returns: A `Data` object containing the encoded `multiFormData`. func encode(_ multiFormData: MultiFormData) throws -> Data + + /// Encodes the specified `MultiFormData` object and writes it to the specified file URL. + /// + /// - Parameters: + /// - multiFormData: The `MultiFormData` object to encode. + /// - fileUrl: The file URL to write the encoded data to. func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws } diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 9b888e41..fc144add 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -120,6 +120,8 @@ extension UploadAPIManager: UploadAPIManaging { ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + // Encode in-memory and upload directly if the payload's size is less than the threshold, + // otherwise we write the payload to the disk first and upload by reading the file content. if multiFormData.size < sizeThreshold { let encodedMultiFormData = try multiFormDataEncoder.encode(multiFormData) return try await uploadRequest( diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 639a7805..f1555d89 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -42,9 +42,11 @@ public protocol UploadAPIManaging { /// /// If the size of the `MultiFormData` exceeds the given `sizeThreshold`, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. /// + /// When uploaded from disk, a temporary file is created on the file system. This file is deleted when the upload task completes or errors out after all retry attempts. + /// /// - Parameters: /// - multiFormData: The multipart form data to upload. - /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. Defaults to 10MB. + /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. /// - endpoint: The API endpoint to where data will be sent. /// - retryConfiguration: An optional configuration for retry behavior. /// @@ -78,15 +80,25 @@ public protocol UploadAPIManaging { } extension UploadAPIManaging { + /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. + /// + /// If the size of the `MultiFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. + /// To specify different data threshold, use ``upload(multiFormData:sizeThreshold:to:retryConfiguration:)``. + /// + /// - Parameters: + /// - multiFormData: The multipart form data to upload. + /// - endpoint: The API endpoint to where data will be sent. + /// - retryConfiguration: An optional configuration for retry behavior. + /// + /// - Returns: An `UploadTask` that represents this request. func upload( multiFormData: MultiFormData, - sizeThreshold: UInt64 = 10_000_000, to endpoint: Requestable, retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { try await upload( multiFormData: multiFormData, - sizeThreshold: sizeThreshold, + sizeThreshold: 10_000_000, to: endpoint, retryConfiguration: retryConfiguration ) From 2f85689281447b9d6f06e8e8e50b7367d1203e8b Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 11:16:05 +0800 Subject: [PATCH 53/79] feat: add multipart form upload example --- .../project.pbxproj | 4 + .../API/Routers/SampleUploadRouter.swift | 3 + .../NetworkingSampleApp/ContentView.swift | 15 +- .../Scenes/Upload/FormUploadsViewModel.swift | 59 ++++++++ .../Scenes/Upload/UploadService.swift | 14 ++ .../Scenes/Upload/UploadsView.swift | 133 ++++++++++++++---- .../Core/Upload/UploadAPIManaging.swift | 2 +- 7 files changed, 194 insertions(+), 36 deletions(-) create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 5eb8bc70..067eb2fb 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */; }; B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C42A37102D006D3B9C /* UploadsView.swift */; }; B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C62A371046006D3B9C /* UploadItemView.swift */; }; + B5A2CE6C2A3FF42400467EB3 /* FormUploadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */; }; 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 */; }; @@ -74,6 +75,7 @@ B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemViewModel.swift; sourceTree = ""; }; B52674C42A37102D006D3B9C /* UploadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsView.swift; sourceTree = ""; }; B52674C62A371046006D3B9C /* UploadItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemView.swift; sourceTree = ""; }; + B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormUploadsViewModel.swift; 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 = ""; }; @@ -240,6 +242,7 @@ B52674BB2A370D0D006D3B9C /* Upload */ = { isa = PBXGroup; children = ( + B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */, B52674BE2A370D33006D3B9C /* UploadItem.swift */, B52674C62A371046006D3B9C /* UploadItemView.swift */, B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */, @@ -337,6 +340,7 @@ DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */, DDE8884529476AC300DD3BFF /* SampleRefreshTokenRequest.swift in Sources */, DDD3AD212951F527006CB777 /* SampleAuthorizationManager.swift in Sources */, + B5A2CE6C2A3FF42400467EB3 /* FormUploadsViewModel.swift in Sources */, 23EA9CF7292FB70A00B8E418 /* SampleUserAuthResponse.swift in Sources */, 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */, 23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */, diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift index cf907254..e8314fc3 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift @@ -12,6 +12,7 @@ import UniformTypeIdentifiers enum SampleUploadRouter: Requestable { case image case file(URL) + case multipart(boundary: String) var baseURL: URL { fatalError("Provide your API base URL for upload") @@ -23,6 +24,8 @@ enum SampleUploadRouter: Requestable { return ["Content-Type": "image/png"] case let .file(url): return ["Content-Type": url.mimeType] + case let .multipart(boundary): + return ["Content-Type": "multipart/form-data; boundary=\(boundary)"] } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift index 6d7d037f..75d1bec4 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift @@ -30,11 +30,18 @@ struct ContentView: View { case .downloads: DownloadsView() case .uploads: - UploadsView(viewModel: UploadsViewModel( - uploadService: UploadService( - uploadManager: UploadAPIManager() + UploadsView( + viewModel: UploadsViewModel( + uploadService: UploadService( + uploadManager: UploadAPIManager() + ) + ), + formViewModel: FormUploadsViewModel( + uploadService: UploadService( + uploadManager: UploadAPIManager() + ) ) - )) + ) } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift new file mode 100644 index 00000000..e13a990c --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -0,0 +1,59 @@ +// +// FormUploadsViewModel.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 19.06.2023. +// + +import Foundation + +@MainActor +final class FormUploadsViewModel: ObservableObject { + @Published var text = "" + @Published var fileUrl: URL? + @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] + + var selectedFileName: String { + let resources = try? fileUrl?.resourceValues(forKeys:[.fileSizeKey]) + let fileSize = (resources?.fileSize ?? 0) / 1_000_000 + var fileName = fileUrl?.lastPathComponent ?? "" + if fileSize > 0 { fileName += "\n\(fileSize) MB" } + return fileName + } + + private let uploadService: UploadService + + init(uploadService: UploadService) { + self.uploadService = uploadService + } +} + +extension FormUploadsViewModel { + func uploadForm() { + Task { + do { + let uploadItem = try await uploadService.uploadFormData { form in + form.append(Data(self.text.utf8), name: "textfield") + + if + let fileUrl = self.fileUrl, + let resources = try? fileUrl.resourceValues(forKeys:[.fileSizeKey]), + let fileSize = resources.fileSize + { + try form.append(from: fileUrl, name: "attachment", size: UInt64(fileSize)) + } + } + + uploadItemViewModels.append(UploadItemViewModel( + item: uploadItem, + uploadService: uploadService + )) + + text = "" + fileUrl = nil + } catch { + print("Failed to upload with error:", error) + } + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 48b0a94e..6ba72196 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -45,6 +45,20 @@ extension UploadService { ) } + func uploadFormData(_ build: @escaping (MultiFormData) throws -> Void) async throws -> UploadItem { + let multiFormData = MultiFormData() + try build(multiFormData) + let task = try await uploadManager.upload( + multiFormData: multiFormData, + to: SampleUploadRouter.multipart(boundary: multiFormData.boundary), + retryConfiguration: .default + ) + return UploadItem( + id: task.id, + fileName: "Form upload of size \(multiFormData.size)" + ) + } + func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream { await uploadManager.stateStream(for: uploadTaskId) } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index 4492373a..c6cc8f78 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -10,48 +10,34 @@ import PhotosUI struct UploadsView: View { @ObservedObject var viewModel: UploadsViewModel + @ObservedObject var formViewModel: FormUploadsViewModel @State var isPhotosPickerPresented = false @State var isFileImporterPresented = false + @State var isFormFileImporterPresented = false @State var selectedPhotoPickerItem: PhotosPickerItem? var body: some View { - List { - Section("Upload") { - Button("Photo") { isPhotosPickerPresented = true } - .photosPicker( - isPresented: $isPhotosPickerPresented, - selection: $selectedPhotoPickerItem, - matching: .images - ) - .onChange(of: selectedPhotoPickerItem) { photo in - Task { - if let data = try? await photo?.loadTransferable(type: Data.self) { - await viewModel.uploadImage( - data, - fileName: selectedPhotoPickerItem?.supportedContentTypes.first?.preferredFilenameExtension - ) - } - } - } + Form { + singleUpload - Button("File") { isFileImporterPresented = true } - .fileImporter( - isPresented: $isFileImporterPresented, - allowedContentTypes: [.mp3, .mpeg4Movie] - ) { result in - Task { - if let fileUrl = try? result.get() { - await viewModel.uploadFile(at: fileUrl) - } + if !viewModel.uploadItemViewModels.isEmpty { + Section("Single upload progress") { + VStack { + ForEach(viewModel.uploadItemViewModels.indices, id: \.self) { index in + let viewModel = viewModel.uploadItemViewModels[index] + UploadItemView(viewModel: viewModel) } } + } } - if !viewModel.uploadItemViewModels.isEmpty { - Section("Upload progress") { + multipartUpload + + if !formViewModel.uploadItemViewModels.isEmpty { + Section("Multi part upload progress") { VStack { - ForEach(viewModel.uploadItemViewModels.indices, id: \.self) { index in - let viewModel = viewModel.uploadItemViewModels[index] + ForEach(formViewModel.uploadItemViewModels.indices, id: \.self) { index in + let viewModel = formViewModel.uploadItemViewModels[index] UploadItemView(viewModel: viewModel) } } @@ -61,3 +47,88 @@ struct UploadsView: View { .navigationTitle("Uploads") } } + +private extension UploadsView { + var singleUpload: some View { + Section("Single") { + Button("Photo") { isPhotosPickerPresented = true } + .photosPicker( + isPresented: $isPhotosPickerPresented, + selection: $selectedPhotoPickerItem, + matching: .images + ) + .onChange(of: selectedPhotoPickerItem) { photo in + Task { + if let data = try? await photo?.loadTransferable(type: Data.self) { + await viewModel.uploadImage( + data, + fileName: selectedPhotoPickerItem?.supportedContentTypes.first?.preferredFilenameExtension + ) + } + } + } + + Button("File") { isFileImporterPresented = true } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [.mp3, .mpeg4Movie] + ) { result in + Task { + if let fileUrl = try? result.get() { + await viewModel.uploadFile(at: fileUrl) + } + } + } + } + } + + var multipartUpload: some View { + Section( + content: { + TextField("Enter text", text: $formViewModel.text) + + HStack { + if formViewModel.fileUrl == nil { + Button("Add attachment") { isFormFileImporterPresented = true } + .fileImporter( + isPresented: $isFormFileImporterPresented, + allowedContentTypes: [.mp3, .mpeg4Movie] + ) { result in + formViewModel.fileUrl = try? result.get() + } + } + + + Text(formViewModel.selectedFileName) + + Spacer() + + if formViewModel.fileUrl != nil { + Button( + action: { formViewModel.fileUrl = nil }, + label: { + Image(systemName: "x") + .symbolVariant(.circle.fill) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.tertiary) + } + ) + .buttonStyle(.plain) + .contentShape(Circle()) + } + } + }, + header: { + Text("Multipart") + }, + footer: { + Button("Upload") { + formViewModel.uploadForm() + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + ) + } +} diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index f1555d89..2315d3e7 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -79,7 +79,7 @@ public protocol UploadAPIManaging { func invalidateSession(shouldFinishTasks: Bool) } -extension UploadAPIManaging { +public extension UploadAPIManaging { /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. /// /// If the size of the `MultiFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. From 83205be38fe17113f0e37d2c292aa4790c835ad2 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 11:46:25 +0800 Subject: [PATCH 54/79] fix: remove file uploadable conditionally --- Sources/Networking/Core/Upload/UploadAPIManager.swift | 10 ++++++---- Sources/Networking/Core/Upload/UploadTask.swift | 2 +- Sources/Networking/Core/Upload/Uploadable.swift | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index fc144add..d28394c5 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -133,7 +133,7 @@ extension UploadAPIManager: UploadAPIManaging { let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) try multiFormDataEncoder.encode(multiFormData, to: temporaryFileUrl) return try await uploadRequest( - .file(temporaryFileUrl), + .file(temporaryFileUrl, removeOnComplete: true), request: endpointRequest, retryConfiguration: retryConfiguration ) @@ -297,7 +297,7 @@ private extension UploadAPIManager { from: data, completionHandler: completionHandler ) - case let .file(fileUrl): + case let .file(fileUrl, _): return urlSession.uploadTask( with: request, fromFile: fileUrl, @@ -320,11 +320,13 @@ private extension UploadAPIManager { } func temporaryFileUrl(for request: EndpointRequest) throws -> URL { - let temporaryFileUrl = fileManager + let temporaryDirectoryUrl = fileManager .temporaryDirectory .appendingPathComponent("ios-networking") + + let temporaryFileUrl = temporaryDirectoryUrl .appendingPathComponent(request.id) - try fileManager.createDirectory(at: temporaryFileUrl, withIntermediateDirectories: true) + try fileManager.createDirectory(at: temporaryDirectoryUrl, withIntermediateDirectories: true) return temporaryFileUrl } } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 1156cdc4..88eaaa4c 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -83,7 +83,7 @@ extension UploadTask { try await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) - if case let .file(url) = uploadable { + if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { try? fileManager.removeItem(at: url) } } diff --git a/Sources/Networking/Core/Upload/Uploadable.swift b/Sources/Networking/Core/Upload/Uploadable.swift index bec9dc84..290db57e 100644 --- a/Sources/Networking/Core/Upload/Uploadable.swift +++ b/Sources/Networking/Core/Upload/Uploadable.swift @@ -10,5 +10,5 @@ import Foundation /// Represents a data type that can be uploaded. enum Uploadable { case data(Data) - case file(URL) + case file(URL, removeOnComplete: Bool = false) } From f3dc126aa931089c3c0cabc6724ce0af58889256 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 12:16:47 +0800 Subject: [PATCH 55/79] fix: cleanup task only on request completion --- Sources/Networking/Core/Upload/UploadAPIManager.swift | 2 +- Sources/Networking/Core/Upload/UploadTask.swift | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index d28394c5..8e2350c2 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -252,7 +252,7 @@ private extension UploadAPIManager { try await uploadTask.complete(with: state) // Cleanup on successful task completion - await uploadTask.resetRetryCounter() + await uploadTask.cleanup() await uploadTasks.set(value: nil, for: endpointRequest.id) } else if let error { do { diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 88eaaa4c..5e9cb229 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -83,6 +83,11 @@ extension UploadTask { try await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) + } + + func cleanup() async { + await resetRetryCounter() + if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { try? fileManager.removeItem(at: url) } From 1f60d97349853faa2d683c5f5f723513fb705d5c Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 14:15:59 +0800 Subject: [PATCH 56/79] refactor: calculate file size from given url --- .../Scenes/Upload/FormUploadsViewModel.swift | 8 ++------ Sources/Networking/Core/Upload/MultiFormData.swift | 10 ++++++---- Sources/Networking/Utils/URL+Convenience.swift | 7 +++++++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index e13a990c..b0d4096d 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -35,12 +35,8 @@ extension FormUploadsViewModel { let uploadItem = try await uploadService.uploadFormData { form in form.append(Data(self.text.utf8), name: "textfield") - if - let fileUrl = self.fileUrl, - let resources = try? fileUrl.resourceValues(forKeys:[.fileSizeKey]), - let fileSize = resources.fileSize - { - try form.append(from: fileUrl, name: "attachment", size: UInt64(fileSize)) + if let fileUrl = self.fileUrl { + try form.append(from: fileUrl, name: "attachment") } } diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index 91887917..3fd5ee9e 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -60,13 +60,11 @@ public extension MultiFormData { /// - Parameters: /// - fileUrl: The URL of the file containing the data for the body part. /// - name: The name parameter of the `Content-Disposition` header field associated with this body part. - /// - size: The size of the body part data. /// - fileName: An optional filename parameter of the `Content-Disposition` header field associated with this body part. If not provided, the last path component of the fileUrl is used as the filename (if any). /// - mimeType: An optional MIME type of the body part. If not provided, the MIME type is inferred from the file extension of the file. func append( from fileUrl: URL, name: String, - size: UInt64, fileName: String? = nil, mimeType: String? = nil ) throws { @@ -83,10 +81,14 @@ public extension MultiFormData { throw EncodingError.invalidFileUrl(fileUrl) } + guard let fileSize = fileUrl.fileSize else { + throw EncodingError.missingFileSize(for: fileUrl) + } + append( dataStream: dataStream, name: name, - size: size, + size: UInt64(fileSize), fileName: fileName, mimeType: mimeType ?? fileUrl.mimeType ) @@ -117,9 +119,9 @@ extension MultiFormData { public enum EncodingError: LocalizedError { case invalidFileUrl(URL) case invalidFileName(at: URL) + case missingFileSize(for: URL) case dataStreamReadFailed(with: Error) case dataStreamWriteFailed(at: URL) case fileAlreadyExists(at: URL) - } } diff --git a/Sources/Networking/Utils/URL+Convenience.swift b/Sources/Networking/Utils/URL+Convenience.swift index a2b8e592..21dc5d0c 100644 --- a/Sources/Networking/Utils/URL+Convenience.swift +++ b/Sources/Networking/Utils/URL+Convenience.swift @@ -16,4 +16,11 @@ extension URL { var isDirectory: Bool { (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } + + var fileSize: Int? { + guard let resources = try? resourceValues(forKeys:[.fileSizeKey]) else { + return nil + } + return resources.fileSize + } } From 645166ca9b7eeae1cb36dc241464a5070e9b096c Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 14:25:26 +0800 Subject: [PATCH 57/79] refactor: rename MultiFormData to MultipartFormData --- .../Scenes/Upload/UploadService.swift | 12 +++--- .../Core/Upload/MultiFormDataEncoding.swift | 22 ---------- ...swift => MultipartFormData+BodyPart.swift} | 6 +-- ...FormData.swift => MultipartFormData.swift} | 16 +++---- ...r.swift => MultipartFormDataEncoder.swift} | 42 +++++++++---------- .../Upload/MultipartFormDataEncoding.swift | 22 ++++++++++ .../Core/Upload/UploadAPIManager.swift | 16 +++---- .../Core/Upload/UploadAPIManaging.swift | 16 +++---- ...ft => MultipartFormDataEncoderTests.swift} | 28 ++++++------- 9 files changed, 90 insertions(+), 90 deletions(-) delete mode 100644 Sources/Networking/Core/Upload/MultiFormDataEncoding.swift rename Sources/Networking/Core/Upload/{MultiFormData+BodyPart.swift => MultipartFormData+BodyPart.swift} (91%) rename Sources/Networking/Core/Upload/{MultiFormData.swift => MultipartFormData.swift} (91%) rename Sources/Networking/Core/Upload/{MultiFormDataEncoder.swift => MultipartFormDataEncoder.swift} (73%) create mode 100644 Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift rename Tests/NetworkingTests/{MultiFormDataEncoderTests.swift => MultipartFormDataEncoderTests.swift} (75%) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 6ba72196..767ce20f 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -45,17 +45,17 @@ extension UploadService { ) } - func uploadFormData(_ build: @escaping (MultiFormData) throws -> Void) async throws -> UploadItem { - let multiFormData = MultiFormData() - try build(multiFormData) + func uploadFormData(_ build: @escaping (MultipartFormData) throws -> Void) async throws -> UploadItem { + let multipartFormData = MultipartFormData() + try build(multipartFormData) let task = try await uploadManager.upload( - multiFormData: multiFormData, - to: SampleUploadRouter.multipart(boundary: multiFormData.boundary), + multipartFormData: multipartFormData, + to: SampleUploadRouter.multipart(boundary: multipartFormData.boundary), retryConfiguration: .default ) return UploadItem( id: task.id, - fileName: "Form upload of size \(multiFormData.size)" + fileName: "Form upload of size \(multipartFormData.size)" ) } diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift deleted file mode 100644 index dbae8735..00000000 --- a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// MultiFormDataEncoding.swift -// -// -// Created by Tony Ngo on 18.06.2023. -// - -import Foundation - -public protocol MultiFormDataEncoding { - /// Encodes the specified `MultiFormData` object into a `Data` object. - /// - Parameter multiFormData: The `MultiFormData` object to encode. - /// - Returns: A `Data` object containing the encoded `multiFormData`. - func encode(_ multiFormData: MultiFormData) throws -> Data - - /// Encodes the specified `MultiFormData` object and writes it to the specified file URL. - /// - /// - Parameters: - /// - multiFormData: The `MultiFormData` object to encode. - /// - fileUrl: The file URL to write the encoded data to. - func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws -} diff --git a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift similarity index 91% rename from Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift rename to Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift index 878eb997..6208b4e3 100644 --- a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift @@ -1,5 +1,5 @@ // -// MultiFormData+BodyPart.swift +// MultipartFormData+BodyPart.swift // // // Created by Tony Ngo on 18.06.2023. @@ -7,7 +7,7 @@ import Foundation -public extension MultiFormData { +public extension MultipartFormData { /// Represents an individual part of the `multipart/form-data`. struct BodyPart { /// The input stream containing the data of the part's body. @@ -27,7 +27,7 @@ public extension MultiFormData { } } -extension MultiFormData.BodyPart { +extension MultipartFormData.BodyPart { /// Returns the body part's header fields and values based on the properties of the instance. var contentHeaders: [HTTPHeader.HeaderField: String] { var disposition = "form-data; name=\"\(name)\"" diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultipartFormData.swift similarity index 91% rename from Sources/Networking/Core/Upload/MultiFormData.swift rename to Sources/Networking/Core/Upload/MultipartFormData.swift index 3fd5ee9e..b72a11e1 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData.swift @@ -1,5 +1,5 @@ // -// MultiFormData.swift +// MultipartFormData.swift // // // Created by Tony Ngo on 18.06.2023. @@ -7,11 +7,11 @@ import Foundation -/// The `MultiFormData` class provides a convenient way to handle multipart form data. +/// The `MultipartFormData` class provides a convenient way to handle multipart form data. /// It allows you to construct a multipart form data payload by adding multiple body parts, each representing a separate piece of data. -open class MultiFormData { +open class MultipartFormData { /// The total size of the `multipart/form-data`. - /// It is calculated as the sum of sizes of all the body parts added to the MultiFormData instance. + /// It is calculated as the sum of sizes of all the body parts added to the `MultipartFormData` instance. public var size: UInt64 { bodyParts.reduce(0) { $0 + $1.size } } @@ -22,7 +22,7 @@ open class MultiFormData { private(set) var bodyParts: [BodyPart] = [] - /// Initializes a new instance of `MultiFormData` with an optional boundary string. + /// Initializes a new instance of `MultipartFormData` with an optional boundary string. /// - Parameter boundary: A custom boundary string to be used for separating the body parts in the multipart form data. /// If not provided, a unique boundary string is generated using a combination of "--boundary-" and a UUID. public init(boundary: String? = nil) { @@ -31,7 +31,7 @@ open class MultiFormData { } // MARK: - Adding form data -public extension MultiFormData { +public extension MultipartFormData { /// Adds a body part to the multipart form data payload using the specified `data`. /// /// - Parameters: @@ -96,7 +96,7 @@ public extension MultiFormData { } // MARK: - Private -private extension MultiFormData { +private extension MultipartFormData { func append( dataStream: InputStream, name: String, @@ -115,7 +115,7 @@ private extension MultiFormData { } // MARK: - Errors -extension MultiFormData { +extension MultipartFormData { public enum EncodingError: LocalizedError { case invalidFileUrl(URL) case invalidFileName(at: URL) diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift similarity index 73% rename from Sources/Networking/Core/Upload/MultiFormDataEncoder.swift rename to Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift index b8d0264e..1d613e8e 100644 --- a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift @@ -1,5 +1,5 @@ // -// MultiFormDataEncoder.swift +// MultipartFormDataEncoder.swift // // // Created by Tony Ngo on 18.06.2023. @@ -7,7 +7,7 @@ import Foundation -open class MultiFormDataEncoder { +open class MultipartFormDataEncoder { /// A string representing a carriage return and line feed. private let crlf = "\r\n" @@ -17,7 +17,7 @@ open class MultiFormDataEncoder { /// A read/write stream buffer size in bytes. private let streamBufferSize: Int - /// Creates a `MultiFormDataEncoder` instance with the specified file manager and stream buffer size. + /// Creates a `MultipartFormDataEncoder` instance with the specified file manager and stream buffer size. /// /// - Parameters: /// - fileManager: A `FileManager` used for files management. @@ -31,13 +31,13 @@ open class MultiFormDataEncoder { } } -// MARK: - MultiFormDataEncoding -extension MultiFormDataEncoder: MultiFormDataEncoding { - public func encode(_ multiFormData: MultiFormData) throws -> Data { +// MARK: - MultipartFormDataEncoding +extension MultipartFormDataEncoder: MultipartFormDataEncoding { + public func encode(_ multipartFormData: MultipartFormData) throws -> Data { var encoded = Data() - for bodyPart in multiFormData.bodyParts { - encoded.append("\(multiFormData.boundary)\(crlf)") + for bodyPart in multipartFormData.bodyParts { + encoded.append("\(multipartFormData.boundary)\(crlf)") let encodedHeaders = encode(bodyPart.contentHeaders) encoded.append(encodedHeaders) @@ -48,34 +48,34 @@ extension MultiFormDataEncoder: MultiFormDataEncoding { encoded.append("\(crlf)") } - encoded.append("\(multiFormData.boundary)--\(crlf)") + encoded.append("\(multipartFormData.boundary)--\(crlf)") return encoded } - public func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws { + public func encode(_ multipartFormData: MultipartFormData, to fileUrl: URL) throws { guard fileUrl.isFileURL else { - throw MultiFormData.EncodingError.invalidFileUrl(fileUrl) + throw MultipartFormData.EncodingError.invalidFileUrl(fileUrl) } guard !fileManager.fileExists(at: fileUrl) else { - throw MultiFormData.EncodingError.fileAlreadyExists(at: fileUrl) + throw MultipartFormData.EncodingError.fileAlreadyExists(at: fileUrl) } guard let outputStream = OutputStream(url: fileUrl, append: false) else { - throw MultiFormData.EncodingError.dataStreamWriteFailed(at: fileUrl) + throw MultipartFormData.EncodingError.dataStreamWriteFailed(at: fileUrl) } - try encode(multiFormData, into: outputStream) + try encode(multipartFormData, into: outputStream) } } -private extension MultiFormDataEncoder { - func encode(_ multiFormData: MultiFormData, into outputStream: OutputStream) throws { +private extension MultipartFormDataEncoder { + func encode(_ multipartFormData: MultipartFormData, into outputStream: OutputStream) throws { outputStream.open() defer { outputStream.close() } - for bodyPart in multiFormData.bodyParts { - let encodedBoundary = "\(multiFormData.boundary)\(crlf)".data + for bodyPart in multipartFormData.bodyParts { + let encodedBoundary = "\(multipartFormData.boundary)\(crlf)".data try write(encodedBoundary, into: outputStream) var encodedHeaders = encode(bodyPart.contentHeaders) @@ -86,7 +86,7 @@ private extension MultiFormDataEncoder { try write("\(crlf)".data, into: outputStream) } - try write("\(multiFormData.boundary)--\(crlf)".data, into: outputStream) + try write("\(multipartFormData.boundary)--\(crlf)".data, into: outputStream) } func write(_ inputStream: InputStream, into outputStream: OutputStream) throws { @@ -101,7 +101,7 @@ private extension MultiFormDataEncoder { let bytesRead = inputStream.read(buffer, maxLength: streamBufferSize) if bytesRead == -1, let error = inputStream.streamError { - throw MultiFormData.EncodingError.dataStreamReadFailed(with: error) + throw MultipartFormData.EncodingError.dataStreamReadFailed(with: error) } if bytesRead > 0 { @@ -129,7 +129,7 @@ private extension MultiFormDataEncoder { let bytesRead = dataStream.read(buffer, maxLength: streamBufferSize) if bytesRead == -1, let error = dataStream.streamError { - throw MultiFormData.EncodingError.dataStreamReadFailed(with: error) + throw MultipartFormData.EncodingError.dataStreamReadFailed(with: error) } if bytesRead > 0 { diff --git a/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift new file mode 100644 index 00000000..79e510c9 --- /dev/null +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift @@ -0,0 +1,22 @@ +// +// MultipartFormDataEncoding.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +public protocol MultipartFormDataEncoding { + /// Encodes the specified `MultipartFormData` object into a `Data` object. + /// - Parameter multipartFormData: The `MultipartFormData` object to encode. + /// - Returns: A `Data` object containing the encoded `multipartFormData`. + func encode(_ multipartFormData: MultipartFormData) throws -> Data + + /// Encodes the specified `MultipartFormData` object and writes it to the specified file URL. + /// + /// - Parameters: + /// - multipartFormData: The `MultipartFormData` object to encode. + /// - fileUrl: The file URL to write the encoded data to. + func encode(_ multipartFormData: MultipartFormData, to fileUrl: URL) throws +} diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 8e2350c2..499b067b 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -31,7 +31,7 @@ open class UploadAPIManager: NSObject { delegateQueue: nil ) - private let multiFormDataEncoder: MultiFormDataEncoding + private let multipartFormDataEncoder: MultipartFormDataEncoding private let fileManager: FileManager private let requestAdapters: [RequestAdapting] private let responseProcessors: [ResponseProcessing] @@ -42,14 +42,14 @@ open class UploadAPIManager: NSObject { // MARK: - Initialization public init( urlSessionConfiguration: URLSessionConfiguration = .default, - multiFormDataEncoder: MultiFormDataEncoding = MultiFormDataEncoder(), + multipartFormDataEncoder: MultipartFormDataEncoding = MultipartFormDataEncoder(), fileManager: FileManager = .default, requestAdapters: [RequestAdapting] = [], responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], errorProcessors: [ErrorProcessing] = [] ) { self.urlSessionConfiguration = urlSessionConfiguration - self.multiFormDataEncoder = multiFormDataEncoder + self.multipartFormDataEncoder = multipartFormDataEncoder self.fileManager = fileManager self.requestAdapters = requestAdapters self.responseProcessors = responseProcessors @@ -113,7 +113,7 @@ extension UploadAPIManager: UploadAPIManaging { } public func upload( - multiFormData: MultiFormData, + multipartFormData: MultipartFormData, sizeThreshold: UInt64 = 10_000_000, to endpoint: Requestable, retryConfiguration: RetryConfiguration? @@ -122,16 +122,16 @@ extension UploadAPIManager: UploadAPIManaging { // Encode in-memory and upload directly if the payload's size is less than the threshold, // otherwise we write the payload to the disk first and upload by reading the file content. - if multiFormData.size < sizeThreshold { - let encodedMultiFormData = try multiFormDataEncoder.encode(multiFormData) + if multipartFormData.size < sizeThreshold { + let encodedMultipartFormData = try multipartFormDataEncoder.encode(multipartFormData) return try await uploadRequest( - .data(encodedMultiFormData), + .data(encodedMultipartFormData), request: endpointRequest, retryConfiguration: retryConfiguration ) } else { let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) - try multiFormDataEncoder.encode(multiFormData, to: temporaryFileUrl) + try multipartFormDataEncoder.encode(multipartFormData, to: temporaryFileUrl) return try await uploadRequest( .file(temporaryFileUrl, removeOnComplete: true), request: endpointRequest, diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 2315d3e7..bdc40b3f 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -40,19 +40,19 @@ public protocol UploadAPIManaging { /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. /// - /// If the size of the `MultiFormData` exceeds the given `sizeThreshold`, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. + /// If the size of the `MultipartFormData` exceeds the given `sizeThreshold`, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. /// /// When uploaded from disk, a temporary file is created on the file system. This file is deleted when the upload task completes or errors out after all retry attempts. /// /// - Parameters: - /// - multiFormData: The multipart form data to upload. + /// - multipartFormData: The multipart form data to upload. /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. /// - endpoint: The API endpoint to where data will be sent. /// - retryConfiguration: An optional configuration for retry behavior. /// /// - Returns: An `UploadTask` that represents this request. func upload( - multiFormData: MultiFormData, + multipartFormData: MultipartFormData, sizeThreshold: UInt64, to endpoint: Requestable, retryConfiguration: RetryConfiguration? @@ -82,22 +82,22 @@ public protocol UploadAPIManaging { public extension UploadAPIManaging { /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. /// - /// If the size of the `MultiFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. - /// To specify different data threshold, use ``upload(multiFormData:sizeThreshold:to:retryConfiguration:)``. + /// If the size of the `MultipartFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. + /// To specify different data threshold, use ``upload(multipartFormData:sizeThreshold:to:retryConfiguration:)``. /// /// - Parameters: - /// - multiFormData: The multipart form data to upload. + /// - multipartFormData: The multipart form data to upload. /// - endpoint: The API endpoint to where data will be sent. /// - retryConfiguration: An optional configuration for retry behavior. /// /// - Returns: An `UploadTask` that represents this request. func upload( - multiFormData: MultiFormData, + multipartFormData: MultipartFormData, to endpoint: Requestable, retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { try await upload( - multiFormData: multiFormData, + multipartFormData: multipartFormData, sizeThreshold: 10_000_000, to: endpoint, retryConfiguration: retryConfiguration diff --git a/Tests/NetworkingTests/MultiFormDataEncoderTests.swift b/Tests/NetworkingTests/MultipartFormDataEncoderTests.swift similarity index 75% rename from Tests/NetworkingTests/MultiFormDataEncoderTests.swift rename to Tests/NetworkingTests/MultipartFormDataEncoderTests.swift index 2b9be5df..aca40bf7 100644 --- a/Tests/NetworkingTests/MultiFormDataEncoderTests.swift +++ b/Tests/NetworkingTests/MultipartFormDataEncoderTests.swift @@ -1,5 +1,5 @@ // -// MultiFormDataEncoderTests.swift +// MultipartFormDataEncoderTests.swift // // // Created by Tony Ngo on 18.06.2023. @@ -8,14 +8,14 @@ import Networking import XCTest -final class MultiFormDataEncoderTests: XCTestCase { +final class MultipartFormDataEncoderTests: XCTestCase { private let fileManager = FileManager.default private var temporaryDirectoryUrl: URL { URL( fileURLWithPath: NSTemporaryDirectory(), isDirectory: true - ).appendingPathComponent("multiformdata-encoder-tests") + ).appendingPathComponent("multipartformdata-encoder-tests") } override func setUpWithError() throws { @@ -33,7 +33,7 @@ final class MultiFormDataEncoderTests: XCTestCase { func test_encode_encodesDataAsExpected() throws { let sut = makeSUT() - let formData = MultiFormData(boundary: "--boundary--123") + let formData = MultipartFormData(boundary: "--boundary--123") let data1 = Data("Hello".utf8) formData.append(data1, name: "first-data") @@ -56,7 +56,7 @@ final class MultiFormDataEncoderTests: XCTestCase { func test_encode_encodesToFileAsExpected() throws { let sut = makeSUT() - let formData = MultiFormData(boundary: "--boundary--123") + let formData = MultipartFormData(boundary: "--boundary--123") let data = Data("Hello".utf8) formData.append(data, name: "first-data") @@ -76,36 +76,36 @@ final class MultiFormDataEncoderTests: XCTestCase { func test_encode_throwsInvalidFileUrl() { let sut = makeSUT() - let formData = MultiFormData() + let formData = MultipartFormData() let tmpFileUrl = URL(string: "invalid/path")! do { try sut.encode(formData, to: tmpFileUrl) XCTFail("Encoding should have failed.") - } catch MultiFormData.EncodingError.invalidFileUrl { + } catch MultipartFormData.EncodingError.invalidFileUrl { } catch { - XCTFail("Should have failed with MultiFormData.EncodingError.fileAlreadyExists") + XCTFail("Should have failed with MultipartFormData.EncodingError.fileAlreadyExists") } } func test_encode_throwsFileAlreadyExists() { let sut = makeSUT() - let formData = MultiFormData() + let formData = MultipartFormData() let tmpFileUrl = temporaryDirectoryUrl.appendingPathComponent("file") try? sut.encode(formData, to: tmpFileUrl) do { try sut.encode(formData, to: tmpFileUrl) XCTFail("Encoding should have failed.") - } catch MultiFormData.EncodingError.fileAlreadyExists { + } catch MultipartFormData.EncodingError.fileAlreadyExists { } catch { - XCTFail("Should have failed with MultiFormData.EncodingError.fileAlreadyExists") + XCTFail("Should have failed with MultipartFormData.EncodingError.fileAlreadyExists") } } } -private extension MultiFormDataEncoderTests { - func makeSUT(fileManager: FileManager = .default) -> MultiFormDataEncoder { - let sut = MultiFormDataEncoder(fileManager: fileManager) +private extension MultipartFormDataEncoderTests { + func makeSUT(fileManager: FileManager = .default) -> MultipartFormDataEncoder { + let sut = MultipartFormDataEncoder(fileManager: fileManager) return sut } } From 880581e87ab6ef5bb5151e9b1f6c8a3cb172fee9 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 14:31:35 +0800 Subject: [PATCH 58/79] chore: cleanup code --- .../MultipartFormData+EncodingError.swift | 19 +++++++++++++++++++ .../Core/Upload/MultipartFormData.swift | 12 ------------ .../Upload/MultipartFormDataEncoder.swift | 11 +++++++++-- .../Networking/Core/Upload/UploadTask.swift | 2 ++ 4 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 Sources/Networking/Core/Upload/MultipartFormData+EncodingError.swift diff --git a/Sources/Networking/Core/Upload/MultipartFormData+EncodingError.swift b/Sources/Networking/Core/Upload/MultipartFormData+EncodingError.swift new file mode 100644 index 00000000..d081c49c --- /dev/null +++ b/Sources/Networking/Core/Upload/MultipartFormData+EncodingError.swift @@ -0,0 +1,19 @@ +// +// MultipartFormData+EncodingError.swift +// +// +// Created by Tony Ngo on 19.06.2023. +// + +import Foundation + +public extension MultipartFormData { + enum EncodingError: LocalizedError { + case invalidFileUrl(URL) + case invalidFileName(at: URL) + case missingFileSize(for: URL) + case dataStreamReadFailed(with: Error) + case dataStreamWriteFailed(at: URL) + case fileAlreadyExists(at: URL) + } +} diff --git a/Sources/Networking/Core/Upload/MultipartFormData.swift b/Sources/Networking/Core/Upload/MultipartFormData.swift index b72a11e1..04e07f1b 100644 --- a/Sources/Networking/Core/Upload/MultipartFormData.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData.swift @@ -113,15 +113,3 @@ private extension MultipartFormData { )) } } - -// MARK: - Errors -extension MultipartFormData { - public enum EncodingError: LocalizedError { - case invalidFileUrl(URL) - case invalidFileName(at: URL) - case missingFileSize(for: URL) - case dataStreamReadFailed(with: Error) - case dataStreamWriteFailed(at: URL) - case fileAlreadyExists(at: URL) - } -} diff --git a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift index 1d613e8e..6337d9c5 100644 --- a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift @@ -69,8 +69,12 @@ extension MultipartFormDataEncoder: MultipartFormDataEncoding { } } +// MARK: - Private API private extension MultipartFormDataEncoder { - func encode(_ multipartFormData: MultipartFormData, into outputStream: OutputStream) throws { + func encode( + _ multipartFormData: MultipartFormData, + into outputStream: OutputStream + ) throws { outputStream.open() defer { outputStream.close() } @@ -89,7 +93,10 @@ private extension MultipartFormDataEncoder { try write("\(multipartFormData.boundary)--\(crlf)".data, into: outputStream) } - func write(_ inputStream: InputStream, into outputStream: OutputStream) throws { + func write( + _ inputStream: InputStream, + into outputStream: OutputStream + ) throws { let buffer = UnsafeMutablePointer.allocate(capacity: streamBufferSize) inputStream.open() defer { diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 5e9cb229..51436263 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -31,6 +31,7 @@ public struct UploadTask { let fileManager: FileManager } +// MARK: - Public API public extension UploadTask { /// Resumes the task. /// Has no effect if the task is not in the suspended state. @@ -60,6 +61,7 @@ public extension UploadTask { } } +// MARK: - Internal API extension UploadTask { /// The identifier of the underlying `URLSessionUploadTask`. var taskIdentifier: Int { From 44c29988b60f8e6b095f818a4cc5cc9cf34cdef2 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 19:27:53 +0800 Subject: [PATCH 59/79] refactor: simplify call site --- .../API/Routers/SampleUploadRouter.swift | 2 +- .../Scenes/Upload/UploadsView.swift | 15 ++---- .../Scenes/Upload/UploadsViewModel.swift | 47 ++++++++++++------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift index cf907254..396637c4 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift @@ -17,7 +17,7 @@ enum SampleUploadRouter: Requestable { fatalError("Provide your API base URL for upload") } - var headers: [String : String]? { + var headers: [String: String]? { switch self { case .image: return ["Content-Type": "image/png"] diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index 4492373a..0dd01475 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -24,13 +24,8 @@ struct UploadsView: View { matching: .images ) .onChange(of: selectedPhotoPickerItem) { photo in - Task { - if let data = try? await photo?.loadTransferable(type: Data.self) { - await viewModel.uploadImage( - data, - fileName: selectedPhotoPickerItem?.supportedContentTypes.first?.preferredFilenameExtension - ) - } + photo?.loadTransferable(type: Data.self) { result in + viewModel.uploadImage(result: result) } } @@ -39,11 +34,7 @@ struct UploadsView: View { isPresented: $isFileImporterPresented, allowedContentTypes: [.mp3, .mpeg4Movie] ) { result in - Task { - if let fileUrl = try? result.get() { - await viewModel.uploadFile(at: fileUrl) - } - } + viewModel.uploadFile(result: result) } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index e28b4001..0a9e6a2b 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -20,26 +20,39 @@ final class UploadsViewModel: ObservableObject { } extension UploadsViewModel { - func uploadImage(_ imageData: Data, fileName: String?) async { - do { - let uploadItem = try await uploadService.uploadImage( - imageData, - fileName: fileName ?? "" - ) - uploadItemViewModels.append(UploadItemViewModel(item: uploadItem, uploadService: uploadService)) - } catch { - print("Failed to upload with error", error) - self.error = error + func uploadImage(result: Result) { + Task { + do { + if let imageData = try result.get() { + let uploadItem = try await uploadService.uploadImage( + imageData, + fileName: "image.jpg" + ) + uploadItemViewModels.append(UploadItemViewModel( + item: uploadItem, + uploadService: uploadService + )) + } + } catch { + print("Failed to upload with error", error) + self.error = error + } } } - func uploadFile(at fileUrl: URL) async { - do { - let uploadItem = try await uploadService.uploadFile(fileUrl) - uploadItemViewModels.append(UploadItemViewModel(item: uploadItem, uploadService: uploadService)) - } catch { - print("Failed to upload with error", error) - self.error = error + func uploadFile(result: Result) { + Task { + do { + let fileUrl = try result.get() + let uploadItem = try await uploadService.uploadFile(fileUrl) + uploadItemViewModels.append(UploadItemViewModel( + item: uploadItem, + uploadService: uploadService + )) + } catch { + print("Failed to upload with error", error) + self.error = error + } } } } From 758639f9e0469dcca47e1caa3f3bc14539a7d05a Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 19:43:56 +0800 Subject: [PATCH 60/79] refactor: rename allTasks to activeTasks --- .../NetworkingSampleApp/Scenes/Upload/UploadService.swift | 2 +- Sources/Networking/Core/Upload/UploadAPIManager.swift | 2 +- Sources/Networking/Core/Upload/UploadAPIManaging.swift | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 48b0a94e..0a402d4e 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -71,6 +71,6 @@ extension UploadService { private extension UploadAPIManaging { func task(with id: String) async -> UploadTask? { - await allTasks.first { $0.id == id } + await activeTasks.first { $0.id == id } } } diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 47e2adb9..c7e7c9be 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -11,7 +11,7 @@ import Foundation /// Default upload API manager open class UploadAPIManager: NSObject { // MARK: - Public Properties - public var allTasks: [UploadTask] { + public var activeTasks: [UploadTask] { get async { let activeTasks = await urlSession.allTasks.compactMap { $0 as? URLSessionUploadTask } return await uploadTasks diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 75919119..6d5ff96f 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -11,8 +11,8 @@ import Foundation public protocol UploadAPIManaging { typealias StateStream = AsyncPublisher> - /// Currently ongoing upload tasks. - var allTasks: [UploadTask] { get async } + /// Currently active upload tasks. + var activeTasks: [UploadTask] { get async } /// Initiates a data upload request for the specified endpoint. /// - Parameters: From ab940e381a9b18243bcf97c8739aacb4c7739bd9 Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Tue, 20 Jun 2023 10:11:37 +0200 Subject: [PATCH 61/79] [chore] update section about mock response provider --- .../Documentation.docc/Documentation.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/Networking/Documentation.docc/Documentation.md b/Sources/Networking/Documentation.docc/Documentation.md index ee267ce1..584af800 100644 --- a/Sources/Networking/Documentation.docc/Documentation.md +++ b/Sources/Networking/Documentation.docc/Documentation.md @@ -76,7 +76,8 @@ init( ) ``` -2. Using custom response provider by conforming to ``ResponseProviding``. +2. Using custom response provider by conforming to ``ResponseProviding``. An example of a custom provider is ``MockResponseProvider``, which can be used for UI tests to interact with mocked data saved through "EndpointRequestStorageProcessor". To utilize them, simply move the stored session folder into the Asset catalogue. + ```swift init( responseProvider: ResponseProviding, @@ -85,6 +86,7 @@ init( errorProcessors: [ErrorProcessing] = [] ) ``` + Adapters and processors are passed during initialisation and cannot be changed afterwards. There are two methods provided by the ``APIManaging`` protocol: @@ -183,8 +185,8 @@ let retryConfiguration = RetryConfiguration(retries: 2, delay: .constant(1)) { e } ``` -## Interceptors -Interceptors are useful pieces of code that modify request/response in the network request pipeline. +## Modifiers +Modifiers are useful pieces of code that modify request/response in the network request pipeline. ![Interceptors diagram](interceptors-diagram.png) There are three types you can leverage:
@@ -195,13 +197,13 @@ Adapters are request transformable components that perform operations on the URL ``ResponseProcessing`` -Processors are modifying the URLResponse received after a successful network request. +Processors are handling the ``Response`` received after a successful network request. ``RequestInterceptor`` Interceptors handle both adapting and processing. -By conforming to these protocols, you can create your own adaptors/processors/interceptors. In the following part, interceptors provided by Networking are introduced. +By conforming to these protocols, you can create your own adaptors/processors/interceptors. In the following part, modifiers provided by Networking are introduced. ## Request Interceptors @@ -262,9 +264,9 @@ APIManager( ``` ### Storage -Networking provides an ``EndpointRequestStorageProcessor`` which allows for requests and responses to be saved locally into the file system. +Networking provides an ``EndpointRequestStorageProcessor`` which allows for requests and responses to be saved locally into the file system. Requests are stored in a sequential manner. Each session is kept in its own dedicated folder. The ``EndpointRequestStorageModel`` includes both successful and erroneous data. -Initialise by optionally providing a `FileManager` instance, `JSONEncoder` to be used during request/response data encoding and a configuration. The configuration allows you to set a `storedSessionsLimit` and optionally a multiPeerSharing configuration if you wish to utilize the multipeer connectivity feature for sharing the ``EndpointRequestStorageModel`` with devices using the `MultipeerConnectivity` framework. +Initialise by optionally providing a `FileManager` instance, `JSONEncoder` to be used during request/response data encoding and a configuration. The configuration allows you to set optionally a multiPeerSharing configuration if you wish to utilize the multipeer connectivity feature for sharing the ``EndpointRequestStorageModel`` with devices using the `MultipeerConnectivity` framework. ```swift init( From f5c35ced0292250d888d771b7afe921f2025d0b8 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Tue, 20 Jun 2023 20:17:29 +0800 Subject: [PATCH 62/79] feat: show error alerts in examples --- .../Scenes/Upload/FormUploadsViewModel.swift | 4 ++++ .../Scenes/Upload/UploadsView.swift | 16 ++++++++++++++++ .../Scenes/Upload/UploadsViewModel.swift | 5 ++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index b0d4096d..a9f40bd5 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -11,6 +11,8 @@ import Foundation final class FormUploadsViewModel: ObservableObject { @Published var text = "" @Published var fileUrl: URL? + @Published var isErrorAlertPresented = false + @Published private(set) var error: Error? @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] var selectedFileName: String { @@ -49,6 +51,8 @@ extension FormUploadsViewModel { fileUrl = nil } catch { print("Failed to upload with error:", error) + self.error = error + self.isErrorAlertPresented = true } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index d775c5f4..95739b61 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -44,6 +44,22 @@ struct UploadsView: View { } } } + .alert( + "Error", + isPresented: $viewModel.isErrorAlertPresented, + actions: {}, + message: { + Text(viewModel.error?.localizedDescription ?? "") + } + ) + .alert( + "Error", + isPresented: $formViewModel.isErrorAlertPresented, + actions: {}, + message: { + Text(formViewModel.error?.localizedDescription ?? "") + } + ) .navigationTitle("Uploads") } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index 0a9e6a2b..311304c2 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -9,7 +9,8 @@ import Foundation @MainActor final class UploadsViewModel: ObservableObject { - @Published var error: Error? + @Published var isErrorAlertPresented = false + @Published private(set) var error: Error? @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] private let uploadService: UploadService @@ -36,6 +37,7 @@ extension UploadsViewModel { } catch { print("Failed to upload with error", error) self.error = error + self.isErrorAlertPresented = true } } } @@ -52,6 +54,7 @@ extension UploadsViewModel { } catch { print("Failed to upload with error", error) self.error = error + self.isErrorAlertPresented = true } } } From 312452cabe2a448da33bba4d3e52c49d8207e91e Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 26 Jun 2023 13:51:10 +0800 Subject: [PATCH 63/79] refactor: provide default inits, make uploadsViewModel state object --- NetworkingSampleApp/NetworkingSampleApp/ContentView.swift | 7 +------ .../NetworkingSampleApp/Scenes/Upload/UploadService.swift | 2 +- .../NetworkingSampleApp/Scenes/Upload/UploadsView.swift | 5 +++-- .../Scenes/Upload/UploadsViewModel.swift | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift index 6d7d037f..44bf5700 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift @@ -5,7 +5,6 @@ // Created by Matej Molnár on 28.01.2023. // -import Networking import SwiftUI enum NetworkingFeature: String, Hashable, CaseIterable { @@ -30,11 +29,7 @@ struct ContentView: View { case .downloads: DownloadsView() case .uploads: - UploadsView(viewModel: UploadsViewModel( - uploadService: UploadService( - uploadManager: UploadAPIManager() - ) - )) + UploadsView() } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 0a402d4e..0d4f7392 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -11,7 +11,7 @@ import Networking final class UploadService { private let uploadManager: UploadAPIManaging - init(uploadManager: UploadAPIManaging) { + init(uploadManager: UploadAPIManaging = UploadAPIManager()) { self.uploadManager = uploadManager } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index 0dd01475..ea851804 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -5,11 +5,12 @@ // Created by Tony Ngo on 12.06.2023. // -import SwiftUI import PhotosUI +import SwiftUI struct UploadsView: View { - @ObservedObject var viewModel: UploadsViewModel + @StateObject var viewModel = UploadsViewModel() + @State var isPhotosPickerPresented = false @State var isFileImporterPresented = false @State var selectedPhotoPickerItem: PhotosPickerItem? diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index 0a9e6a2b..f1003c64 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -14,7 +14,7 @@ final class UploadsViewModel: ObservableObject { private let uploadService: UploadService - init(uploadService: UploadService) { + init(uploadService: UploadService = UploadService()) { self.uploadService = uploadService } } From 86c96f61c5124f0a9bc431220af913b739376b48 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 26 Jun 2023 13:53:08 +0800 Subject: [PATCH 64/79] feat: provide task lookup by identifier on `UploadAPIManaging` protocol --- .../NetworkingSampleApp/Scenes/Upload/UploadService.swift | 6 ------ Sources/Networking/Core/Upload/UploadAPIManaging.swift | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 0d4f7392..9903dcb9 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -68,9 +68,3 @@ extension UploadService { ) } } - -private extension UploadAPIManaging { - func task(with id: String) async -> UploadTask? { - await activeTasks.first { $0.id == id } - } -} diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 6d5ff96f..aa00e495 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -58,3 +58,10 @@ public protocol UploadAPIManaging { /// - Parameter shouldFinishTasks: Determines whether all outstanding tasks should finish before invalidating the session or be immediately cancelled. func invalidateSession(shouldFinishTasks: Bool) } + +public extension UploadAPIManaging { + /// Returns an active ``UploadTask`` specified by its identifier. + func task(with id: UploadTask.ID) async -> UploadTask? { + await activeTasks.first { $0.id == id } + } +} From 21cb093c5aa7757d98b0742b70733dd039d768af Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 26 Jun 2023 14:05:23 +0800 Subject: [PATCH 65/79] refactor: mark extension as public --- Sources/Networking/Core/Upload/UploadTask+State.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadTask+State.swift b/Sources/Networking/Core/Upload/UploadTask+State.swift index c0553ddb..ef7030ca 100644 --- a/Sources/Networking/Core/Upload/UploadTask+State.swift +++ b/Sources/Networking/Core/Upload/UploadTask+State.swift @@ -7,9 +7,9 @@ import Foundation -extension UploadTask { +public extension UploadTask { /// The upload task's state. - public struct State { + struct State { /// Number of bytes sent. public let sentBytes: Int64 From 6729a56128ebcf6d94f8d7348273e84548f5b9ab Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 26 Jun 2023 14:11:27 +0800 Subject: [PATCH 66/79] docs: explain usage of completion closure upload tasks --- Sources/Networking/Core/Upload/UploadAPIManager.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index c7e7c9be..f38548d0 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -250,6 +250,13 @@ private extension UploadAPIManager { } } + /// The implementation uses completion closure version of the upload task instaed of the async versions. + /// + /// The async versions could be used, however, if we go down that route, we'll need to make some slight + /// changes to the implementation, because: + /// - The async versions don't return `URLSessionTask` and will be suspended, which means returning immediately + /// as it is now wouldn't be possible. So we'll need to consider what to return to the client, if anything at all. + /// - We'll need to handle errors and responses from the request using delegates. func sessionUploadTask( with uploadable: Uploadable, for request: URLRequest, From e74e2dcfb7aa084024d75b95f6d5f467e0ef0ca5 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:07:08 +0200 Subject: [PATCH 67/79] chore: add sample upload server --- .../NetworkingSampleApp/API/Routers/SampleUploadRouter.swift | 4 ++-- .../NetworkingSampleApp/API/SampleAPIConstants.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift index 396637c4..1c4b1194 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift @@ -14,7 +14,7 @@ enum SampleUploadRouter: Requestable { case file(URL) var baseURL: URL { - fatalError("Provide your API base URL for upload") + URL(string: SampleAPIConstants.uploadHost)! } var headers: [String: String]? { @@ -27,7 +27,7 @@ enum SampleUploadRouter: Requestable { } var path: String { - fatalError("Provide your API endpoint path for upload") + "/post" } var method: HTTPMethod { diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift index 1db7331c..15e5ed0d 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift @@ -11,6 +11,7 @@ import Foundation enum SampleAPIConstants { static let userHost = "https://reqres.in/api" static let authHost = "https://nonexistentmockauth.com/api" + static let uploadHost = "https://httpbin.org" static let validEmail = "eve.holt@reqres.in" static let validPassword = "cityslicka" static let videoUrl = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" From 30bf7c53c28ccd97e253bf7ceef8f2bd2d0e3e50 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:20:26 +0200 Subject: [PATCH 68/79] chore: make contentHeaders on body part public --- Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift index 6208b4e3..86da8894 100644 --- a/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift @@ -27,7 +27,7 @@ public extension MultipartFormData { } } -extension MultipartFormData.BodyPart { +public extension MultipartFormData.BodyPart { /// Returns the body part's header fields and values based on the properties of the instance. var contentHeaders: [HTTPHeader.HeaderField: String] { var disposition = "form-data; name=\"\(name)\"" From 8b134f752666511ace843c8ef17254364b997b33 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:25:57 +0200 Subject: [PATCH 69/79] refactor: log using os_log instead of print --- .../Scenes/Upload/FormUploadsViewModel.swift | 3 ++- .../NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index 49e5df1d..b0cfd8e7 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog @MainActor final class FormUploadsViewModel: ObservableObject { @@ -50,7 +51,7 @@ extension FormUploadsViewModel { text = "" fileUrl = nil } catch { - print("Failed to upload with error:", error) + os_log("❌ FormUploadsViewModel failed to upload form with error: \(error.localizedDescription)") self.error = error self.isErrorAlertPresented = true } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index be09c11a..a07f2485 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog @MainActor final class UploadsViewModel: ObservableObject { @@ -35,7 +36,7 @@ extension UploadsViewModel { )) } } catch { - print("Failed to upload with error", error) + os_log("❌ UploadsViewModel failed to upload with error: \(error.localizedDescription)") self.error = error self.isErrorAlertPresented = true } @@ -52,7 +53,7 @@ extension UploadsViewModel { uploadService: uploadService )) } catch { - print("Failed to upload with error", error) + os_log("❌ UploadsViewModel failed to upload with error: \(error.localizedDescription)") self.error = error self.isErrorAlertPresented = true } From 085c06c497e84d64eb6a62ca8a1a6d83ed2de410 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:43:43 +0200 Subject: [PATCH 70/79] chore: resolve PR comments --- .../API/Routers/SampleUploadRouter.swift | 7 ------- .../Scenes/Upload/FormUploadsViewModel.swift | 6 +++--- .../NetworkingSampleApp/Scenes/Upload/UploadsView.swift | 2 +- Sources/Networking/Utils/URL+Convenience.swift | 2 +- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift index 15f40a4f..49c2f4b8 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift @@ -7,7 +7,6 @@ import Foundation import Networking -import UniformTypeIdentifiers enum SampleUploadRouter: Requestable { case image @@ -37,9 +36,3 @@ enum SampleUploadRouter: Requestable { .post } } - -private extension URL { - var mimeType: String { - UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" - } -} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index b0cfd8e7..be7b57ea 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -10,7 +10,7 @@ import OSLog @MainActor final class FormUploadsViewModel: ObservableObject { - @Published var text = "" + @Published var username = "" @Published var fileUrl: URL? @Published var isErrorAlertPresented = false @Published private(set) var error: Error? @@ -36,7 +36,7 @@ extension FormUploadsViewModel { Task { do { let uploadItem = try await uploadService.uploadFormData { form in - form.append(Data(self.text.utf8), name: "textfield") + form.append(Data(self.username.utf8), name: "username-textfield") if let fileUrl = self.fileUrl { try form.append(from: fileUrl, name: "attachment") @@ -48,7 +48,7 @@ extension FormUploadsViewModel { uploadService: uploadService )) - text = "" + username = "" fileUrl = nil } catch { os_log("❌ FormUploadsViewModel failed to upload form with error: \(error.localizedDescription)") diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index b25535f8..fb7ae33e 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -93,7 +93,7 @@ private extension UploadsView { var multipartUpload: some View { Section( content: { - TextField("Enter text", text: $formViewModel.text) + TextField("Enter username", text: $formViewModel.username) HStack { if formViewModel.fileUrl == nil { diff --git a/Sources/Networking/Utils/URL+Convenience.swift b/Sources/Networking/Utils/URL+Convenience.swift index 21dc5d0c..92bf9fbe 100644 --- a/Sources/Networking/Utils/URL+Convenience.swift +++ b/Sources/Networking/Utils/URL+Convenience.swift @@ -8,7 +8,7 @@ import Foundation import UniformTypeIdentifiers -extension URL { +public extension URL { var mimeType: String { UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" } From 5cd1a725f6b5131c0c0d29f6e295424d5a0b1185 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:56:36 +0200 Subject: [PATCH 71/79] refactor: use formatter to show file size in MB --- .../project.pbxproj | 4 ++++ .../ByteCountFormatter+Convenience.swift | 17 +++++++++++++++++ .../Scenes/Upload/FormUploadsViewModel.swift | 6 +++--- .../Scenes/Upload/UploadService.swift | 7 ++++++- 4 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 067eb2fb..752c9f5b 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */; }; B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C42A37102D006D3B9C /* UploadsView.swift */; }; B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C62A371046006D3B9C /* UploadItemView.swift */; }; + B58162F72A4F23420074A115 /* ByteCountFormatter+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */; }; B5A2CE6C2A3FF42400467EB3 /* FormUploadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */; }; DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */; }; DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */; }; @@ -75,6 +76,7 @@ B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemViewModel.swift; sourceTree = ""; }; B52674C42A37102D006D3B9C /* UploadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsView.swift; sourceTree = ""; }; B52674C62A371046006D3B9C /* UploadItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemView.swift; sourceTree = ""; }; + B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ByteCountFormatter+Convenience.swift"; sourceTree = ""; }; B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormUploadsViewModel.swift; 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 = ""; }; @@ -257,6 +259,7 @@ isa = PBXGroup; children = ( DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */, + B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */, ); path = Extensions; sourceTree = ""; @@ -344,6 +347,7 @@ 23EA9CF7292FB70A00B8E418 /* SampleUserAuthResponse.swift in Sources */, 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */, 23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */, + B58162F72A4F23420074A115 /* ByteCountFormatter+Convenience.swift in Sources */, 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */, B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */, 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */, diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift new file mode 100644 index 00000000..f9bde355 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift @@ -0,0 +1,17 @@ +// +// ByteCountFormatter+Convenience.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 30.06.2023. +// + +import Foundation + +extension ByteCountFormatter { + static let megaBytesFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB] + formatter.countStyle = .file + return formatter + }() +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index be7b57ea..7def0331 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -17,10 +17,10 @@ final class FormUploadsViewModel: ObservableObject { @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] var selectedFileName: String { - let resources = try? fileUrl?.resourceValues(forKeys:[.fileSizeKey]) - let fileSize = (resources?.fileSize ?? 0) / 1_000_000 + let fileSize = Int64(fileUrl?.fileSize ?? 0) var fileName = fileUrl?.lastPathComponent ?? "" - if fileSize > 0 { fileName += "\n\(fileSize) MB" } + let formattedFileSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: fileSize) + if fileSize > 0 { fileName += "\n\(formattedFileSize)" } return fileName } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index f949d559..e02e1804 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -48,14 +48,19 @@ extension UploadService { func uploadFormData(_ build: @escaping (MultipartFormData) throws -> Void) async throws -> UploadItem { let multipartFormData = MultipartFormData() try build(multipartFormData) + let task = try await uploadManager.upload( multipartFormData: multipartFormData, to: SampleUploadRouter.multipart(boundary: multipartFormData.boundary), retryConfiguration: .default ) + + let dataSize = Int64(multipartFormData.size) + let formattedDataSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: dataSize) + return UploadItem( id: task.id, - fileName: "Form upload of size \(multipartFormData.size)" + fileName: "Form upload of size \(formattedDataSize)" ) } From 7f5c96c3b7a9dcb1956cf4634b330f63ecbe1b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 13 Jul 2023 13:26:14 +0200 Subject: [PATCH 72/79] fix: copy downloaded file to temp location before method returns --- .../Networking/Core/DownloadAPIManager.swift | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index 1f0d8914..9a16dc0a 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -197,10 +197,22 @@ extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { } 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() + do { + guard let response = downloadTask.response else { + return + } + + // Move downloaded contents to documents as location will be unavailable after scope of this method. + let tempURL = try location.moveContentsToDocuments(response: response) + Task { + await downloadStateDict.update(task: downloadTask, for: \.downloadedFileURL, with: tempURL) + downloadStateDictSubject.send(await downloadStateDict.getValues()) + updateTasks() + } + } catch { + Task { + await downloadStateDict.update(task: downloadTask, for: \.error, with: error) + } } } @@ -212,3 +224,23 @@ extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { } } } + +extension URL { + enum FileError: Error { + case documentsDirUnavailable + } + + func moveContentsToDocuments(response: URLResponse) throws -> URL { + guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw FileError.documentsDirUnavailable + } + + // Use original extension, otherwise urlSession saves file as .tmp + let pathExtension = response.url?.pathExtension ?? pathExtension + let newURL = documentsURL.appendingPathComponent("temp.\(pathExtension)") + + try? FileManager.default.removeItem(at: newURL) + try FileManager.default.moveItem(at: self, to: newURL) + return newURL + } +} From 80d2a465beceffa0faad9bcdecd4a886d1b5ebcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Thu, 13 Jul 2023 14:15:29 +0200 Subject: [PATCH 73/79] chore: keep original file name --- Sources/Networking/Core/DownloadAPIManager.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index 9a16dc0a..c61a7b6f 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/DownloadAPIManager.swift @@ -227,17 +227,17 @@ extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { extension URL { enum FileError: Error { - case documentsDirUnavailable + case documentsDirectoryUnavailable } func moveContentsToDocuments(response: URLResponse) throws -> URL { guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - throw FileError.documentsDirUnavailable + throw FileError.documentsDirectoryUnavailable } - // Use original extension, otherwise urlSession saves file as .tmp - let pathExtension = response.url?.pathExtension ?? pathExtension - let newURL = documentsURL.appendingPathComponent("temp.\(pathExtension)") + // Use original filename. + let filename = response.suggestedFilename ?? response.url?.lastPathComponent ?? lastPathComponent + let newURL = documentsURL.appendingPathComponent(filename) try? FileManager.default.removeItem(at: newURL) try FileManager.default.moveItem(at: self, to: newURL) From 71eaec8ee37ba0d33080dadd39ab8d6c34f3a915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Mon, 7 Aug 2023 14:07:53 +0200 Subject: [PATCH 74/79] chore: use delegates instead of completion handler to support background upload, error handling --- .../Core/Upload/UploadAPIManager.swift | 170 ++++++++++-------- .../Networking/Core/Upload/UploadTask.swift | 5 +- 2 files changed, 102 insertions(+), 73 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 05a50867..b0c91c0c 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -1,6 +1,6 @@ // // UploadAPIManager.swift -// +// // // Created by Tony Ngo on 12.06.2023. // @@ -59,6 +59,38 @@ open class UploadAPIManager: NSObject { } } +// MARK: URLSessionDataDelegate +extension UploadAPIManager: URLSessionDataDelegate { + public func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + Task { + guard let uploadTask = await uploadTask(for: dataTask) else { + return + } + + if let originalRequest = dataTask.originalRequest, + let response = dataTask.response { + do { + try await handleUploadTaskCompletion( + uploadTask: uploadTask, + urlRequest: originalRequest, + response: response, + data: data + ) + } catch { + await handleUploadTaskError( + uploadTask: uploadTask, + error: error + ) + } + } + } + } +} + // MARK: - URLSessionTaskDelegate extension UploadAPIManager: URLSessionTaskDelegate { public func urlSession( @@ -74,6 +106,27 @@ extension UploadAPIManager: URLSessionTaskDelegate { .send(UploadTask.State(task: task)) } } + + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + Task { + await uploadTask(for: task)? + .statePublisher + .send(UploadTask.State(task: task)) + + guard let uploadTask = await uploadTask(for: task) else { + return + } + + await handleUploadTaskError( + uploadTask: uploadTask, + error: error + ) + } + } } // MARK: - UploadAPIManaging @@ -119,10 +172,13 @@ extension UploadAPIManager: UploadAPIManaging { retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + + // Determine if the session configuration is background. + let usesBackgroundSession = urlSessionConfiguration.sessionSendsLaunchEvents // Encode in-memory and upload directly if the payload's size is less than the threshold, // otherwise we write the payload to the disk first and upload by reading the file content. - if multipartFormData.size < sizeThreshold { + if multipartFormData.size < sizeThreshold && !usesBackgroundSession { let encodedMultipartFormData = try multipartFormDataEncoder.encode(multipartFormData) return try await uploadRequest( .data(encodedMultipartFormData), @@ -180,30 +236,22 @@ private extension UploadAPIManager { ) async throws -> UploadTask { do { let urlRequest = try await prepare(request) - + let sessionUploadTask = sessionUploadTask( with: uploadable, for: urlRequest - ) { [weak self] data, response, error in - self?.handleUploadTaskCompletion( - urlRequest: urlRequest, - endpointRequest: request, - retryConfiguration: retryConfiguration, - data: data, - response: response, - error: error - ) - } - + ) + let uploadTask = await existingUploadTaskOrNew( for: sessionUploadTask, request: request, uploadable: uploadable ) - + // Store the task for future processing await uploadTasks.set(value: uploadTask, for: request.id) sessionUploadTask.resume() + return uploadTask } catch { throw await errorProcessors.process(error, for: request) @@ -229,59 +277,44 @@ private extension UploadAPIManager { } func handleUploadTaskCompletion( + uploadTask: UploadTask, urlRequest: URLRequest, - endpointRequest: EndpointRequest, - retryConfiguration: RetryConfiguration?, - data: Data?, - response: URLResponse?, + response: URLResponse, + data: Data + ) async throws { + var state = UploadTask.State(task: uploadTask.task) + state.response = try await responseProcessors.process( + (data, response), + with: urlRequest, + for: uploadTask.endpointRequest + ) + await uploadTask.complete(with: state) + + // Cleanup on successful task completion + await uploadTask.resetRetryCounter() + await uploadTasks.set(value: nil, for: uploadTask.endpointRequest.id) + } + + func handleUploadTaskError( + uploadTask: UploadTask, error: Error? - ) { - Task { - guard let uploadTask = await uploadTasks.getValue(for: endpointRequest.id) else { + ) async { + var state = UploadTask.State(task: uploadTask.task) + + if let error { + // URLError.Code.cancelled is thrown if the URLSessionTask is cancelled. + // Consider this action intentional, thus the request won't be retried. + guard !state.cancelled else { return } - - var state = UploadTask.State(task: uploadTask.task) - if let data, let response { - state.response = try await responseProcessors.process( - (data, response), - with: urlRequest, - for: endpointRequest - ) - - try await uploadTask.complete(with: state) - - // Cleanup on successful task completion - await uploadTask.cleanup() - await uploadTasks.set(value: nil, for: endpointRequest.id) - } else if let error { - do { - // URLError.Code.cancelled is thrown if the URLSessionTask is cancelled. - // Consider this action intentional, thus the request won't be retried. - guard !state.cancelled else { - throw error - } - - try await uploadTask.sleepIfRetry( - for: error, - retryConfiguration: retryConfiguration - ) - - try await self.uploadRequest( - uploadTask.uploadable, - request: uploadTask.endpointRequest, - retryConfiguration: retryConfiguration - ) - } catch { - state.error = await errorProcessors.process( - error, - for: uploadTask.endpointRequest - ) - - // No cleanup in case the task will be retried. - try await uploadTask.complete(with: state) - } - } + + state.error = await errorProcessors.process( + error, + for: uploadTask.endpointRequest + ) + + // No cleanup in case the task will be retried. + await uploadTask.complete(with: state) } } @@ -294,21 +327,18 @@ private extension UploadAPIManager { /// - We'll need to handle errors and responses from the request using delegates. func sessionUploadTask( with uploadable: Uploadable, - for request: URLRequest, - completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + for request: URLRequest ) -> URLSessionUploadTask { switch uploadable { case let .data(data): return urlSession.uploadTask( with: request, - from: data, - completionHandler: completionHandler + from: data ) case let .file(fileUrl, _): return urlSession.uploadTask( with: request, - fromFile: fileUrl, - completionHandler: completionHandler + fromFile: fileUrl ) } } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 51436263..a34bc2ea 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -77,14 +77,13 @@ extension UploadTask { /// - Parameters: /// - state: The latest state to emit before completing the task. /// - delay: The delay between the emitting the `state` and completion in nanoseconds. Defaults to 0.2 seconds. - func complete(with state: State, delay: TimeInterval = 20_000_000) async throws { + func complete(with state: State, delay: TimeInterval = 20_000_000) async { statePublisher.send(state) // Publishing value and completion one after another might cause the completion // cancelling the whole stream before the client can process the emitted value. - try await Task.sleep(nanoseconds: UInt64(delay)) + try? await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) - } func cleanup() async { From 58368078b918598c78c3b126d671021921ab2c72 Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Mon, 21 Aug 2023 16:14:28 +0200 Subject: [PATCH 75/79] [feat] add documentation to data encoder, polish uploadTask --- .../Upload/MultipartFormDataEncoder.swift | 6 +++++ .../Core/Upload/UploadAPIManager.swift | 2 +- .../Networking/Core/Upload/UploadTask.swift | 22 +++++++++---------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift index 6337d9c5..a37fecf8 100644 --- a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift @@ -31,6 +31,12 @@ open class MultipartFormDataEncoder { } } + +/** + +The main reason why there are methods to encode data & encode file is similar to `uploadTask(with:from:)` and `uploadTask(with:fromFile:)` ig one could convert the content of the file to Data using Data(contentsOf:) and use the first method to send data. One has the data available in memory while the second reads the data directly from the file thus doesn't load the data into memory so it is more efficient. + */ + // MARK: - MultipartFormDataEncoding extension MultipartFormDataEncoder: MultipartFormDataEncoding { public func encode(_ multipartFormData: MultipartFormData) throws -> Data { diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index b0c91c0c..a9956a9e 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -291,7 +291,7 @@ private extension UploadAPIManager { await uploadTask.complete(with: state) // Cleanup on successful task completion - await uploadTask.resetRetryCounter() + await uploadTask.cleanup() await uploadTasks.set(value: nil, for: uploadTask.endpointRequest.id) } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index a34bc2ea..7257cbc8 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -59,6 +59,14 @@ public extension UploadTask { task.cancel() statePublisher.send(State(task: task)) } + + func cleanup() async { + await resetRetryCounter() + + if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { + try? fileManager.removeItem(at: url) + } + } } // MARK: - Internal API @@ -85,13 +93,9 @@ extension UploadTask { try? await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) } - - func cleanup() async { - await resetRetryCounter() - - if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { - try? fileManager.removeItem(at: url) - } + + func resetRetryCounter() async { + await retryCounter.reset(for: endpointRequest.id) } } @@ -120,10 +124,6 @@ extension UploadTask: Retryable { retryConfiguration: retryConfiguration ) } - - func resetRetryCounter() async { - await retryCounter.reset(for: endpointRequest.id) - } } // MARK: - Identifiable From c80991bdaf4b13e25e0eb4d3ff24d6ea657b94ac Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Tue, 22 Aug 2023 09:45:35 +0200 Subject: [PATCH 76/79] [feat] adjust upload multipartdata creation flow --- .../Scenes/Upload/FormUploadsViewModel.swift | 22 +++++++++++++------ .../Scenes/Upload/UploadService.swift | 13 +++++------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index 7def0331..ebf78050 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import Networking import OSLog @MainActor @@ -35,13 +36,8 @@ extension FormUploadsViewModel { func uploadForm() { Task { do { - let uploadItem = try await uploadService.uploadFormData { form in - form.append(Data(self.username.utf8), name: "username-textfield") - - if let fileUrl = self.fileUrl { - try form.append(from: fileUrl, name: "attachment") - } - } + let multipartFormData = try createMultipartFormData() + let uploadItem = try await uploadService.uploadFormData(multipartFormData) uploadItemViewModels.append(UploadItemViewModel( item: uploadItem, @@ -58,3 +54,15 @@ extension FormUploadsViewModel { } } } + +// MARK: - Prepare multipartForm data +private extension FormUploadsViewModel { + func createMultipartFormData() throws -> MultipartFormData { + let multipartFormData = MultipartFormData() + multipartFormData.append(Data(username.utf8), name: "username-textfield") + if let fileUrl { + try multipartFormData.append(from: fileUrl, name: "attachment") + } + return multipartFormData + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index e02e1804..0227c8d4 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -45,17 +45,14 @@ extension UploadService { ) } - func uploadFormData(_ build: @escaping (MultipartFormData) throws -> Void) async throws -> UploadItem { - let multipartFormData = MultipartFormData() - try build(multipartFormData) - + func uploadFormData(_ data: MultipartFormData) async throws -> UploadItem { let task = try await uploadManager.upload( - multipartFormData: multipartFormData, - to: SampleUploadRouter.multipart(boundary: multipartFormData.boundary), + multipartFormData: data, + to: SampleUploadRouter.multipart(boundary: data.boundary), retryConfiguration: .default ) - let dataSize = Int64(multipartFormData.size) + let dataSize = Int64(data.size) let formattedDataSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: dataSize) return UploadItem( @@ -63,7 +60,7 @@ extension UploadService { fileName: "Form upload of size \(formattedDataSize)" ) } - + func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream { await uploadManager.stateStream(for: uploadTaskId) } From 0f4b9d1e48f41349cdbdc48a96377ab7380eb063 Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Wed, 23 Aug 2023 10:55:57 +0200 Subject: [PATCH 77/79] [feat] refactor fileManager on UploadTask --- .../Scenes/Upload/UploadService.swift | 1 + Sources/Networking/Core/Upload/UploadAPIManager.swift | 3 +-- Sources/Networking/Core/Upload/UploadTask.swift | 9 ++------- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 0227c8d4..ba85b4de 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -27,6 +27,7 @@ extension UploadService { to: SampleUploadRouter.image, retryConfiguration: .default ) + return UploadItem( id: task.id, fileName: fileName diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index a9956a9e..e3e1b4e2 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -268,8 +268,7 @@ private extension UploadAPIManager { return UploadTask( sessionUploadTask: sessionUploadTask, endpointRequest: request, - uploadable: uploadable, - fileManager: fileManager + uploadable: uploadable ) } existingUploadTask.task = sessionUploadTask diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 7257cbc8..d667126f 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -26,9 +26,6 @@ public struct UploadTask { /// The counter that counts number of retries for this task. let retryCounter: Counter - - /// The file manager associated with the task. - let fileManager: FileManager } // MARK: - Public API @@ -64,7 +61,7 @@ public extension UploadTask { await resetRetryCounter() if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { - try? fileManager.removeItem(at: url) + try? FileManager.default.removeItem(at: url) } } } @@ -103,15 +100,13 @@ extension UploadTask { init( sessionUploadTask: URLSessionUploadTask, endpointRequest: EndpointRequest, - uploadable: Uploadable, - fileManager: FileManager + uploadable: Uploadable ) { self.task = sessionUploadTask self.endpointRequest = endpointRequest self.uploadable = uploadable self.statePublisher = .init(State(task: sessionUploadTask)) self.retryCounter = Counter() - self.fileManager = fileManager } } From e16a558002ffdf53ebac63f8eafe09b177d68455 Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Wed, 23 Aug 2023 12:44:13 +0200 Subject: [PATCH 78/79] [feat] remove retry configuration from uploading --- .../Scenes/Upload/UploadService.swift | 12 +++---- .../Core/Upload/UploadAPIManager.swift | 32 ++++++------------- .../Core/Upload/UploadAPIManaging.swift | 24 ++++---------- .../Networking/Core/Upload/UploadTask.swift | 21 ------------ 4 files changed, 21 insertions(+), 68 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index ba85b4de..9d745649 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -24,8 +24,7 @@ extension UploadService { func uploadImage(_ data: Data, fileName: String) async throws -> UploadItem { let task = try await uploadManager.upload( data: data, - to: SampleUploadRouter.image, - retryConfiguration: .default + to: SampleUploadRouter.image ) return UploadItem( @@ -37,8 +36,7 @@ extension UploadService { func uploadFile(_ fileUrl: URL) async throws -> UploadItem { let task = try await uploadManager.upload( fromFile: fileUrl, - to: SampleUploadRouter.file(fileUrl), - retryConfiguration: .default + to: SampleUploadRouter.file(fileUrl) ) return UploadItem( id: task.id, @@ -49,8 +47,7 @@ extension UploadService { func uploadFormData(_ data: MultipartFormData) async throws -> UploadItem { let task = try await uploadManager.upload( multipartFormData: data, - to: SampleUploadRouter.multipart(boundary: data.boundary), - retryConfiguration: .default + to: SampleUploadRouter.multipart(boundary: data.boundary) ) let dataSize = Int64(data.size) @@ -80,8 +77,7 @@ extension UploadService { func retry(_ uploadItem: UploadItem) async throws { try await uploadManager.retry( - taskId: uploadItem.id, - retryConfiguration: .default + taskId: uploadItem.id ) } } diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index e3e1b4e2..501d85d5 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -141,35 +141,30 @@ extension UploadAPIManager: UploadAPIManaging { public func upload( data: Data, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) return try await uploadRequest( .data(data), - request: endpointRequest, - retryConfiguration: retryConfiguration + request: endpointRequest ) } public func upload( fromFile fileUrl: URL, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) return try await uploadRequest( .file(fileUrl), - request: endpointRequest, - retryConfiguration: retryConfiguration + request: endpointRequest ) } public func upload( multipartFormData: MultipartFormData, sizeThreshold: UInt64 = 10_000_000, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) @@ -182,24 +177,19 @@ extension UploadAPIManager: UploadAPIManaging { let encodedMultipartFormData = try multipartFormDataEncoder.encode(multipartFormData) return try await uploadRequest( .data(encodedMultipartFormData), - request: endpointRequest, - retryConfiguration: retryConfiguration + request: endpointRequest ) } else { let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) try multipartFormDataEncoder.encode(multipartFormData, to: temporaryFileUrl) return try await uploadRequest( .file(temporaryFileUrl, removeOnComplete: true), - request: endpointRequest, - retryConfiguration: retryConfiguration + request: endpointRequest ) } } - public func retry( - taskId: String, - retryConfiguration: RetryConfiguration? - ) async throws { + public func retry(taskId: String) async throws { // Get stored upload task to invoke the request with the same arguments guard let existingUploadTask = await uploadTasks.getValue(for: taskId) else { throw NetworkError.unknown @@ -211,8 +201,7 @@ extension UploadAPIManager: UploadAPIManaging { try await uploadRequest( existingUploadTask.uploadable, - request: existingUploadTask.endpointRequest, - retryConfiguration: retryConfiguration + request: existingUploadTask.endpointRequest ) } @@ -231,8 +220,7 @@ private extension UploadAPIManager { @discardableResult func uploadRequest( _ uploadable: Uploadable, - request: EndpointRequest, - retryConfiguration: RetryConfiguration? + request: EndpointRequest ) async throws -> UploadTask { do { let urlRequest = try await prepare(request) diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index b2f8a454..db867d62 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -18,24 +18,20 @@ public protocol UploadAPIManaging { /// - Parameters: /// - data: The data to send to the server. /// - endpoint: The API endpoint to where data will be sent. - /// - retryConfiguration: An optional configuration for retry behavior. /// - Returns: An `UploadTask` that represents this request. func upload( data: Data, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask /// Initiates a file upload request for the specified endpoint. /// - Parameters: /// - fileUrl: The file's URL to send to the server. /// - endpoint: The API endpoint to where data will be sent. - /// - retryConfiguration: An optional configuration for retry behavior. /// - Returns: An `UploadTask` that represents this request. func upload( fromFile fileUrl: URL, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. @@ -48,21 +44,18 @@ public protocol UploadAPIManaging { /// - multipartFormData: The multipart form data to upload. /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. /// - endpoint: The API endpoint to where data will be sent. - /// - retryConfiguration: An optional configuration for retry behavior. /// /// - Returns: An `UploadTask` that represents this request. func upload( multipartFormData: MultipartFormData, sizeThreshold: UInt64, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask /// Retries the upload task with the specified identifier. /// - Parameters: /// - taskId: The upload task's identifier to retry. - /// - retryConfiguration: An optional configuration for retry behavior. - func retry(taskId: String, retryConfiguration: RetryConfiguration?) async throws + func retry(taskId: String) async throws /// Provides a stream of upload task's states for the specified `UploadTask.ID`. /// @@ -83,24 +76,21 @@ public extension UploadAPIManaging { /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. /// /// If the size of the `MultipartFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. - /// To specify different data threshold, use ``upload(multipartFormData:sizeThreshold:to:retryConfiguration:)``. + /// To specify different data threshold, use ``upload(multipartFormData:sizeThreshold:to:)``. /// /// - Parameters: /// - multipartFormData: The multipart form data to upload. /// - endpoint: The API endpoint to where data will be sent. - /// - retryConfiguration: An optional configuration for retry behavior. /// /// - Returns: An `UploadTask` that represents this request. func upload( multipartFormData: MultipartFormData, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask { try await upload( multipartFormData: multipartFormData, sizeThreshold: 10_000_000, - to: endpoint, - retryConfiguration: retryConfiguration + to: endpoint ) } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index d667126f..740694c9 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -23,9 +23,6 @@ public struct UploadTask { /// Use this publisher to emit a new state of the task. let statePublisher: CurrentValueSubject - - /// The counter that counts number of retries for this task. - let retryCounter: Counter } // MARK: - Public API @@ -58,8 +55,6 @@ public extension UploadTask { } func cleanup() async { - await resetRetryCounter() - if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { try? FileManager.default.removeItem(at: url) } @@ -90,10 +85,6 @@ extension UploadTask { try? await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) } - - func resetRetryCounter() async { - await retryCounter.reset(for: endpointRequest.id) - } } extension UploadTask { @@ -106,18 +97,6 @@ extension UploadTask { self.endpointRequest = endpointRequest self.uploadable = uploadable self.statePublisher = .init(State(task: sessionUploadTask)) - self.retryCounter = Counter() - } -} - -// MARK: - Retryable -extension UploadTask: Retryable { - func sleepIfRetry(for error: Error, retryConfiguration: RetryConfiguration?) async throws { - try await sleepIfRetry( - for: error, - endpointRequest: endpointRequest, - retryConfiguration: retryConfiguration - ) } } From 004dd253e4b8a7841a85544052d4fae3d99a80ca Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Wed, 6 Sep 2023 12:56:50 +0200 Subject: [PATCH 79/79] [chore] update boundary prefix --- Sources/Networking/Core/Upload/MultipartFormData.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Networking/Core/Upload/MultipartFormData.swift b/Sources/Networking/Core/Upload/MultipartFormData.swift index 04e07f1b..ade36a46 100644 --- a/Sources/Networking/Core/Upload/MultipartFormData.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData.swift @@ -26,7 +26,7 @@ open class MultipartFormData { /// - Parameter boundary: A custom boundary string to be used for separating the body parts in the multipart form data. /// If not provided, a unique boundary string is generated using a combination of "--boundary-" and a UUID. public init(boundary: String? = nil) { - self.boundary = boundary ?? "--boundary-\(UUID().uuidString)" + self.boundary = boundary ?? "----boundary-\(UUID().uuidString)" } }