diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e364fe7c..385acfaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,18 +23,14 @@ jobs: fail-fast: false matrix: include: - - name: 'iOS 17.2' - destination: 'OS=17.2,name=iPhone 15 Pro' - xcode: 'Xcode_15.2' - runsOn: macos-14 -# - name: 'iOS 16.4' -# destination: 'OS=16.4,name=iPhone 14 Pro' -# xcode: 'Xcode_14.3.1' -# runsOn: macos-13 - - name: 'macOS 13, Xcode 15.2' + - name: 'iOS 18.2, Xcode 16.2' + destination: 'OS=18.2,name=iPhone 16 Pro' + xcode: 'Xcode_16.2' + runsOn: macos-15 + - name: 'macOS 15, Xcode 16.2' destination: 'platform=macOS' - xcode: 'Xcode_15.2' - runsOn: macos-14 + xcode: 'Xcode_16.2' + runsOn: macos-15 steps: - uses: actions/checkout@v4 @@ -43,11 +39,13 @@ jobs: set -o pipefail && \ xcodebuild clean test -resultBundlePath "TestResults-${{ matrix.name }}" -skipPackagePluginValidation -scheme "Networking" -destination "${{ matrix.destination }}" | tee "build-log-${{ matrix.name }}.txt" | xcpretty - - uses: kishikawakatsumi/xcresulttool@v1 - with: - path: 'TestResults-${{ matrix.name }}.xcresult' - title: '${{ matrix.name }} Test Results' - if: success() || failure() + # Missing Xcode 16 support + # https://github.com/kishikawakatsumi/xcresulttool/issues/765 + # - uses: kishikawakatsumi/xcresulttool@v1 + # with: + # path: 'TestResults-${{ matrix.name }}.xcresult' + # title: '${{ matrix.name }} Test Results' + # if: success() || failure() - name: 'Upload Build Log' uses: actions/upload-artifact@v4 diff --git a/.swiftlint.yml b/.swiftlint.yml index e459d116..6c3a8041 100755 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -11,6 +11,7 @@ analyzer_rules: # Rule identifiers to exclude from running. # disabled_rules: + - opening_brace # # Some rules are only opt-in. Find all the available rules by running: swiftlint rules diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme index 5f948cd1..6708f04d 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme @@ -1,6 +1,6 @@ Error { guard let networkError = error as? NetworkError, case let .unacceptableStatusCode(statusCode, _, response) = networkError, diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift index f9bde355..032c3f06 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift @@ -8,6 +8,7 @@ import Foundation extension ByteCountFormatter { + @MainActor static let megaBytesFormatter: ByteCountFormatter = { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useMB] diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift index 0364dacb..072d5563 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/DownloadAPIManager+SharedInstance.swift @@ -8,7 +8,7 @@ import Networking extension DownloadAPIManager { - static var shared: DownloadAPIManaging = { + static let shared: DownloadAPIManager = { var responseProcessors: [ResponseProcessing] = [ LoggingInterceptor.shared, StatusCodeProcessor.shared diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationView.swift index 4852b234..681172e6 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationView.swift @@ -23,12 +23,10 @@ struct AuthorizationView: View { private extension AuthorizationView { var loginButton: some View { Button { - Task { - try await viewModel.login( - email: SampleAPIConstants.validEmail, - password: SampleAPIConstants.validPassword - ) - } + viewModel.login( + email: SampleAPIConstants.validEmail, + password: SampleAPIConstants.validPassword + ) } label: { Text("Login") } @@ -36,9 +34,7 @@ private extension AuthorizationView { var getStatusButton: some View { Button { - Task { - try await viewModel.checkAuthorizationStatus() - } + viewModel.checkAuthorizationStatus() } label: { Text("Get Status") } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationViewModel.swift index 37f4134d..91caf16e 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationViewModel.swift @@ -8,8 +8,11 @@ import Foundation import Networking +@MainActor final class AuthorizationViewModel: ObservableObject { + @NetworkingActor private lazy var authManager = SampleAuthorizationManager() + @NetworkingActor private lazy var apiManager: APIManager = { let authorizationInterceptor = AuthorizationTokenInterceptor(authorizationManager: authManager) @@ -38,20 +41,24 @@ final class AuthorizationViewModel: ObservableObject { } extension AuthorizationViewModel { - func login(email: String?, password: String?) async throws { - let request = SampleUserAuthRequest(email: email, password: password) - let response: SampleUserAuthResponse = try await apiManager.request( - SampleAuthRouter.loginUser(request) - ) - - let data = response.authData - // Save login token data to auth storage. - try await authManager.storage.saveData(data) + func login(email: String?, password: String?) { + Task { + let request = SampleUserAuthRequest(email: email, password: password) + let response: SampleUserAuthResponse = try await apiManager.request( + SampleAuthRouter.loginUser(request) + ) + + let data = response.authData + // Save login token data to auth storage. + try await authManager.storage.saveData(data) + } } - func checkAuthorizationStatus() async throws { - try await apiManager.request( - SampleAuthRouter.status - ) + func checkAuthorizationStatus() { + Task { + try await apiManager.request( + SampleAuthRouter.status + ) + } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift index b171851b..a9f3fd78 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift @@ -24,7 +24,7 @@ final class DownloadProgressViewModel: TaskProgressViewModel { } func onAppear() async { - let stream = DownloadAPIManager.shared.progressStream(for: task) + let stream = await DownloadAPIManager.shared.progressStream(for: task) for try await downloadState in stream { title = task.currentRequest?.url?.absoluteString ?? "-" diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift index 1811a740..eb0b82a4 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadsViewModel.swift @@ -13,7 +13,9 @@ import OSLog final class DownloadsViewModel: ObservableObject { @Published var tasks: [URLSessionTask] = [] @Published var urlText: String = SampleAPIConstants.videoUrl - private let downloadAPIManager = DownloadAPIManager.shared + + @NetworkingActor + private lazy var downloadAPIManager = DownloadAPIManager.shared func startDownload() { Task { diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadProgressViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadProgressViewModel.swift index b9c4bb33..011dcd2b 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadProgressViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadProgressViewModel.swift @@ -11,7 +11,9 @@ import Networking @MainActor final class UploadProgressViewModel: TaskProgressViewModel { private let task: UploadTask - private let uploadManager = UploadAPIManager.shared + + @NetworkingActor + private lazy var uploadManager = UploadAPIManager.shared let isRetryable = true diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index 46d4ff9c..329bf4e4 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -57,7 +57,9 @@ private extension UploadsView { ) .onChange(of: selectedPhotoPickerItem) { photo in photo?.loadTransferable(type: Data.self) { result in - viewModel.uploadImage(result: result) + Task { + await viewModel.uploadImage(result: result) + } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index 63e7d4dd..4809e10c 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -17,6 +17,9 @@ final class UploadsViewModel: ObservableObject { @Published private(set) var error: Error? @Published var isErrorAlertPresented = false + @NetworkingActor + private lazy var uploadManager = UploadAPIManager.shared + var formSelectedFileName: String { let fileSize = Int64(formFileUrl?.fileSize ?? 0) var fileName = formFileUrl?.lastPathComponent ?? "" @@ -24,8 +27,6 @@ final class UploadsViewModel: ObservableObject { if fileSize > 0 { fileName += "\n\(formattedFileSize)" } return fileName } - - private let uploadManager = UploadAPIManager.shared } extension UploadsViewModel { @@ -37,7 +38,13 @@ extension UploadsViewModel { Task { do { if let imageData = try result.get() { - let uploadTask = try await uploadManager.upload(.data(imageData, contentType: "image/png"), to: SampleAPIConstants.uploadURL) + let uploadTask = try await uploadManager.upload( + .data( + imageData, + contentType: "image/png" + ), + to: SampleAPIConstants.uploadURL + ) uploadTasks.append(uploadTask) } } catch { @@ -86,7 +93,7 @@ extension UploadsViewModel { // MARK: - Prepare multipartForm data private extension UploadsViewModel { func createMultipartFormData() throws -> MultipartFormData { - let multipartFormData = MultipartFormData() + var multipartFormData = MultipartFormData() multipartFormData.append(Data(formUsername.utf8), name: "username-textfield") if let formFileUrl { try multipartFormData.append(from: formFileUrl, name: "attachment") diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersViewModel.swift index 4d3e020f..66ded15b 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersViewModel.swift @@ -22,6 +22,7 @@ final class UsersViewModel: ObservableObject { return decoder }() + @NetworkingActor private lazy var apiManager: APIManager = { var responseProcessors: [ResponseProcessing] = [ LoggingInterceptor.shared, @@ -57,7 +58,13 @@ extension UsersViewModel { } } - return try await group.reduce(into: [User]()) { $0.append($1) } + var results = [User]() + + for try await value in group { + results.append(value) + } + + return results } } else { // Fetch user add it to users array and wait for 0.5 seconds, before fetching the next one. diff --git a/Package.resolved b/Package.resolved index 87947c99..80cb6a0e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", "state" : { - "revision" : "32f641cf24fc7abc1c591a2025e9f2f572648b0f", - "version" : "1.7.2" + "revision" : "729e01bc9b9dab466ac85f21fb9ee2bc1c61b258", + "version" : "1.8.4" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/SourceKitten.git", "state" : { - "revision" : "b6dc09ee51dfb0c66e042d2328c017483a1a5d56", - "version" : "0.34.1" + "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7", + "version" : "0.35.0" } }, { @@ -41,14 +41,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", - "version" : "1.3.0" + "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", + "version" : "1.4.3" } }, { "identity" : "swift-docc-symbolkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-symbolkit", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", "state" : { "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" @@ -57,10 +57,10 @@ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", - "version" : "509.0.0" + "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", + "version" : "600.0.0" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/SwiftLint.git", "state" : { - "revision" : "6d2e58271ebc14c37bf76d7c9f4082cc15bad718", - "version" : "0.53.0" + "revision" : "eba420f77846e93beb98d516b225abeb2fef4ca2", + "version" : "0.58.2" } }, { diff --git a/Package.swift b/Package.swift index 93dd1044..b8cfc849 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,7 @@ let package = Package( // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "Networking", + swiftSettings: [.enableExperimentalFeature("StrictConcurrency")], plugins: [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] ), .testTarget( diff --git a/Sources/Networking/Core/APIManager.swift b/Sources/Networking/Core/APIManager.swift index 3cd386c9..c4f55eb3 100644 --- a/Sources/Networking/Core/APIManager.swift +++ b/Sources/Networking/Core/APIManager.swift @@ -144,7 +144,7 @@ private extension APIManager { response = try await responseProcessors.process(response, with: request, for: endpointRequest) // reset retry count - await retryCounter.reset(for: endpointRequest.id) + retryCounter.reset(for: endpointRequest.id) return response } catch { diff --git a/Sources/Networking/Core/APIManaging.swift b/Sources/Networking/Core/APIManaging.swift index c47405ba..9d162835 100644 --- a/Sources/Networking/Core/APIManaging.swift +++ b/Sources/Networking/Core/APIManaging.swift @@ -11,7 +11,8 @@ import Foundation // MARK: - Defines API managing /// A definition of an API layer with methods for handling API requests. -public protocol APIManaging { +@NetworkingActor +public protocol APIManaging: Sendable { /// A default `JSONDecoder` used for all requests. var defaultDecoder: JSONDecoder { get } diff --git a/Sources/Networking/Core/Download/DownloadAPIManager.swift b/Sources/Networking/Core/Download/DownloadAPIManager.swift index c92147c9..68afbebe 100644 --- a/Sources/Networking/Core/Download/DownloadAPIManager.swift +++ b/Sources/Networking/Core/Download/DownloadAPIManager.swift @@ -6,7 +6,8 @@ // import Foundation -import Combine +// The @preconcurrency suppresses Sendable warning for AnyCancellables which doesn't yet conform to Sendable. +@preconcurrency import Combine /** Default download API manager which is responsible for the creation and management of network file downloads. @@ -34,10 +35,14 @@ open class DownloadAPIManager: NSObject, Retryable { private let sessionId: String private let downloadStateDictSubject = CurrentValueSubject<[URLSessionTask: URLSessionTask.DownloadState], Never>([:]) private var urlSession = URLSession(configuration: .default) - private var taskStateCancellables = ThreadSafeDictionary() - private var downloadStateDict = ThreadSafeDictionary() - - internal var retryCounter = Counter() + private var taskStateCancellables = [URLSessionTask: AnyCancellable]() + private var downloadStateDict = [URLSessionTask: URLSessionTask.DownloadState]() { + didSet { + downloadStateDictSubject.send(downloadStateDict) + } + } + + let retryCounter = Counter() public var allTasks: [URLSessionDownloadTask] { get async { @@ -91,7 +96,11 @@ extension DownloadAPIManager: DownloadAPIManaging { ) async throws -> DownloadResult { /// create identifiable request from endpoint let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) - return try await downloadRequest(endpointRequest, resumableData: resumableData, retryConfiguration: retryConfiguration) + return try await downloadRequest( + endpointRequest, + resumableData: resumableData, + retryConfiguration: retryConfiguration + ) } /// Creates an async stream of download state updates for a given task. @@ -140,19 +149,17 @@ private extension DownloadAPIManager { 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() - + let urlResponse = try await downloadTask.resumeWithResponse() + /// process response let response = try await responseProcessors.process((Data(), urlResponse), with: request, for: endpointRequest) + await updateTasks() + /// reset retry count - await retryCounter.reset(for: endpointRequest.id) + retryCounter.reset(for: endpointRequest.id) /// create download AsyncStream return (downloadTask, response) @@ -180,47 +187,48 @@ private extension DownloadAPIManager { /// Creates a record in the `downloadStateDict` for each task and observes their states. /// Every `downloadStateDict` update triggers an event to the `downloadStateDictSubject` /// which then leads to a task state update from `progressStream`. - func updateTasks() { - Task { - for task in await allTasks where await downloadStateDict.getValue(for: task) == nil { - /// In case there is no DownloadState for a given task in the dictionary, we need to create one. - await downloadStateDict.set(value: .init(task: task), for: task) - - /// We need to observe URLSessionTask.State via KVO individually for each task, because there is no delegate callback for the state change. - let cancellable = task - .publisher(for: \.state) - .sink { [weak self] state in - guard let self else { - return - } - - Task { - await self.downloadStateDict.update(task: task, for: \.taskState, with: state) - self.downloadStateDictSubject.send(await self.downloadStateDict.getValues()) - - if state == .completed { - await self.taskStateCancellables.set(value: nil, for: task) - } + func updateTasks() async { + 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. + let cancellable = task + .publisher(for: \.state) + .sink { [weak self] state in + guard let self else { + return + } + + Task { + var mutableTask = self.downloadStateDict[task] + mutableTask?.taskState = state + self.downloadStateDict[task] = mutableTask + self.downloadStateDictSubject.send(self.downloadStateDict) + + if state == .completed { + self.taskStateCancellables[task] = nil } } - - await taskStateCancellables.set(value: cancellable, for: task) - } + } + + taskStateCancellables[task] = cancellable } } } // MARK: - URLSession Delegate extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { - public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - Task { - await downloadStateDict.update(task: downloadTask, for: \.downloadedBytes, with: totalBytesWritten) - await downloadStateDict.update(task: downloadTask, for: \.totalBytes, with: totalBytesExpectedToWrite) - downloadStateDictSubject.send(await downloadStateDict.getValues()) + nonisolated public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + Task { @NetworkingActor in + var mutableTask = downloadStateDict[downloadTask] + mutableTask?.downloadedBytes = totalBytesWritten + mutableTask?.totalBytes = totalBytesExpectedToWrite + downloadStateDict[downloadTask] = mutableTask } } - public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + nonisolated public func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { do { guard let response = downloadTask.response else { return @@ -228,23 +236,25 @@ extension DownloadAPIManager: URLSessionDelegate, URLSessionDownloadDelegate { // 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() + Task { @NetworkingActor in + var mutableTask = downloadStateDict[downloadTask] + mutableTask?.downloadedFileURL = tempURL + downloadStateDict[downloadTask] = mutableTask } } catch { - Task { - await downloadStateDict.update(task: downloadTask, for: \.error, with: error) + Task { @NetworkingActor in + var mutableTask = downloadStateDict[downloadTask] + mutableTask?.error = error + downloadStateDict[downloadTask] = mutableTask } } } - public func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - Task { - await downloadStateDict.update(task: task, for: \.error, with: error) - downloadStateDictSubject.send(await downloadStateDict.getValues()) - updateTasks() + nonisolated public func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + Task { @NetworkingActor in + var mutableTask = downloadStateDict[task] + mutableTask?.error = error + downloadStateDict[task] = mutableTask } } } diff --git a/Sources/Networking/Core/Download/DownloadAPIManaging.swift b/Sources/Networking/Core/Download/DownloadAPIManaging.swift index 36cc1543..1476c09e 100644 --- a/Sources/Networking/Core/Download/DownloadAPIManaging.swift +++ b/Sources/Networking/Core/Download/DownloadAPIManaging.swift @@ -13,9 +13,10 @@ public typealias DownloadResult = (URLSessionDownloadTask, Response) // MARK: - Defines Download API managing /// 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 { +/// 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. +@NetworkingActor +public protocol DownloadAPIManaging: Sendable { /// List of all currently ongoing download tasks. var allTasks: [URLSessionDownloadTask] { get async } @@ -35,8 +36,7 @@ public protocol DownloadAPIManaging { 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. diff --git a/Sources/Networking/Core/EndpointRequest.swift b/Sources/Networking/Core/EndpointRequest.swift index 1fe0925e..e0724a4b 100644 --- a/Sources/Networking/Core/EndpointRequest.swift +++ b/Sources/Networking/Core/EndpointRequest.swift @@ -9,7 +9,7 @@ import Foundation /// A wrapper structure which contains API endpoint with additional info about the session within which it's being called and an API call identifier. -public struct EndpointRequest: Identifiable { +public struct EndpointRequest: Identifiable, Sendable { public let id: String public let sessionId: String public let endpoint: Requestable diff --git a/Sources/Networking/Core/ErrorProcessing.swift b/Sources/Networking/Core/ErrorProcessing.swift index a60bd7a8..f0b80064 100644 --- a/Sources/Networking/Core/ErrorProcessing.swift +++ b/Sources/Networking/Core/ErrorProcessing.swift @@ -8,7 +8,8 @@ import Foundation /// A type that is able to customize error returned after failed network request. -public protocol ErrorProcessing { +@NetworkingActor +public protocol ErrorProcessing: Sendable { /// Modifies a given `Error`. /// - Parameters: /// - error: The error to be processed. diff --git a/Sources/Networking/Core/NetworkingActor.swift b/Sources/Networking/Core/NetworkingActor.swift new file mode 100644 index 00000000..46f6c798 --- /dev/null +++ b/Sources/Networking/Core/NetworkingActor.swift @@ -0,0 +1,13 @@ +// +// NetworkingActor.swift +// +// +// Created by Matej Molnár on 29.12.2023. +// + +import Foundation + +/// The only singleton actor in this library where all networking related operations should synchronize. +@globalActor public actor NetworkingActor { + public static let shared = NetworkingActor() +} diff --git a/Sources/Networking/Core/RequestAdapting.swift b/Sources/Networking/Core/RequestAdapting.swift index 77181572..f51b204b 100644 --- a/Sources/Networking/Core/RequestAdapting.swift +++ b/Sources/Networking/Core/RequestAdapting.swift @@ -11,7 +11,8 @@ import Foundation // MARK: - Modifying the request before it's been sent /// A type that is able to modify a request before sending it to an API. -public protocol RequestAdapting { +@NetworkingActor +public protocol RequestAdapting: Sendable { /// Modifies a given `URLRequest`. /// - Parameters: /// - request: The request to be adapted. diff --git a/Sources/Networking/Core/Requestable.swift b/Sources/Networking/Core/Requestable.swift index 08c8c064..8f2639be 100644 --- a/Sources/Networking/Core/Requestable.swift +++ b/Sources/Networking/Core/Requestable.swift @@ -67,7 +67,8 @@ import Foundation Some of the properties have default implementations defined in the `Requestable+Convenience` extension. */ -public protocol Requestable: EndpointIdentifiable { +/// A type that represents an API endpoint. +public protocol Requestable: EndpointIdentifiable, Sendable { /// The host URL of REST API. var baseURL: URL { get } diff --git a/Sources/Networking/Core/ResponseProcessing.swift b/Sources/Networking/Core/ResponseProcessing.swift index e7bf5b72..65846a1b 100644 --- a/Sources/Networking/Core/ResponseProcessing.swift +++ b/Sources/Networking/Core/ResponseProcessing.swift @@ -11,7 +11,8 @@ import Foundation // MARK: - Defines modifying the response after it's been received /// A type that is able to modify a ``Response`` when it's received from the network layer. -public protocol ResponseProcessing { +@NetworkingActor +public protocol ResponseProcessing: Sendable { /// Modifies a given ``Response``. /// - Parameters: /// - response: The response to be processed. diff --git a/Sources/Networking/Core/ResponseProviding.swift b/Sources/Networking/Core/ResponseProviding.swift index f6a374bb..f1bfddff 100644 --- a/Sources/Networking/Core/ResponseProviding.swift +++ b/Sources/Networking/Core/ResponseProviding.swift @@ -10,7 +10,8 @@ import Foundation /// A type is able to provide a ``Response`` for a given `URLRequest`. /// /// By default the Networking library uses `URLSession` to make API calls. -public protocol ResponseProviding { +@NetworkingActor +public protocol ResponseProviding: Sendable { /// Creates a ``Response`` for a given `URLRequest`. func response(for request: URLRequest) async throws -> Response } diff --git a/Sources/Networking/Core/RetryConfiguration.swift b/Sources/Networking/Core/RetryConfiguration.swift index e793f5d0..8217c80a 100644 --- a/Sources/Networking/Core/RetryConfiguration.swift +++ b/Sources/Networking/Core/RetryConfiguration.swift @@ -8,48 +8,49 @@ import Foundation /// Retry of API calls allows various options wrapped into `RetryConfiguration` struct. -public struct RetryConfiguration { +public struct RetryConfiguration: Sendable { /// The number of retries. let retries: Int /// The delay between each retry to avoid overwhelming API. let delay: DelayConfiguration /// A handler which determines wether a request should be retried or not based on an error. /// By default errors with status codes `HTTPStatusCode.nonRetriableCodes` are not being retried. - let retryHandler: (Error) -> Bool + let retryHandler: @Sendable (Error) -> Bool public init( retries: Int, delay: DelayConfiguration, - retryHandler: @escaping (Error) -> Bool) { + retryHandler: @Sendable @escaping (Error) -> Bool) { self.retries = retries self.delay = delay self.retryHandler = retryHandler } - - // default configuration ignores - public static var `default` = RetryConfiguration( - retries: 3, - delay: .constant(2) - ) { error in - /// Do not retry authorization errors. - if error is AuthorizationError { - return false - } - - /// But retry certain HTTP errors. - guard let networkError = error as? NetworkError, - case let .unacceptableStatusCode(statusCode, _, _) = networkError - else { - return true - } - return !(HTTPStatusCode.nonRetriableCodes ~= statusCode) + public static var `default`: RetryConfiguration { + .init( + retries: 3, + delay: .constant(2) + ) { error in + // Do not retry authorization errors. + if error is AuthorizationError { + return false + } + + // But retry certain HTTP errors. + guard let networkError = error as? NetworkError, + case let .unacceptableStatusCode(statusCode, _, _) = networkError + else { + return true + } + + return !(HTTPStatusCode.nonRetriableCodes ~= statusCode) + } } } public extension RetryConfiguration { /// A type that defines the delay strategy for retry logic. - enum DelayConfiguration { + enum DelayConfiguration: Sendable { /// The delay cumulatively increases after each retry. case progressive(TimeInterval) /// The delay is the same after each retry. diff --git a/Sources/Networking/Core/Retryable.swift b/Sources/Networking/Core/Retryable.swift index 7ad94f52..956f751f 100644 --- a/Sources/Networking/Core/Retryable.swift +++ b/Sources/Networking/Core/Retryable.swift @@ -6,6 +6,7 @@ // /// Provides retry utility functionality to subjects that require it. +@NetworkingActor protocol Retryable { /// Keeps count of executed retries so far given by `RetryConfiguration.retries`. var retryCounter: Counter { get } @@ -26,7 +27,7 @@ protocol Retryable { extension Retryable { func sleepIfRetry(for error: Error, endpointRequest: EndpointRequest, retryConfiguration: RetryConfiguration?) async throws { - let retryCount = await retryCounter.count(for: endpointRequest.id) + let retryCount = retryCounter.count(for: endpointRequest.id) guard let retryConfiguration = retryConfiguration, @@ -34,12 +35,12 @@ extension Retryable { retryConfiguration.retries > retryCount else { /// reset retry count - await retryCounter.reset(for: endpointRequest.id) + retryCounter.reset(for: endpointRequest.id) throw error } /// count the delay for retry - await retryCounter.increment(for: endpointRequest.id) + retryCounter.increment(for: endpointRequest.id) var sleepDuration: UInt64 switch retryConfiguration.delay { diff --git a/Sources/Networking/Core/StoredResponseProvider.swift b/Sources/Networking/Core/StoredResponseProvider.swift index e4b996d9..8aae218a 100644 --- a/Sources/Networking/Core/StoredResponseProvider.swift +++ b/Sources/Networking/Core/StoredResponseProvider.swift @@ -15,6 +15,7 @@ import Foundation #endif /// A response provider which creates responses for requests from corresponding data files stored in Assets. +@NetworkingActor open class StoredResponseProvider: ResponseProviding { private let bundle: Bundle private let sessionId: String @@ -60,11 +61,11 @@ private extension StoredResponseProvider { /// Loads a corresponding file from Assets for a given ``URLRequest`` and decodes the data to `EndpointRequestStorageModel`. func loadModel(for request: URLRequest) async throws -> EndpointRequestStorageModel? { // counting from 0, check storage request processing - let count = await requestCounter.count(for: request.identifier) + let count = requestCounter.count(for: request.identifier) if let data = NSDataAsset(name: "\(sessionId)_\(request.identifier)_\(count)", bundle: bundle)?.data { // store info about next indexed api call - await requestCounter.increment(for: request.identifier) + requestCounter.increment(for: request.identifier) return try decoder.decode(EndpointRequestStorageModel.self, from: data) } diff --git a/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift index 043b0c09..c07592d1 100644 --- a/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift @@ -7,9 +7,11 @@ import Foundation +extension InputStream: @unchecked @retroactive Sendable {} + public extension MultipartFormData { /// Represents an individual part of the `multipart/form-data`. - struct BodyPart { + struct BodyPart: Sendable { /// The input stream containing the data of the part's body. let dataStream: InputStream diff --git a/Sources/Networking/Core/Upload/MultipartFormData.swift b/Sources/Networking/Core/Upload/MultipartFormData.swift index 03a86fed..f3caf33b 100644 --- a/Sources/Networking/Core/Upload/MultipartFormData.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData.swift @@ -9,7 +9,7 @@ import Foundation /// 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 MultipartFormData { +public struct MultipartFormData: Sendable { /// The total size of the `multipart/form-data`. /// It is calculated as the sum of sizes of all the body parts added to the `MultipartFormData` instance. public var size: UInt64 { @@ -39,7 +39,7 @@ public extension MultipartFormData { /// - 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( + mutating func append( _ data: Data, name: String, fileName: String? = nil, @@ -62,7 +62,7 @@ public extension MultipartFormData { /// - 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. 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( + mutating func append( from fileUrl: URL, name: String, fileName: String? = nil, @@ -97,7 +97,7 @@ public extension MultipartFormData { // MARK: - Private private extension MultipartFormData { - func append( + mutating func append( dataStream: InputStream, name: String, size: UInt64, diff --git a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift index 79cbf04b..0704db79 100644 --- a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift @@ -7,7 +7,7 @@ import Foundation -open class MultipartFormDataEncoder { +open class MultipartFormDataEncoder: @unchecked Sendable { /// A string representing a carriage return and line feed. private let crlf = "\r\n" diff --git a/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift index 6050f454..11521507 100644 --- a/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift @@ -7,7 +7,7 @@ import Foundation -public protocol MultipartFormDataEncoding { +public protocol MultipartFormDataEncoding: Sendable { /// Encodes the specified `MultipartFormData` object into a `Data` object. /// - Parameter multipartFormData: The `MultipartFormData` object to encode. /// - Returns: A `Data` object containing the encoded `multipartFormData`. diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index ae9a6691..d85e9495 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -29,21 +29,18 @@ import Foundation 5. In case you are not using a singleton instance don't forget to call ``invalidateSession(shouldFinishTasks:)`` once the instance is not needed anymore in order to prevent memory leaks, since the `UploadAPIManager` is not automatically deallocated from memory because of a `URLSession` holding a reference to it. */ @available(iOS 15.0, *) -open class UploadAPIManager: NSObject { +open class UploadAPIManager: NSObject, UploadAPIManaging { // MARK: - Public Properties public var activeTasks: [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) } + // Values may contain inactive tasks + return uploadTasks.values.filter { activeTasks.contains($0.task) } } } // MARK: - Private Properties - private var uploadTasks = ThreadSafeDictionary() + private var uploadTasks = [String: UploadTask]() private lazy var urlSession = URLSession( configuration: urlSessionConfiguration, @@ -82,7 +79,7 @@ open class UploadAPIManager: NSObject { // MARK: URLSessionDataDelegate @available(iOS 15.0, *) extension UploadAPIManager: URLSessionDataDelegate { - public func urlSession( + nonisolated public func urlSession( _ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data @@ -93,7 +90,9 @@ extension UploadAPIManager: URLSessionDataDelegate { } if let originalRequest = dataTask.originalRequest, - let response = dataTask.response { + let response = dataTask.response, + dataTask.state == .completed + { do { try await handleUploadTaskCompletion( uploadTask: uploadTask, @@ -115,7 +114,7 @@ extension UploadAPIManager: URLSessionDataDelegate { // MARK: - URLSessionTaskDelegate @available(iOS 15.0, *) extension UploadAPIManager: URLSessionTaskDelegate { - public func urlSession( + nonisolated public func urlSession( _ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, @@ -123,26 +122,24 @@ extension UploadAPIManager: URLSessionTaskDelegate { totalBytesExpectedToSend: Int64 ) { Task { - await uploadTask(for: task)? - .statePublisher - .send(UploadTask.State(task: task)) + let uploadTask = await uploadTask(for: task) + uploadTask?.stateContinuation.yield(UploadTask.State(task: task)) } } - public func urlSession( + nonisolated 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 } - + + uploadTask.stateContinuation.yield(UploadTask.State(task: task)) + uploadTask.stateContinuation.finish() + await handleUploadTaskError( uploadTask: uploadTask, error: error @@ -153,8 +150,8 @@ extension UploadAPIManager: URLSessionTaskDelegate { // MARK: - UploadAPIManaging @available(iOS 15.0, *) -extension UploadAPIManager: UploadAPIManaging { - public func upload(_ type: UploadType, to endpoint: Requestable) async throws -> UploadTask { +public extension UploadAPIManager { + func upload(_ type: UploadType, to endpoint: Requestable) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) switch type { @@ -191,7 +188,7 @@ extension UploadAPIManager: UploadAPIManaging { } } - public func invalidateSession(shouldFinishTasks: Bool) { + func invalidateSession(shouldFinishTasks: Bool) { if shouldFinishTasks { urlSession.finishTasksAndInvalidate() } else { @@ -199,15 +196,15 @@ extension UploadAPIManager: UploadAPIManaging { } } - public func retry(taskId: String) async throws { + 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 { + guard let existingUploadTask = uploadTasks[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) + uploadTasks[taskId] = nil try await uploadRequest( existingUploadTask.uploadable, @@ -215,13 +212,10 @@ extension UploadAPIManager: UploadAPIManaging { ) } - public func stateStream(for uploadTaskId: UploadTask.ID) async -> StateStream { - let uploadTask = await uploadTasks - .getValues() - .values - .first { $0.id == uploadTaskId } + func stateStream(for uploadTaskId: UploadTask.ID) -> StateStream { + let uploadTask = uploadTasks.values.first { $0.id == uploadTaskId } - return uploadTask?.stateStream ?? Empty().eraseToAnyPublisher().values + return uploadTask?.stateStream ?? AsyncStream.makeStream(of: UploadTask.State.self).stream } } @@ -241,14 +235,14 @@ private extension UploadAPIManager { for: urlRequest ) - let uploadTask = await existingUploadTaskOrNew( + let uploadTask = existingUploadTaskOrNew( for: sessionUploadTask, request: request, uploadable: uploadable ) // Store the task for future processing - await uploadTasks.set(value: uploadTask, for: request.id) + uploadTasks[request.id] = uploadTask sessionUploadTask.resume() return uploadTask @@ -262,8 +256,8 @@ private extension UploadAPIManager { for sessionUploadTask: URLSessionUploadTask, request: EndpointRequest, uploadable: Uploadable - ) async -> UploadTask { - guard var existingUploadTask = await uploadTasks.getValue(for: request.id) else { + ) -> UploadTask { + guard var existingUploadTask = uploadTasks[request.id] else { return UploadTask( sessionUploadTask: sessionUploadTask, endpointRequest: request, @@ -290,7 +284,7 @@ private extension UploadAPIManager { // Cleanup on successful task completion await uploadTask.cleanup() - await uploadTasks.set(value: nil, for: uploadTask.endpointRequest.id) + uploadTasks[uploadTask.endpointRequest.id] = nil } func handleUploadTaskError( @@ -347,11 +341,8 @@ private extension UploadAPIManager { return adaptedRequest } - func uploadTask(for task: URLSessionTask) async -> UploadTask? { - await uploadTasks - .getValues() - .values - .first { $0.taskIdentifier == task.taskIdentifier } + func uploadTask(for task: URLSessionTask) -> UploadTask? { + uploadTasks.values.first { $0.taskIdentifier == task.taskIdentifier } } func temporaryFileUrl(for request: EndpointRequest) throws -> URL { diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 3e8f6dbc..9df500cb 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -12,8 +12,9 @@ import Foundation /// /// 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. @available(iOS 15.0, *) -public protocol UploadAPIManaging { - typealias StateStream = AsyncPublisher> +@NetworkingActor +public protocol UploadAPIManaging: Sendable { + typealias StateStream = AsyncStream /// Currently active upload tasks. var activeTasks: [UploadTask] { get async } @@ -39,7 +40,7 @@ public protocol UploadAPIManaging { /// 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. If there is no such upload task the return stream finishes immediately. - func stateStream(for uploadTaskId: UploadTask.ID) async -> StateStream + func stateStream(for uploadTaskId: UploadTask.ID) -> StateStream /// Invalidates the session with the option to wait for all outstanding (active) tasks. /// diff --git a/Sources/Networking/Core/Upload/UploadTask+State.swift b/Sources/Networking/Core/Upload/UploadTask+State.swift index 814d6ec7..7b181af8 100644 --- a/Sources/Networking/Core/Upload/UploadTask+State.swift +++ b/Sources/Networking/Core/Upload/UploadTask+State.swift @@ -9,7 +9,7 @@ import Foundation public extension UploadTask { /// The upload task's state. - struct State { + struct State: Sendable { /// Number of bytes sent. public let sentBytes: Int64 diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 1e09622e..a6fbc9aa 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -5,11 +5,10 @@ // 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 { +public struct UploadTask: Sendable { // swiftlint:disable:next type_name public typealias ID = String @@ -22,8 +21,11 @@ public struct UploadTask { /// The uploadable data associated with this task. let uploadable: Uploadable - /// Use this publisher to emit a new state of the task. - let statePublisher: CurrentValueSubject + /// An asynchronous sequence of the upload task' state. + let stateStream: AsyncStream + + /// Use this stream to emit a new state of the task + let stateContinuation: AsyncStream.Continuation } // MARK: - Public API @@ -33,7 +35,7 @@ public extension UploadTask { func resume() { if task.state == .suspended { task.resume() - statePublisher.send(State(task: task)) + stateContinuation.yield(State(task: task)) } } @@ -43,7 +45,7 @@ public extension UploadTask { /// - Note: While paused (suspended state), the task is still subject to timeouts. func pause() { task.suspend() - statePublisher.send(State(task: task)) + stateContinuation.yield(State(task: task)) } /// Cancels the task. @@ -52,7 +54,8 @@ public extension UploadTask { /// and set the task to the `URLSessionTask.State.cancelled` state. func cancel() { task.cancel() - statePublisher.send(State(task: task)) + stateContinuation.yield(State(task: task)) + stateContinuation.finish() } func cleanup() async { @@ -70,22 +73,17 @@ extension UploadTask { task.taskIdentifier } - /// An asynchronous sequence of the upload task' state. - 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 { - statePublisher.send(state) + stateContinuation.yield(State(task: task)) // 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) + stateContinuation.finish() } } @@ -98,7 +96,10 @@ extension UploadTask { self.task = sessionUploadTask self.endpointRequest = endpointRequest self.uploadable = uploadable - self.statePublisher = .init(State(task: sessionUploadTask)) + + let (stream, continuation) = AsyncStream.makeStream(of: State.self) + self.stateStream = stream + self.stateContinuation = continuation } } diff --git a/Sources/Networking/Core/Upload/UploadType.swift b/Sources/Networking/Core/Upload/UploadType.swift index e8082a24..92f6e02f 100644 --- a/Sources/Networking/Core/Upload/UploadType.swift +++ b/Sources/Networking/Core/Upload/UploadType.swift @@ -8,7 +8,7 @@ import Foundation /// A type which represents data that can be uploaded. -public enum UploadType { +public enum UploadType: Sendable { /// - 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) diff --git a/Sources/Networking/Misc/Counter.swift b/Sources/Networking/Misc/Counter.swift index 8862c504..745876b8 100644 --- a/Sources/Networking/Misc/Counter.swift +++ b/Sources/Networking/Misc/Counter.swift @@ -8,7 +8,8 @@ import Foundation /// A thread safe wrapper for count dictionary. -actor Counter { +@NetworkingActor +final class Counter { private var dict = [String: Int]() func count(for key: String) -> Int { diff --git a/Sources/Networking/Misc/ThreadSafeDictionary.swift b/Sources/Networking/Misc/ThreadSafeDictionary.swift deleted file mode 100644 index 864ffed5..00000000 --- a/Sources/Networking/Misc/ThreadSafeDictionary.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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+DownloadState.swift b/Sources/Networking/Misc/URLSessionTask+DownloadState.swift index 7ac1433e..a3fd1a80 100644 --- a/Sources/Networking/Misc/URLSessionTask+DownloadState.swift +++ b/Sources/Networking/Misc/URLSessionTask+DownloadState.swift @@ -9,7 +9,7 @@ import Foundation public extension URLSessionTask { /// A struct which provides you with information about a download, including bytes downloaded, total byte size of the file being downloaded or the error if any occurs. - struct DownloadState { + struct DownloadState: Sendable { public var downloadedBytes: Int64 public var totalBytes: Int64 public var taskState: URLSessionTask.State diff --git a/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift b/Sources/Networking/Misc/URLSessionTask+ResumeWithResponse.swift similarity index 72% rename from Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift rename to Sources/Networking/Misc/URLSessionTask+ResumeWithResponse.swift index d75e7c84..c313392c 100644 --- a/Sources/Networking/Misc/URLSessionTask+AsyncResponse.swift +++ b/Sources/Networking/Misc/URLSessionTask+ResumeWithResponse.swift @@ -1,20 +1,21 @@ // -// URLSessionTask+AsyncResponse.swift +// URLSessionTask+ResumeWithResponse.swift // // // Created by Dominika Gajdová on 12.05.2023. // import Foundation -import Combine +// The @preconcurrency suppresses capture of non-sendable type 'AnyCancellables' warning, which doesn't yet conform to Sendable. +@preconcurrency import Combine extension URLSessionTask { - func asyncResponse() async throws -> URLResponse { + func resumeWithResponse() async throws -> URLResponse { var cancellable: AnyCancellable? - + return try await withTaskCancellationHandler( operation: { - try await withCheckedThrowingContinuation { continuation in + return try await withCheckedThrowingContinuation { continuation in cancellable = Publishers.CombineLatest( publisher(for: \.response), publisher(for: \.error) @@ -29,6 +30,8 @@ extension URLSessionTask { continuation.resume(returning: response) } } + + resume() } }, onCancel: { [cancellable] in diff --git a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationManaging.swift b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationManaging.swift index 7f3c130c..709496a8 100644 --- a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationManaging.swift +++ b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationManaging.swift @@ -30,7 +30,8 @@ import Foundation } ``` */ -public protocol AuthorizationManaging { +@NetworkingActor +public protocol AuthorizationManaging: Sendable { var storage: any AuthorizationStorageManaging { get } func refreshAuthorizationData(with refreshToken: String) async throws -> AuthorizationData diff --git a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationStorageManaging.swift b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationStorageManaging.swift index 042a2ced..b899ea60 100644 --- a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationStorageManaging.swift +++ b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationStorageManaging.swift @@ -8,7 +8,9 @@ import Foundation /// A definition of a manager which handles operations related to storing ``AuthorizationData`` for example in a KeyChain. -public protocol AuthorizationStorageManaging { +/// To keep consistency all operations are async +@NetworkingActor +public protocol AuthorizationStorageManaging: Sendable { func saveData(_ data: AuthorizationData) async throws func getData() async throws -> AuthorizationData func deleteData() async throws diff --git a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationTokenInterceptor.swift b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationTokenInterceptor.swift index 4f98ca91..d09a4c74 100644 --- a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationTokenInterceptor.swift +++ b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationTokenInterceptor.swift @@ -12,7 +12,7 @@ import Foundation It makes sure that ``AuthorizationManaging/refreshAuthorizationData()`` is triggered in case the ``AuthorizationData`` is expired and that it won't get triggered multiple times at once. */ -public final actor AuthorizationTokenInterceptor: RequestInterceptor { +open class AuthorizationTokenInterceptor: RequestInterceptor { private var authorizationManager: AuthorizationManaging private var refreshTask: Task? @@ -78,15 +78,15 @@ private extension AuthorizationTokenInterceptor { } // Otherwise create a new refresh task. - let newRefreshTask = Task { [weak self] () throws -> Void in + let newRefreshTask = Task { [weak self] () throws in do { // Perform the actual refresh logic. try await self?.authorizationManager.refreshAuthorizationData() - // Make sure to clear refreshTask property after refreshing finishes. - await self?.clearRefreshTask() + /// Make sure to clear refreshTask property after refreshing finishes. + self?.clearRefreshTask() } catch { - // Make sure to clear refreshTask property after refreshing finishes. - await self?.clearRefreshTask() + /// Make sure to clear refreshTask property after refreshing finishes. + self?.clearRefreshTask() throw error } } diff --git a/Sources/Networking/Modifiers/Interceptors/LoggingInterceptor.swift b/Sources/Networking/Modifiers/Interceptors/LoggingInterceptor.swift index 1fbe740c..9849ce1e 100644 --- a/Sources/Networking/Modifiers/Interceptors/LoggingInterceptor.swift +++ b/Sources/Networking/Modifiers/Interceptors/LoggingInterceptor.swift @@ -36,7 +36,7 @@ open class LoggingInterceptor: RequestInterceptor { /// - request: The request to be logged. /// - endpointRequest: An endpoint request wrapper. /// - Returns: The the original `URLRequest`. - open func adapt(_ request: URLRequest, for endpointRequest: EndpointRequest) -> URLRequest { + public func adapt(_ request: URLRequest, for endpointRequest: EndpointRequest) -> URLRequest { prettyRequestLog(request, from: endpointRequest.endpoint) return request } @@ -47,7 +47,7 @@ open class LoggingInterceptor: RequestInterceptor { /// - request: The original URL request. /// - endpointRequest: An endpoint request wrapper. /// - Returns: The original ``Response``. - open func process(_ response: Response, with urlRequest: URLRequest, for endpointRequest: EndpointRequest) throws -> Response { + public func process(_ response: Response, with urlRequest: URLRequest, for endpointRequest: EndpointRequest) throws -> Response { prettyResponseLog(response, from: endpointRequest.endpoint) return response } @@ -57,7 +57,7 @@ open class LoggingInterceptor: RequestInterceptor { /// - error: The error to be logged. /// - endpointRequest: An endpoint request wrapper. /// - Returns: The original `Error`. - open func process(_ error: Error, for endpointRequest: EndpointRequest) async -> Error { + public func process(_ error: Error, for endpointRequest: EndpointRequest) async -> Error { prettyErrorLog(error, from: endpointRequest.endpoint) return error } diff --git a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageModel.swift b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageModel.swift index 433b2ac0..db8ad71d 100644 --- a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageModel.swift +++ b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageModel.swift @@ -8,7 +8,7 @@ import Foundation /// A model containing all necessary info about request and related response to be replayed as mocked data. -public struct EndpointRequestStorageModel: Codable { +public struct EndpointRequestStorageModel: Codable, Sendable { public let sessionId: String public let date: Date public let path: String diff --git a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageProcessor.swift b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageProcessor.swift index d5731937..d50b8079 100644 --- a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageProcessor.swift +++ b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageProcessor.swift @@ -24,23 +24,27 @@ open class EndpointRequestStorageProcessor: ResponseProcessing, ErrorProcessing private let jsonEncoder: JSONEncoder private let fileDataWriter: FileDataWriting private let config: Config - + private let deviceName: String private lazy var responsesDirectory = fileManager.temporaryDirectory.appendingPathComponent("responses") private lazy var requestCounter = Counter() + private lazy var multipeerConnectivityManager: MultipeerConnectivityManager? = { #if DEBUG - // Initialise only in DEBUG mode otherwise it could pose a security risk for production apps. - guard let multiPeerSharingConfig = config.multiPeerSharing else { + guard let multipeerSharingConfig = config.multiPeerSharing else { return nil } - - let initialBuffer = multiPeerSharingConfig.shareHistory ? getAllStoredModels() : [] - return .init(buffer: initialBuffer) + + let initialBuffer = multipeerSharingConfig.shareHistory ? getAllStoredModels() : [] + + return MultipeerConnectivityManager( + buffer: initialBuffer, + deviceName: deviceName + ) #else return nil #endif }() - + // MARK: Default shared instance public static let shared = EndpointRequestStorageProcessor( config: .init( @@ -48,17 +52,19 @@ open class EndpointRequestStorageProcessor: ResponseProcessing, ErrorProcessing storedSessionsLimit: 5 ) ) - + public init( fileManager: FileManager = .default, fileDataWriter: FileDataWriting = FileDataWriter(), jsonEncoder: JSONEncoder? = nil, - config: Config = .default + config: Config = .default, + deviceName: String = UUID().uuidString ) { self.fileManager = fileManager self.fileDataWriter = fileDataWriter self.jsonEncoder = jsonEncoder ?? .default self.config = config + self.deviceName = deviceName deleteStoredSessionsExceedingLimit() } @@ -103,7 +109,7 @@ open class EndpointRequestStorageProcessor: ResponseProcessing, ErrorProcessing // MARK: - Config public extension EndpointRequestStorageProcessor { - struct Config { + struct Config: Sendable { public static let `default` = Config() /// If `nil` the MultiPeerConnectivity session won't get initialised. @@ -120,7 +126,7 @@ public extension EndpointRequestStorageProcessor { } } - struct MultiPeerSharingConfig { + struct MultiPeerSharingConfig: Sendable { /// If `true` it loads all stored responses and shares them at the start. /// If `false` it only shares the responses from the current session. let shareHistory: Bool @@ -145,7 +151,7 @@ private extension EndpointRequestStorageProcessor { return } - self.createFolderIfNeeded(endpointRequest.sessionId) + await self.createFolderIfNeeded(endpointRequest.sessionId) // for http responses read headers let httpResponse = response.response as? HTTPURLResponse @@ -185,7 +191,7 @@ private extension EndpointRequestStorageProcessor { fileUrl: self.createFileUrl(endpointRequest) ) - multipeerConnectivityManager?.send(model: storageModel) + await multipeerConnectivityManager?.send(model: storageModel) } } @@ -203,8 +209,8 @@ private extension EndpointRequestStorageProcessor { } func createFileUrl(_ endpointRequest: EndpointRequest) async -> URL { - let count = await requestCounter.count(for: endpointRequest.endpoint.identifier) - await requestCounter.increment(for: endpointRequest.endpoint.identifier) + let count = requestCounter.count(for: endpointRequest.endpoint.identifier) + requestCounter.increment(for: endpointRequest.endpoint.identifier) let fileName = "\(endpointRequest.sessionId)_\(endpointRequest.endpoint.identifier)_\(count)" diff --git a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/MultipeerConnectivityManager.swift b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/MultipeerConnectivityManager.swift index bf0eeed1..05197330 100644 --- a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/MultipeerConnectivityManager.swift +++ b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/MultipeerConnectivityManager.swift @@ -7,45 +7,49 @@ #if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) +// @preconcurrency suppresses a swift concurrency warning: Non-sendable type ... +@preconcurrency import MultipeerConnectivity import OSLog -import MultipeerConnectivity -open class MultipeerConnectivityManager: NSObject { +@NetworkingActor +public final class MultipeerConnectivityManager: NSObject { public static let service = "networking-jobs" public static let macOSAppDisplayName = "networking-macos-app" private var buffer: [EndpointRequestStorageModel] private var peers = Set() - private lazy var myPeerId: MCPeerID = { - #if os(macOS) - let deviceName = Host.current().localizedName ?? "macOS" - #else - let deviceName = UIDevice.current.name - #endif - - #if targetEnvironment(simulator) - return MCPeerID(displayName: "Simulator - " + deviceName) - #else - return MCPeerID(displayName: deviceName) - #endif - }() - - private lazy var session = MCSession( - peer: myPeerId, - securityIdentity: nil, - encryptionPreference: .none - ) - private lazy var nearbyServiceAdvertiser = MCNearbyServiceAdvertiser( - peer: myPeerId, - discoveryInfo: nil, - serviceType: MultipeerConnectivityManager.service - ) - - init(buffer: [EndpointRequestStorageModel]) { + + private let session: MCSession + private let nearbyServiceAdvertiser: MCNearbyServiceAdvertiser + + init( + buffer: [EndpointRequestStorageModel], + deviceName: String + ) { self.buffer = buffer + + let myPeerId: MCPeerID = { + #if targetEnvironment(simulator) + return MCPeerID(displayName: "Simulator - " + deviceName) + #else + return MCPeerID(displayName: deviceName) + #endif + }() + + self.session = MCSession( + peer: myPeerId, + securityIdentity: nil, + encryptionPreference: .none + ) + self.nearbyServiceAdvertiser = MCNearbyServiceAdvertiser( + peer: myPeerId, + discoveryInfo: nil, + serviceType: MultipeerConnectivityManager.service + ) + super.init() - + session.delegate = self nearbyServiceAdvertiser.delegate = self nearbyServiceAdvertiser.startAdvertisingPeer() @@ -81,40 +85,46 @@ private extension MultipeerConnectivityManager { buffer.removeAll() os_log("🎈 Request data were successfully sent via multipeer connection") } + + func stateChanged(_ state: MCSessionState, for peerID: MCPeerID) { + switch state { + case .connected: + peers.insert(peerID) + if !buffer.isEmpty { + sendBuffer(to: peerID) + } + case .notConnected, .connecting: + peers.remove(peerID) + @unknown default: + break + } + } } // MARK: - MCNearbyServiceAdvertiserDelegate -extension MultipeerConnectivityManager: MCNearbyServiceAdvertiserDelegate { +extension MultipeerConnectivityManager: @preconcurrency MCNearbyServiceAdvertiserDelegate { public func advertiser( _ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void ) { - invitationHandler(true, self.session) + invitationHandler(true, session) } } // MARK: - MCSessionDelegate extension MultipeerConnectivityManager: MCSessionDelegate { - public func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { - switch state { - case .connected: - peers.insert(peerID) - if !buffer.isEmpty { - sendBuffer(to: peerID) - } - case .notConnected, .connecting: - peers.remove(peerID) - @unknown default: - break + nonisolated public func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + Task { + await stateChanged(state, for: peerID) } } - public func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {} - public func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {} - public func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {} - public func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {} + nonisolated public func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {} + nonisolated public func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {} + nonisolated public func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {} + nonisolated public func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {} } #endif diff --git a/Sources/Networking/Utils/FileManager+Sendable.swift b/Sources/Networking/Utils/FileManager+Sendable.swift new file mode 100644 index 00000000..5bbee6f5 --- /dev/null +++ b/Sources/Networking/Utils/FileManager+Sendable.swift @@ -0,0 +1,11 @@ +// +// File.swift +// +// +// Created by Matej Molnár on 27.11.2023. +// + +import Foundation + +// FileManager does not yet conforming to Sendable, hence we at least suppress the non-sendable warning. +extension FileManager: @unchecked @retroactive Sendable {} diff --git a/Tests/NetworkingTests/APIManagerTests.swift b/Tests/NetworkingTests/APIManagerTests.swift new file mode 100644 index 00000000..d53414b2 --- /dev/null +++ b/Tests/NetworkingTests/APIManagerTests.swift @@ -0,0 +1,75 @@ +// +// APIManagerTests.swift +// +// +// Created by Matej Molnár on 28.12.2023. +// + +@testable import Networking +import XCTest + +@NetworkingActor +final class APIManagerTests: XCTestCase { + enum UserRouter: Requestable { + case users(page: Int) + + var baseURL: URL { + // swiftlint:disable:next force_unwrapping + URL(string: "https://reqres.in/api")! + } + + var path: String { + switch self { + case .users: + "users" + } + } + + var urlParameters: [String: Any]? { + switch self { + case let .users(page): + ["page": page] + } + } + + var method: HTTPMethod { + switch self { + case .users: + .get + } + } + } + + private let mockSessionId = "2023-01-04T16:15:29Z" + + func testMultiThreadRequests() { + let mockResponseProvider = MockResponseProvider(with: Bundle.module, sessionId: mockSessionId) + let apiManager = APIManager( + responseProvider: mockResponseProvider, + // Since one of the mocked responses returns 400 we don't want the test fail. + responseProcessors: [] + ) + + let expectation = XCTestExpectation(description: "Requests completed") + + Task { + do { + // Create 15 parallel requests on multiple threads to test the manager's thread safety. + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<15 { + group.addTask { + try await apiManager.request(UserRouter.users(page: 2)) + } + } + + try await group.waitForAll() + expectation.fulfill() + } + } catch { + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: 1) + } +} diff --git a/Tests/NetworkingTests/AuthorizationTokenInterceptorTests.swift b/Tests/NetworkingTests/AuthorizationTokenInterceptorTests.swift index b3bc1ffb..9535ffcb 100644 --- a/Tests/NetworkingTests/AuthorizationTokenInterceptorTests.swift +++ b/Tests/NetworkingTests/AuthorizationTokenInterceptorTests.swift @@ -9,7 +9,7 @@ import XCTest // MARK: - Tests - +@NetworkingActor final class AuthorizationTokenInterceptorTests: XCTestCase { let mockSessionId = "mockSessionId" @@ -69,8 +69,8 @@ final class AuthorizationTokenInterceptorTests: XCTestCase { let refreshedAuthData = AuthorizationData.makeValidAuthorizationData() - authManager.refreshedAuthorizationData = refreshedAuthData - + authManager.setRefreshedAuthorizationData(refreshedAuthData) + let requestable = MockRouter.testAuthenticationRequired let request = URLRequest(url: requestable.baseURL) let endpointRequest = EndpointRequest(requestable, sessionId: mockSessionId) @@ -105,13 +105,13 @@ final class AuthorizationTokenInterceptorTests: XCTestCase { let expiredAuthData = AuthorizationData.makeExpiredAuthorizationData() /// Token refresh is going to take 0.5 seconds in order to test wether other requests actually wait for the refresh to finish. - authManager.sleepNanoseconds = 500_000_000 + authManager.setSleepNanoseconds(500_000_000) try await authManager.storage.saveData(expiredAuthData) let refreshedAuthData = AuthorizationData.makeValidAuthorizationData() - authManager.refreshedAuthorizationData = refreshedAuthData - + authManager.setRefreshedAuthorizationData(refreshedAuthData) + let requestable = MockRouter.testAuthenticationRequired let request = URLRequest(url: requestable.baseURL) let endpointRequest = EndpointRequest(requestable, sessionId: mockSessionId) @@ -137,7 +137,7 @@ final class AuthorizationTokenInterceptorTests: XCTestCase { let expiredAuthData = AuthorizationData.makeExpiredAuthorizationData() /// Token refresh is going to take 0.5 seconds in order to test wether other requests actually wait for the refresh to finish. - authManager.sleepNanoseconds = 500_000_000 + authManager.setSleepNanoseconds(500_000_000) try await authManager.storage.saveData(expiredAuthData) let requestable = MockRouter.testAuthenticationRequired @@ -160,7 +160,7 @@ final class AuthorizationTokenInterceptorTests: XCTestCase { } // MARK: - Mock helper classes -private actor MockAuthorizationStorageManager: AuthorizationStorageManaging { +private class MockAuthorizationStorageManager: AuthorizationStorageManaging { private var storage: AuthorizationData? func saveData(_ data: AuthorizationData) async throws { @@ -183,9 +183,17 @@ private actor MockAuthorizationStorageManager: AuthorizationStorageManaging { private class MockAuthorizationManager: AuthorizationManaging { let storage: AuthorizationStorageManaging = MockAuthorizationStorageManager() - var sleepNanoseconds: UInt64 = 0 - var refreshedAuthorizationData: AuthorizationData? + private var sleepNanoseconds: UInt64 = 0 + private var refreshedAuthorizationData: AuthorizationData? + func setSleepNanoseconds(_ nanoseconds: UInt64) { + sleepNanoseconds = nanoseconds + } + + func setRefreshedAuthorizationData(_ authorisationData: AuthorizationData) { + refreshedAuthorizationData = authorisationData + } + func refreshAuthorizationData(with refreshToken: String) async throws -> Networking.AuthorizationData { try await Task.sleep(nanoseconds: sleepNanoseconds) diff --git a/Tests/NetworkingTests/DownloadAPIManagerTests.swift b/Tests/NetworkingTests/DownloadAPIManagerTests.swift new file mode 100644 index 00000000..54f3f396 --- /dev/null +++ b/Tests/NetworkingTests/DownloadAPIManagerTests.swift @@ -0,0 +1,70 @@ +// +// DownloadAPIManagerTests.swift +// +// +// Created by Matej Molnár on 01.01.2024. +// + +@testable import Networking +import XCTest + +@NetworkingActor +final class DownloadAPIManagerTests: XCTestCase { + enum DownloadRouter: Requestable { + case download(url: URL) + + var baseURL: URL { + switch self { + case let .download(url): + url + } + } + + var path: String { + switch self { + case .download: + "" + } + } + } + + func testMultiThreadRequests() { + let apiManager = DownloadAPIManager( + // A session configuration that uses no persistent storage for caches, cookies, or credentials. + urlSessionConfiguration: .ephemeral, + // Set empty response processors since the mock download requests return status code 0 and we don't want the test fail. + responseProcessors: [] + ) + + // We can simulate the download even with a local file. + guard let downloadUrl = Bundle.module.url(forResource: "download_test", withExtension: "txt") else { + XCTFail("Resource not found") + return + } + + let expectation = XCTestExpectation(description: "Downloads completed") + + Task { + // Create 15 parallel requests on multiple threads to test the manager's thread safety. + do { + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<15 { + group.addTask { + _ = try await apiManager.downloadRequest( + DownloadRouter.download(url: downloadUrl), + retryConfiguration: nil + ) + } + } + + try await group.waitForAll() + expectation.fulfill() + } + } catch { + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: 5) + } +} diff --git a/Tests/NetworkingTests/EndpointRequestStorageProcessorTests.swift b/Tests/NetworkingTests/EndpointRequestStorageProcessorTests.swift index bbe62d3b..7c0a1b0b 100644 --- a/Tests/NetworkingTests/EndpointRequestStorageProcessorTests.swift +++ b/Tests/NetworkingTests/EndpointRequestStorageProcessorTests.swift @@ -12,6 +12,7 @@ import XCTest // MARK: - Test Endpoint request storage processor +@NetworkingActor final class EndpointRequestStorageProcessorTests: XCTestCase { private let sessionId = "sessionId_request_storage" private let mockFileManager = MockFileManager() @@ -87,7 +88,8 @@ final class EndpointRequestStorageProcessorTests: XCTestCase { let mockURLResponse: URLResponse = HTTPURLResponse(url: MockRouter.testStoringGet.baseURL, statusCode: 200, httpVersion: nil, headerFields: nil)! let mockResponse = (Data(), mockURLResponse) - let response = try await EndpointRequestStorageProcessor().process(mockResponse, with: mockURLRequest, for: mockEndpointRequest) + let response = try await EndpointRequestStorageProcessor() + .process(mockResponse, with: mockURLRequest, for: mockEndpointRequest) // test storing data processor doesn't effect response in anyway XCTAssertEqual(response.data, mockResponse.0) @@ -133,7 +135,7 @@ final class EndpointRequestStorageProcessorTests: XCTestCase { httpVersion: nil, headerFields: ["mockResponseHeader": "mock"] )! - let mockResponseData = "Mock data".data(using: .utf8)! + let mockResponseData = Data("Mock data".utf8) let mockResponse = (mockResponseData, mockURLResponse) let expectation = expectation(description: "Data was written") @@ -235,7 +237,7 @@ final class EndpointRequestStorageProcessorTests: XCTestCase { httpVersion: nil, headerFields: ["mockResponseHeader": "mock"] )! - let mockResponseData = "Not found".data(using: .utf8)! + let mockResponseData = Data("Not found".utf8) let mockResponse = (mockResponseData, mockURLResponse) let mockError = NetworkError.unacceptableStatusCode( statusCode: 404, @@ -290,7 +292,7 @@ final class EndpointRequestStorageProcessorTests: XCTestCase { httpVersion: nil, headerFields: ["mockResponseHeader": "mock"] )! - let mockResponseData = "Mock data".data(using: .utf8)! + let mockResponseData = Data("Mock data".utf8) let mockResponse = (mockResponseData, mockURLResponse) let expectation = expectation(description: "Data was written") diff --git a/Tests/NetworkingTests/ErrorProcessorTests.swift b/Tests/NetworkingTests/ErrorProcessorTests.swift index 195768e2..d6d1c2ad 100644 --- a/Tests/NetworkingTests/ErrorProcessorTests.swift +++ b/Tests/NetworkingTests/ErrorProcessorTests.swift @@ -9,6 +9,7 @@ import XCTest import Foundation +@NetworkingActor final class ErrorProcessorTests: XCTestCase { enum MockRouter: Requestable { case testMockSimpleError diff --git a/Tests/NetworkingTests/Mocks/MockFileManager.swift b/Tests/NetworkingTests/Mocks/MockFileManager.swift index d51633c5..ebabde27 100644 --- a/Tests/NetworkingTests/Mocks/MockFileManager.swift +++ b/Tests/NetworkingTests/Mocks/MockFileManager.swift @@ -9,7 +9,7 @@ import Foundation import XCTest /// A subclass of `FileManager` where the file existence is based on a dictionary whose key is the file path. -final class MockFileManager: FileManager { +final class MockFileManager: FileManager, @unchecked Sendable { enum Function: Equatable { case fileExists(path: String) case createDirectory(path: String) diff --git a/Tests/NetworkingTests/Mocks/MockResponseProvider.swift b/Tests/NetworkingTests/Mocks/MockResponseProvider.swift new file mode 100644 index 00000000..0fb497b3 --- /dev/null +++ b/Tests/NetworkingTests/Mocks/MockResponseProvider.swift @@ -0,0 +1,83 @@ +// +// MockResponseProvider.swift +// +// +// Created by Matej Molnár on 04.01.2023. +// + +import Foundation +@testable import Networking + +// necessary for NSDataAsset import +#if os(macOS) + import AppKit +#else + import UIKit +#endif + +// MARK: - MockResponseProvider definition + +/// A response provider which creates responses for requests from corresponding data files stored in Assets. +@NetworkingActor +open class MockResponseProvider: ResponseProviding { + private let bundle: Bundle + private let sessionId: String + private let requestCounter = Counter() + private lazy var decoder = JSONDecoder() + + /// Creates MockResponseProvider instance. + /// - Parameters: + /// - bundle: A bundle which includes the assets file. + /// - sessionId: An ID of a session, which data should be read. + public init(with bundle: Bundle, sessionId: String) { + self.bundle = bundle + self.sessionId = sessionId + } + + /// Creates a ``Response`` for a given `URLRequest` based on data from a corresponding file stored in Assets. + /// - Parameter request: URL request. + public func response(for request: URLRequest) async throws -> Response { + guard let model = try? await loadModel(for: request) else { + throw NetworkError.underlying(error: MockResponseProviderError.unableToLoadAssetData) + } + + guard + let statusCode = model.statusCode, + let url = request.url, + let httpResponse = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: model.responseHeaders + ) + else { + throw NetworkError.underlying(error: MockResponseProviderError.unableToConstructResponse) + } + + return Response(model.responseBody ?? Data(), httpResponse) + } +} + +// MARK: Private helper functions + +private extension MockResponseProvider { + /// Loads a corresponding file from Assets for a given ``URLRequest`` and decodes the data to `EndpointRequestStorageModel`. + func loadModel(for request: URLRequest) async throws -> EndpointRequestStorageModel? { + // counting from 0, check storage request processing + let count = requestCounter.count(for: request.identifier) + + if let data = NSDataAsset(name: "\(sessionId)_\(request.identifier)_\(count)", bundle: bundle)?.data { + // store info about next indexed api call + requestCounter.increment(for: request.identifier) + return try decoder.decode(EndpointRequestStorageModel.self, from: data) + } + + // return previous response, if no more stored indexed api calls + // swiftlint:disable:next empty_count + if count > 0, let data = NSDataAsset(name: "\(sessionId)_\(request.identifier)_\(count - 1)", bundle: bundle)?.data { + return try decoder.decode(EndpointRequestStorageModel.self, from: data) + } + + return nil + } +} diff --git a/Tests/NetworkingTests/Mocks/MockResponseProviderError.swift b/Tests/NetworkingTests/Mocks/MockResponseProviderError.swift new file mode 100644 index 00000000..0319461a --- /dev/null +++ b/Tests/NetworkingTests/Mocks/MockResponseProviderError.swift @@ -0,0 +1,16 @@ +// +// MockResponseProviderError.swift +// +// +// Created by Matej Molnár on 04.01.2023. +// + +import Foundation + +/// An error that occurs during loading a ``Response`` from assets by `MockResponseProvider`. +enum MockResponseProviderError: Error { + /// An indication that there was a problem with loading or decoding data from assets. + case unableToLoadAssetData + /// An indication that it was not possible to construct a `Response` from the loaded data. + case unableToConstructResponse +} diff --git a/Tests/NetworkingTests/MultipartFormDataEncoderTests.swift b/Tests/NetworkingTests/MultipartFormDataEncoderTests.swift index 80e8a252..27d75d5c 100644 --- a/Tests/NetworkingTests/MultipartFormDataEncoderTests.swift +++ b/Tests/NetworkingTests/MultipartFormDataEncoderTests.swift @@ -33,7 +33,7 @@ final class MultipartFormDataEncoderTests: XCTestCase { func test_encode_encodesDataAsExpected() throws { let sut = makeSUT() - let formData = MultipartFormData(boundary: "--boundary--123") + var formData = MultipartFormData(boundary: "--boundary--123") let data1 = Data("Hello".utf8) formData.append(data1, name: "first-data") @@ -56,7 +56,7 @@ final class MultipartFormDataEncoderTests: XCTestCase { func test_encode_encodesToFileAsExpected() throws { let sut = makeSUT() - let formData = MultipartFormData(boundary: "--boundary--123") + var formData = MultipartFormData(boundary: "--boundary--123") let data = Data("Hello".utf8) formData.append(data, name: "first-data") diff --git a/Tests/NetworkingTests/Resources/download_test.txt b/Tests/NetworkingTests/Resources/download_test.txt new file mode 100644 index 00000000..9452afb6 --- /dev/null +++ b/Tests/NetworkingTests/Resources/download_test.txt @@ -0,0 +1 @@ +just a test download file diff --git a/Tests/NetworkingTests/StatusCodeProcessorTests.swift b/Tests/NetworkingTests/StatusCodeProcessorTests.swift index 3240b711..6e390e0f 100644 --- a/Tests/NetworkingTests/StatusCodeProcessorTests.swift +++ b/Tests/NetworkingTests/StatusCodeProcessorTests.swift @@ -8,6 +8,7 @@ @testable import Networking import XCTest +@NetworkingActor final class StatusCodeProcessorTests: XCTestCase { private let sessionId = "sessionId_status_code" diff --git a/Tests/NetworkingTests/StoredResponseProviderTests.swift b/Tests/NetworkingTests/StoredResponseProviderTests.swift index 2ef1689b..7f216f26 100644 --- a/Tests/NetworkingTests/StoredResponseProviderTests.swift +++ b/Tests/NetworkingTests/StoredResponseProviderTests.swift @@ -8,6 +8,7 @@ @testable import Networking import XCTest +@NetworkingActor final class StoredResponseProviderTests: XCTestCase { // swiftlint:disable:next force_unwrapping private lazy var mockUrlRequest = URLRequest(url: URL(string: "https://reqres.in/api/users?page=2")!) diff --git a/Tests/NetworkingTests/URLParametersTests.swift b/Tests/NetworkingTests/URLParametersTests.swift index b090254e..29fe2501 100644 --- a/Tests/NetworkingTests/URLParametersTests.swift +++ b/Tests/NetworkingTests/URLParametersTests.swift @@ -12,7 +12,7 @@ private let baseURLString = "https://requestable.tests" final class URLParametersTests: XCTestCase { enum Router: Requestable { - case urlParameters([String: Any]) + case urlParameters([String: any Sendable]) var baseURL: URL { // swiftlint:disable:next force_unwrapping diff --git a/Tests/NetworkingTests/UploadAPIManagerTests.swift b/Tests/NetworkingTests/UploadAPIManagerTests.swift new file mode 100644 index 00000000..d1de07e8 --- /dev/null +++ b/Tests/NetworkingTests/UploadAPIManagerTests.swift @@ -0,0 +1,62 @@ +// +// UploadAPIManagerTests.swift +// +// +// Created by Matej Molnár on 01.01.2024. +// + +@testable import Networking +import XCTest + +@NetworkingActor +@available(iOS 15.0, *) +final class UploadAPIManagerTests: XCTestCase { + enum UploadRouter: Requestable { + case mock + + var baseURL: URL { + // swiftlint:disable:next force_unwrapping + URL(string: "https://uploadAPIManager.tests")! + } + + var path: String { + "/mock" + } + + var method: HTTPMethod { + .post + } + } + + func testMultiThreadRequests() { + let apiManager = UploadAPIManager( + // A session configuration that uses no persistent storage for caches, cookies, or credentials. + urlSessionConfiguration: .ephemeral + ) + let data = Data("Test data".utf8) + let expectation = XCTestExpectation(description: "Uploads completed") + + Task { + do { + // Create 15 parallel requests on multiple threads to test the manager's thread safety. + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<15 { + group.addTask { + _ = try await apiManager.upload( + .data(data, contentType: "text"), + to: UploadRouter.mock + ) + } + } + + try await group.waitForAll() + expectation.fulfill() + } + } catch { + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: 1) + } +}