diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 50dd980f..399dd9e8 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -18,30 +18,26 @@ 23EA9CF9292FB70A00B8E418 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CEF292FB70A00B8E418 /* User.swift */; }; 23EA9CFA292FB70A00B8E418 /* SampleUserAuthRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CF1292FB70A00B8E418 /* SampleUserAuthRequest.swift */; }; 23EA9CFB292FB70A00B8E418 /* SampleUserRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CF2292FB70A00B8E418 /* SampleUserRequest.swift */; }; - 587CD0EF2B27713700E3CB71 /* TaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CD0EE2B27713700E3CB71 /* TaskButton.swift */; }; 587CD0EC2B271CF800E3CB71 /* SampleCreateUserResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CD0EB2B271CF800E3CB71 /* SampleCreateUserResponse.swift */; }; + 587CD0EF2B27713700E3CB71 /* TaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CD0EE2B27713700E3CB71 /* TaskButton.swift */; }; 58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */; }; 58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */; }; - 58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */; }; 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */; }; 58D6976F2B21FF8300E6C529 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D6976E2B21FF8300E6C529 /* UsersView.swift */; }; 58D697712B21FF8E00E6C529 /* UsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D697702B21FF8E00E6C529 /* UsersViewModel.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 */; }; + 58F0D3312B346A6400CC2581 /* TaskProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F0D3302B346A6400CC2581 /* TaskProgressView.swift */; }; + 58F0D3332B346ACE00CC2581 /* URLSessionTask.State+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F0D3322B346ACE00CC2581 /* URLSessionTask.State+Convenience.swift */; }; + 58F0D3352B346C1F00CC2581 /* UploadAPIManager+SharedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F0D3342B346C1F00CC2581 /* UploadAPIManager+SharedInstance.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 */; }; + B52674C32A370E35006D3B9C /* UploadProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C22A370E35006D3B9C /* UploadProgressViewModel.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 */; }; 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 */; }; @@ -67,26 +63,22 @@ 587CD0EE2B27713700E3CB71 /* TaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskButton.swift; sourceTree = ""; }; 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsViewModel.swift; sourceTree = ""; }; - 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleDownloadRouter.swift; sourceTree = ""; }; 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProgressViewModel.swift; sourceTree = ""; }; 58D6976E2B21FF8300E6C529 /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = ""; }; 58D697702B21FF8E00E6C529 /* UsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersViewModel.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 = ""; }; + 58F0D3302B346A6400CC2581 /* TaskProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskProgressView.swift; sourceTree = ""; }; + 58F0D3322B346ACE00CC2581 /* URLSessionTask.State+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionTask.State+Convenience.swift"; sourceTree = ""; }; + 58F0D3342B346C1F00CC2581 /* UploadAPIManager+SharedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UploadAPIManager+SharedInstance.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 = ""; }; + B52674C22A370E35006D3B9C /* UploadProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadProgressViewModel.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 = ""; }; 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 = ""; }; @@ -186,8 +178,6 @@ isa = PBXGroup; children = ( DDD3AD1E2950E794006CB777 /* SampleAuthRouter.swift */, - 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */, - B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */, 23EA9CE9292FB70A00B8E418 /* SampleUserRouter.swift */, ); path = Routers; @@ -217,6 +207,7 @@ isa = PBXGroup; children = ( 587CD0EE2B27713700E3CB71 /* TaskButton.swift */, + 58F0D3302B346A6400CC2581 /* TaskProgressView.swift */, ); path = UIElements; sourceTree = ""; @@ -227,7 +218,6 @@ 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */, 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */, 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */, - DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */, ); path = Download; sourceTree = ""; @@ -262,13 +252,9 @@ B52674BB2A370D0D006D3B9C /* Upload */ = { isa = PBXGroup; children = ( - B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */, - B52674BE2A370D33006D3B9C /* UploadItem.swift */, - B52674C62A371046006D3B9C /* UploadItemView.swift */, - B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */, - B52674BC2A370D1D006D3B9C /* UploadService.swift */, - B52674C42A37102D006D3B9C /* UploadsView.swift */, B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */, + B52674C42A37102D006D3B9C /* UploadsView.swift */, + B52674C22A370E35006D3B9C /* UploadProgressViewModel.swift */, ); path = Upload; sourceTree = ""; @@ -277,7 +263,9 @@ isa = PBXGroup; children = ( DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */, + 58F0D3342B346C1F00CC2581 /* UploadAPIManager+SharedInstance.swift */, B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */, + 58F0D3322B346ACE00CC2581 /* URLSessionTask.State+Convenience.swift */, ); path = Extensions; sourceTree = ""; @@ -365,36 +353,32 @@ 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 */, B58162F72A4F23420074A115 /* ByteCountFormatter+Convenience.swift in Sources */, 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */, - B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */, + 58F0D3332B346ACE00CC2581 /* URLSessionTask.State+Convenience.swift in Sources */, 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */, 23EA9CF9292FB70A00B8E418 /* User.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 */, + 58F0D3352B346C1F00CC2581 /* UploadAPIManager+SharedInstance.swift in Sources */, 23EA9CF4292FB70A00B8E418 /* SampleUserRouter.swift in Sources */, - B52674BF2A370D33006D3B9C /* UploadItem.swift in Sources */, 58D6976F2B21FF8300E6C529 /* UsersView.swift in Sources */, 23EA9CF5292FB70A00B8E418 /* SampleAPIConstants.swift in Sources */, 58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */, DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */, - B52674BA2A370C15006D3B9C /* SampleUploadRouter.swift in Sources */, - 58D697712B21FF8E00E6C529 /* UsersViewModel.swift in Sources */, - B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */, 58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */, 58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */, + B52674C32A370E35006D3B9C /* UploadProgressViewModel.swift in Sources */, + 58D697712B21FF8E00E6C529 /* UsersViewModel.swift in Sources */, 587CD0EC2B271CF800E3CB71 /* SampleCreateUserResponse.swift in Sources */, - B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */, B52674C12A370DFF006D3B9C /* UploadsViewModel.swift in Sources */, + 58F0D3312B346A6400CC2581 /* TaskProgressView.swift in Sources */, B52674C52A37102D006D3B9C /* UploadsView.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 deleted file mode 100644 index b1952089..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleDownloadRouter.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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): - url - } - } - - var path: String { - switch self { - case .download: - "" - } - } -} diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift deleted file mode 100644 index e2593cf7..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// SampleUploadRouter.swift -// NetworkingSampleApp -// -// Created by Tony Ngo on 12.06.2023. -// - -import Foundation -import Networking - -enum SampleUploadRouter: Requestable { - case image - case file(URL) - case multipart(boundary: String) - - var baseURL: URL { - // swiftlint:disable:next force_unwrapping - URL(string: SampleAPIConstants.uploadHost)! - } - - var headers: [String: String]? { - switch self { - case .image: - ["Content-Type": "image/png"] - case let .file(url): - ["Content-Type": url.mimeType] - case let .multipart(boundary): - ["Content-Type": "multipart/form-data; boundary=\(boundary)"] - } - } - - var path: String { - "/post" - } - - var method: HTTPMethod { - .post - } -} diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift index 9c7efc2f..30345c2c 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAPIConstants.swift @@ -11,8 +11,8 @@ import Foundation enum SampleAPIConstants { static let userHost = "https://reqres.in/api" static let authHost = "https://nonexistentmockauth.com/api" - // swiftlint:disable:next force_https - static let uploadHost = "https://httpbin.org" + // swiftlint:disable:next force_unwrapping force_https + static let uploadURL = URL(string: "https://httpbin.org/post")! static let validEmail = "eve.holt@reqres.in" static let validPassword = "cityslicka" static let videoUrl = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/URLSessionTask.State+Convenience.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/URLSessionTask.State+Convenience.swift new file mode 100644 index 00000000..c225bff8 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/URLSessionTask.State+Convenience.swift @@ -0,0 +1,20 @@ +// +// URLSessionTask.State+Convenience.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 21.12.2023. +// + +import Foundation + +extension URLSessionTask.State { + var title: String { + switch self { + case .canceling: "cancelling" + case .completed: "completed" + case .running: "running" + case .suspended: "suspended" + @unknown default: "" + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/UploadAPIManager+SharedInstance.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/UploadAPIManager+SharedInstance.swift new file mode 100644 index 00000000..8d7a8608 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/UploadAPIManager+SharedInstance.swift @@ -0,0 +1,32 @@ +// +// UploadAPIManager+SharedInstance.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 21.12.2023. +// + +import Networking + +extension UploadAPIManager { + static var shared: UploadAPIManaging = { + var responseProcessors: [ResponseProcessing] = [ + LoggingInterceptor.shared, + StatusCodeProcessor.shared + ] + var errorProcessors: [ErrorProcessing] = [LoggingInterceptor.shared] + + #if DEBUG + responseProcessors.append(EndpointRequestStorageProcessor.shared) + errorProcessors.append(EndpointRequestStorageProcessor.shared) + #endif + + return UploadAPIManager( + urlSessionConfiguration: .default, + requestAdapters: [ + LoggingInterceptor.shared + ], + responseProcessors: responseProcessors, + errorProcessors: errorProcessors + ) + }() +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift deleted file mode 100644 index d91ae67b..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressView.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// DownloadProgressView.swift -// NetworkingSampleApp -// -// Created by Matej Molnár on 07.03.2023. -// - -import SwiftUI - -struct DownloadProgressView: View { - @StateObject var viewModel: DownloadProgressViewModel - - var body: some View { - content - .task { - await viewModel.startObservingDownloadProgress() - } - } -} - -// MARK: Components -private extension DownloadProgressView { - @ViewBuilder - var content: some View { - VStack(alignment: .leading, spacing: 8) { - Text(viewModel.state.title) - .truncationMode(.middle) - .lineLimit(1) - .padding(.bottom, 8) - - Group { - if let errorTitle = viewModel.state.errorTitle { - Text("Error: \(errorTitle)") - } else { - Text("Status: \(viewModel.state.statusTitle)") - } - - if let fileURL = viewModel.state.fileURL { - Text("FileURL: \(fileURL)") - } - - HStack { - ProgressView(value: viewModel.state.percentCompleted, total: 100) - .progressViewStyle(.linear) - .frame(width: 150) - - Text(viewModel.state.downloadedBytes) - .font(.footnote) - .foregroundColor(.gray) - - Spacer() - - TaskButton(config: viewModel.state.status == .suspended ? .play : .pause) { - viewModel.state.status == .suspended ? viewModel.resume() : viewModel.suspend() - } - - TaskButton(config: .cancel) { - viewModel.cancel() - } - } - } - .font(.footnote) - .foregroundColor(.gray) - } - } -} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift index 9349e11b..b171851b 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift @@ -9,28 +9,41 @@ import Foundation import Networking @MainActor -final class DownloadProgressViewModel: ObservableObject { +final class DownloadProgressViewModel: TaskProgressViewModel { private let task: URLSessionTask - @Published var state: DownloadProgressState = .init() - + let isRetryable = false + private(set) var title: String = "" + private(set) var status: String = "" + private(set) var downloadedBytes: String = "" + private(set) var state: URLSessionTask.State = .running + private(set) var percentCompleted: Double = 0 + init(task: URLSessionTask) { self.task = task } - func startObservingDownloadProgress() async { + func onAppear() async { let stream = DownloadAPIManager.shared.progressStream(for: task) for try await downloadState in stream { - var newState = DownloadProgressState() - newState.percentCompleted = downloadState.fractionCompleted * 100 - newState.downloadedBytes = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: downloadState.downloadedBytes) - 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 + title = task.currentRequest?.url?.absoluteString ?? "-" + percentCompleted = downloadState.fractionCompleted * 100 + downloadedBytes = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: downloadState.downloadedBytes) + state = downloadState.taskState + status = { + if let error = downloadState.error { + return "Error: \(error.localizedDescription)" + } + + if let downloadedFileURL = downloadState.downloadedFileURL { + return "Downloaded at: \(downloadedFileURL.absoluteString)" + } + + return downloadState.taskState.title + }() + + objectWillChange.send() } } @@ -45,28 +58,6 @@ final class DownloadProgressViewModel: ObservableObject { 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 downloadedBytes: String = "" - var errorTitle: String? - var fileURL: String? -} -// MARK: URLSessionTask states -private extension URLSessionTask.State { - var title: String { - switch self { - case .canceling: "cancelling" - case .completed: "completed" - case .running: "running" - case .suspended: "suspended" - @unknown default: "" - } - } + func retry() {} } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift index 81cfeb79..603742c3 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsView.swift @@ -33,7 +33,7 @@ struct DownloadsView: View { Section("Active downloads") { List { ForEach(viewModel.tasks, id: \.taskIdentifier) { task in - DownloadProgressView(viewModel: .init(task: task)) + TaskProgressView(viewModel: DownloadProgressViewModel(task: task)) } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift index ddfd3fab..1811a740 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -36,7 +36,7 @@ private extension DownloadsViewModel { do { let (task, _) = try await downloadAPIManager.downloadRequest( - SampleDownloadRouter.download(url: url), + url, resumableData: nil, retryConfiguration: RetryConfiguration.default ) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift deleted file mode 100644 index ebf78050..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// FormUploadsViewModel.swift -// NetworkingSampleApp -// -// Created by Tony Ngo on 19.06.2023. -// - -import Foundation -import Networking -import OSLog - -@MainActor -final class FormUploadsViewModel: ObservableObject { - @Published var username = "" - @Published var fileUrl: URL? - @Published var isErrorAlertPresented = false - @Published private(set) var error: Error? - @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] - - var selectedFileName: String { - let fileSize = Int64(fileUrl?.fileSize ?? 0) - var fileName = fileUrl?.lastPathComponent ?? "" - let formattedFileSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: fileSize) - if fileSize > 0 { fileName += "\n\(formattedFileSize)" } - return fileName - } - - private let uploadService: UploadService - - init(uploadService: UploadService = .init()) { - self.uploadService = uploadService - } -} - -extension FormUploadsViewModel { - func uploadForm() { - Task { - do { - let multipartFormData = try createMultipartFormData() - let uploadItem = try await uploadService.uploadFormData(multipartFormData) - - uploadItemViewModels.append(UploadItemViewModel( - item: uploadItem, - uploadService: uploadService - )) - - username = "" - fileUrl = nil - } catch { - os_log("❌ FormUploadsViewModel failed to upload form with error: \(error.localizedDescription)") - self.error = error - self.isErrorAlertPresented = true - } - } - } -} - -// 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/UploadItem.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItem.swift deleted file mode 100644 index aa329893..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItem.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// 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 deleted file mode 100644 index e6042871..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// 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.stateTitle) - .font(.footnote) - .foregroundColor(.gray) - } - - Spacer() - - if !viewModel.isCancelled && !viewModel.isRetryable && !viewModel.isCompleted { - HStack { - TaskButton(config: viewModel.isPaused ? .play : .pause) { - viewModel.isPaused ? viewModel.resume() : viewModel.pause() - } - - TaskButton(config: .cancel) { - viewModel.cancel() - } - } - } else if viewModel.isRetryable { - TaskButton(config: .retry) { - viewModel.retry() - } - } - } - - if !viewModel.isCancelled && !viewModel.isRetryable { - 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 deleted file mode 100644 index c47e14e1..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// 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 - - var stateTitle: String { - isCancelled - ? "Cancelled" - : isCompleted ? "Completed" : formattedProgress - } - - var isCompleted: Bool { progress == 100 } - - 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 || state.error != nil - } - } - - 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 - } - } - - func retry() { - Task { - try await uploadService.retry(item) - await observeProgress() - } - } -} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadProgressViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadProgressViewModel.swift new file mode 100644 index 00000000..b9c4bb33 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadProgressViewModel.swift @@ -0,0 +1,68 @@ +// +// UploadItemViewModel.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 12.06.2023. +// + +import Foundation +import Networking + +@MainActor +final class UploadProgressViewModel: TaskProgressViewModel { + private let task: UploadTask + private let uploadManager = UploadAPIManager.shared + + let isRetryable = true + + private(set) var title: String = "" + private(set) var status: String = "" + private(set) var downloadedBytes: String = "" + private(set) var state: URLSessionTask.State = .running + private(set) var percentCompleted: Double = 0 + + init(task: UploadTask) { + self.task = task + } + + func onAppear() async { + await observeProgress() + } + + func observeProgress() async { + for await uploadState in await uploadManager.stateStream(for: task.id) { + title = task.id + percentCompleted = uploadState.fractionCompleted * 100 + downloadedBytes = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: uploadState.sentBytes) + state = uploadState.taskState + status = { + if let error = uploadState.error { + return "Error: \(error.localizedDescription)" + } + + return uploadState.taskState.title + }() + + objectWillChange.send() + } + } + + func suspend() { + task.pause() + } + + func resume() { + task.resume() + } + + func cancel() { + task.cancel() + } + + func retry() { + Task { + try await uploadManager.retry(taskId: task.id) + await observeProgress() + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift deleted file mode 100644 index 9d745649..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// 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 = UploadAPIManager()) { - 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 uploadFormData(_ data: MultipartFormData) async throws -> UploadItem { - let task = try await uploadManager.upload( - multipartFormData: data, - to: SampleUploadRouter.multipart(boundary: data.boundary) - ) - - let dataSize = Int64(data.size) - let formattedDataSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: dataSize) - - return UploadItem( - id: task.id, - fileName: "Form upload of size \(formattedDataSize)" - ) - } - - 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() - } - - func retry(_ uploadItem: UploadItem) async throws { - try await uploadManager.retry( - taskId: uploadItem.id - ) - } -} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index fb7ae33e..46d4ff9c 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -10,7 +10,6 @@ import SwiftUI struct UploadsView: View { @StateObject var viewModel = UploadsViewModel() - @StateObject var formViewModel = FormUploadsViewModel() @State var isPhotosPickerPresented = false @State var isFileImporterPresented = false @@ -20,26 +19,13 @@ struct UploadsView: View { var body: some View { Form { singleUpload - - 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) - } - } - } - } - multipartUpload - if !formViewModel.uploadItemViewModels.isEmpty { - Section("Multi part upload progress") { + if !viewModel.uploadTasks.isEmpty { + Section("Active Uploads") { VStack { - ForEach(formViewModel.uploadItemViewModels.indices, id: \.self) { index in - let viewModel = formViewModel.uploadItemViewModels[index] - UploadItemView(viewModel: viewModel) + ForEach(viewModel.uploadTasks, id: \.id) { task in + TaskProgressView(viewModel: UploadProgressViewModel(task: task)) } } } @@ -53,14 +39,9 @@ struct UploadsView: View { Text(viewModel.error?.localizedDescription ?? "") } ) - .alert( - "Error", - isPresented: $formViewModel.isErrorAlertPresented, - actions: {}, - message: { - Text(formViewModel.error?.localizedDescription ?? "") - } - ) + .task { + await viewModel.loadTasks() + } .navigationTitle("Uploads") } } @@ -93,27 +74,27 @@ private extension UploadsView { var multipartUpload: some View { Section( content: { - TextField("Enter username", text: $formViewModel.username) + TextField("Enter username", text: $viewModel.formUsername) HStack { - if formViewModel.fileUrl == nil { + if viewModel.formFileUrl == nil { Button("Add attachment") { isFormFileImporterPresented = true } .fileImporter( isPresented: $isFormFileImporterPresented, - allowedContentTypes: [.mp3, .mpeg4Movie] + allowedContentTypes: [.mp3, .mpeg4Movie, .jpeg] ) { result in - formViewModel.fileUrl = try? result.get() + viewModel.formFileUrl = try? result.get() } } - Text(formViewModel.selectedFileName) + Text(viewModel.formSelectedFileName) Spacer() - if formViewModel.fileUrl != nil { + if viewModel.formFileUrl != nil { Button( - action: { formViewModel.fileUrl = nil }, + action: { viewModel.formFileUrl = nil }, label: { Image(systemName: "x") .symbolVariant(.circle.fill) @@ -132,7 +113,7 @@ private extension UploadsView { }, footer: { Button("Upload") { - formViewModel.uploadForm() + viewModel.uploadForm() } .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index a07f2485..63e7d4dd 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -6,34 +6,39 @@ // import Foundation +import Networking import OSLog @MainActor final class UploadsViewModel: ObservableObject { - @Published var isErrorAlertPresented = false + @Published var formUsername = "" + @Published var formFileUrl: URL? + @Published private(set) var uploadTasks: [UploadTask] = [] @Published private(set) var error: Error? - @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] - - private let uploadService: UploadService + @Published var isErrorAlertPresented = false - init(uploadService: UploadService = UploadService()) { - self.uploadService = uploadService + var formSelectedFileName: String { + let fileSize = Int64(formFileUrl?.fileSize ?? 0) + var fileName = formFileUrl?.lastPathComponent ?? "" + let formattedFileSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: fileSize) + if fileSize > 0 { fileName += "\n\(formattedFileSize)" } + return fileName } + + private let uploadManager = UploadAPIManager.shared } extension UploadsViewModel { + func loadTasks() async { + uploadTasks = await uploadManager.activeTasks + } + 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 - )) + let uploadTask = try await uploadManager.upload(.data(imageData, contentType: "image/png"), to: SampleAPIConstants.uploadURL) + uploadTasks.append(uploadTask) } } catch { os_log("❌ UploadsViewModel failed to upload with error: \(error.localizedDescription)") @@ -47,11 +52,8 @@ extension UploadsViewModel { Task { do { let fileUrl = try result.get() - let uploadItem = try await uploadService.uploadFile(fileUrl) - uploadItemViewModels.append(UploadItemViewModel( - item: uploadItem, - uploadService: uploadService - )) + let uploadTask = try await uploadManager.upload(.file(fileUrl), to: SampleAPIConstants.uploadURL) + uploadTasks.append(uploadTask) } catch { os_log("❌ UploadsViewModel failed to upload with error: \(error.localizedDescription)") self.error = error @@ -59,4 +61,36 @@ extension UploadsViewModel { } } } + + func uploadForm() { + Task { + do { + let multipartFormData = try createMultipartFormData() + let uploadTask = try await uploadManager.upload( + .multipart(data: multipartFormData, sizeThreshold: 10_000_000), + to: SampleAPIConstants.uploadURL + ) + uploadTasks.append(uploadTask) + + formUsername = "" + formFileUrl = nil + } catch { + os_log("❌ FormUploadsViewModel failed to upload form with error: \(error.localizedDescription)") + self.error = error + self.isErrorAlertPresented = true + } + } + } +} + +// MARK: - Prepare multipartForm data +private extension UploadsViewModel { + func createMultipartFormData() throws -> MultipartFormData { + let multipartFormData = MultipartFormData() + multipartFormData.append(Data(formUsername.utf8), name: "username-textfield") + if let formFileUrl { + try multipartFormData.append(from: formFileUrl, name: "attachment") + } + return multipartFormData + } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/UIElements/TaskProgressView.swift b/NetworkingSampleApp/NetworkingSampleApp/UIElements/TaskProgressView.swift new file mode 100644 index 00000000..21da5f86 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/UIElements/TaskProgressView.swift @@ -0,0 +1,70 @@ +// +// TaskProgressView.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 21.12.2023. +// + +import SwiftUI + +@MainActor +protocol TaskProgressViewModel: ObservableObject { + var title: String { get } + var status: String { get } + var downloadedBytes: String { get } + var state: URLSessionTask.State { get } + var percentCompleted: Double { get } + var isRetryable: Bool { get } + + func suspend() + func resume() + func cancel() + func retry() + func onAppear() async +} + +struct TaskProgressView: View { + @StateObject var viewModel: ViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.title) + .truncationMode(.middle) + .lineLimit(1) + .padding(.bottom, 8) + + Text(viewModel.status) + .font(.footnote) + .foregroundColor(.gray) + + HStack { + ProgressView(value: viewModel.percentCompleted, total: 100) + .progressViewStyle(.linear) + .frame(width: 150) + + Text(viewModel.downloadedBytes) + .font(.footnote) + .foregroundColor(.gray) + + Spacer() + + if viewModel.state != .completed { + TaskButton(config: viewModel.state == .suspended ? .play : .pause) { + viewModel.state == .suspended ? viewModel.resume() : viewModel.suspend() + } + + TaskButton(config: .cancel) { + viewModel.cancel() + } + } else if viewModel.isRetryable { + TaskButton(config: .retry) { + viewModel.retry() + } + } + } + } + .task { + await viewModel.onAppear() + } + } +} diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..63931882 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,86 @@ +{ + "pins" : [ + { + "identity" : "collectionconcurrencykit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", + "state" : { + "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", + "version" : "0.2.0" + } + }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "32f641cf24fc7abc1c591a2025e9f2f572648b0f", + "version" : "1.7.2" + } + }, + { + "identity" : "sourcekitten", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/SourceKitten.git", + "state" : { + "revision" : "b6dc09ee51dfb0c66e042d2328c017483a1a5d56", + "version" : "0.34.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", + "version" : "1.2.3" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", + "version" : "509.0.0" + } + }, + { + "identity" : "swiftlint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/SwiftLint.git", + "state" : { + "revision" : "6d2e58271ebc14c37bf76d7c9f4082cc15bad718", + "version" : "0.53.0" + } + }, + { + "identity" : "swiftytexttable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", + "state" : { + "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", + "version" : "0.9.0" + } + }, + { + "identity" : "swxmlhash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/drmohundro/SWXMLHash.git", + "state" : { + "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version" : "7.0.2" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", + "version" : "5.0.6" + } + } + ], + "version" : 2 +} diff --git a/Sources/Networking/Core/Download/BasicDownloadRouter.swift b/Sources/Networking/Core/Download/BasicDownloadRouter.swift new file mode 100644 index 00000000..f9d44702 --- /dev/null +++ b/Sources/Networking/Core/Download/BasicDownloadRouter.swift @@ -0,0 +1,25 @@ +// +// BasicDownloadRouter.swift +// +// +// Created by Matej Molnár on 08.01.2024. +// + +import Foundation + +/// A Router used for basic use case of downloading a file from a URL. +struct BasicDownloadRouter: Requestable { + private let fileURL: URL + + init(fileURL: URL) { + self.fileURL = fileURL + } + + var baseURL: URL { + fileURL + } + + var path: String { + "" + } +} diff --git a/Sources/Networking/Core/DownloadAPIManager.swift b/Sources/Networking/Core/Download/DownloadAPIManager.swift similarity index 100% rename from Sources/Networking/Core/DownloadAPIManager.swift rename to Sources/Networking/Core/Download/DownloadAPIManager.swift diff --git a/Sources/Networking/Core/DownloadAPIManaging.swift b/Sources/Networking/Core/Download/DownloadAPIManaging.swift similarity index 66% rename from Sources/Networking/Core/DownloadAPIManaging.swift rename to Sources/Networking/Core/Download/DownloadAPIManaging.swift index 5b0fb33c..ed390dc2 100644 --- a/Sources/Networking/Core/DownloadAPIManaging.swift +++ b/Sources/Networking/Core/Download/DownloadAPIManaging.swift @@ -21,7 +21,7 @@ public protocol DownloadAPIManaging { /// - 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. @@ -41,13 +41,35 @@ public protocol DownloadAPIManaging { func progressStream(for task: URLSessionTask) -> AsyncStream } -// MARK: - Provide request with default nil resumable data, retry configuration public extension DownloadAPIManaging { + /// Initiates a download request for a given fileURL, with optional resumable data and retry configuration. + /// - Parameters: + /// - fileURL: A URL of a file which will be downloaded. + /// - 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( + _ fileURL: URL, + resumableData: Data? = nil, + retryConfiguration: RetryConfiguration? = .default + ) async throws -> DownloadResult { + try await downloadRequest( + BasicDownloadRouter(fileURL: fileURL), + resumableData: resumableData, + retryConfiguration: retryConfiguration + ) + } + + // Provide request with default nil resumable data, retry configuration func downloadRequest( _ endpoint: Requestable, resumableData: Data? = nil, retryConfiguration: RetryConfiguration? = .default ) async throws -> DownloadResult { - try await downloadRequest(endpoint, resumableData: resumableData, retryConfiguration: retryConfiguration) + try await downloadRequest( + endpoint, + resumableData: resumableData, + retryConfiguration: retryConfiguration + ) } } diff --git a/Sources/Networking/Core/Upload/BasicUploadRouter.swift b/Sources/Networking/Core/Upload/BasicUploadRouter.swift new file mode 100644 index 00000000..ae108c00 --- /dev/null +++ b/Sources/Networking/Core/Upload/BasicUploadRouter.swift @@ -0,0 +1,37 @@ +// +// BasicUploadRouter.swift +// +// +// Created by Matej Molnár on 08.01.2024. +// + +import Foundation + +/// A Router for basic use case of uploading file/data/multiPartForm to a given URL. +struct BasicUploadRouter: Requestable { + let url: URL + let uploadType: UploadType + + var baseURL: URL { + url + } + + var headers: [String: String]? { + switch uploadType { + case let .data(_, contentType): + ["Content-Type": contentType] + case let .file(url): + ["Content-Type": url.mimeType] + case let .multipart( data, _): + ["Content-Type": "multipart/form-data; boundary=\(data.boundary)"] + } + } + + var path: String { + "" + } + + var method: HTTPMethod { + .post + } +} diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index a553845a..134f4f74 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -135,61 +135,48 @@ extension UploadAPIManager: URLSessionTaskDelegate { // MARK: - UploadAPIManaging @available(iOS 15.0, *) 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 { + public func upload(_ type: UploadType, to endpoint: Requestable) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) - return try await uploadRequest( - .file(fileUrl), - request: endpointRequest - ) - } - public func upload( - multipartFormData: MultipartFormData, - sizeThreshold: UInt64 = 10_000_000, - to endpoint: Requestable - ) 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 && !usesBackgroundSession { - let encodedMultipartFormData = try multipartFormDataEncoder.encode(multipartFormData) + switch type { + case let .data(data, _): return try await uploadRequest( - .data(encodedMultipartFormData), + .data(data), request: endpointRequest ) - } else { - let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) - try multipartFormDataEncoder.encode(multipartFormData, to: temporaryFileUrl) + case let .file(fileUrl): return try await uploadRequest( - .file(temporaryFileUrl, removeOnComplete: true), + .file(fileUrl), request: endpointRequest ) + case let .multipart(multipartFormData, sizeThreshold): + // 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 && !usesBackgroundSession { + let encodedMultipartFormData = try multipartFormDataEncoder.encode(multipartFormData) + return try await uploadRequest( + .data(encodedMultipartFormData), + 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 + ) + } + } + } + + public func invalidateSession(shouldFinishTasks: Bool) { + if shouldFinishTasks { + urlSession.finishTasksAndInvalidate() + } else { + urlSession.invalidateAndCancel() } } diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 2fe2c0a3..5d21fa0f 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -17,39 +17,11 @@ public protocol UploadAPIManaging { /// Initiates a data upload request for the specified endpoint. /// - Parameters: - /// - data: The data to send to the server. + /// - type: 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 - - /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. - /// - /// 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: - /// - 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. - /// - /// - Returns: An `UploadTask` that represents this request. - func upload( - multipartFormData: MultipartFormData, - sizeThreshold: UInt64, + _ type: UploadType, to endpoint: Requestable ) async throws -> UploadTask @@ -75,27 +47,15 @@ public protocol UploadAPIManaging { @available(iOS 15.0, *) 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:)``. - /// + /// Initiates a data upload request for the specified endpoint. /// - Parameters: - /// - multipartFormData: The multipart form data to upload. - /// - endpoint: The API endpoint to where data will be sent. - /// + /// - type: The data to send to the server. + /// - uploadURL: The URL where data will be sent. /// - Returns: An `UploadTask` that represents this request. - func upload( - multipartFormData: MultipartFormData, - to endpoint: Requestable - ) async throws -> UploadTask { - try await upload( - multipartFormData: multipartFormData, - sizeThreshold: 10_000_000, - to: endpoint - ) + func upload(_ type: UploadType, to uploadURL: URL) async throws -> UploadTask { + try await upload(type, to: BasicUploadRouter(url: uploadURL, uploadType: type)) } - + /// Returns an active ``UploadTask`` specified by its identifier. func task(with id: UploadTask.ID) async -> UploadTask? { await activeTasks.first { $0.id == id } diff --git a/Sources/Networking/Core/Upload/UploadTask+State.swift b/Sources/Networking/Core/Upload/UploadTask+State.swift index 8b5bd2e5..814d6ec7 100644 --- a/Sources/Networking/Core/Upload/UploadTask+State.swift +++ b/Sources/Networking/Core/Upload/UploadTask+State.swift @@ -23,7 +23,7 @@ public extension UploadTask { public var response: Response? /// The internal state of the `URLSessionTask`. - let taskState: URLSessionTask.State + public let taskState: URLSessionTask.State } } diff --git a/Sources/Networking/Core/Upload/UploadType.swift b/Sources/Networking/Core/Upload/UploadType.swift new file mode 100644 index 00000000..e8082a24 --- /dev/null +++ b/Sources/Networking/Core/Upload/UploadType.swift @@ -0,0 +1,20 @@ +// +// UploadType.swift +// +// +// Created by Matej Molnár on 20.12.2023. +// + +import Foundation + +/// A type which represents data that can be uploaded. +public enum UploadType { + /// - data: The data to send to the server. + /// - contentType: Content type which should be set as a header in the upload request. + case data(Data, contentType: String) + /// The URL of a file which should be sent to the server. + case file(URL) + /// - data: 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. + case multipart(data: MultipartFormData, sizeThreshold: UInt64) +}