diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 50dd980f..7d75390b 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -553,6 +553,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.strv.networking-sample-app"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -573,6 +574,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.strv.networking-sample-app"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..63931882 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/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/NetworkingSampleApp/NetworkingSampleApp/API/SampleAuthorizationManager.swift b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAuthorizationManager.swift index f85d1624..d9668089 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAuthorizationManager.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAuthorizationManager.swift @@ -8,6 +8,7 @@ import Networking import Foundation +@NetworkingActor final class SampleAuthorizationManager: AuthorizationManaging { // MARK: Public properties let storage: AuthorizationStorageManaging = SampleAuthorizationStorageManager() diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAuthorizationStorageManager.swift b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAuthorizationStorageManager.swift index d1b4c9c4..fdb28646 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/SampleAuthorizationStorageManager.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/SampleAuthorizationStorageManager.swift @@ -7,7 +7,7 @@ import Networking -actor SampleAuthorizationStorageManager: AuthorizationStorageManaging { +final class SampleAuthorizationStorageManager: AuthorizationStorageManaging { private var storage: AuthorizationData? func saveData(_ data: AuthorizationData) async throws { diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/SampleErrorProcessor.swift b/NetworkingSampleApp/NetworkingSampleApp/API/SampleErrorProcessor.swift index dd90d80a..39dfca2f 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/SampleErrorProcessor.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/SampleErrorProcessor.swift @@ -10,8 +10,8 @@ import Foundation /// Maps NetworkError's unacceptableStatusCode 400 error to SampleAPIError. final class SampleErrorProcessor: ErrorProcessing { - private lazy var decoder = JSONDecoder() - + private let decoder = JSONDecoder() + func process(_ error: Error, for endpointRequest: EndpointRequest) -> Error { guard let networkError = error as? NetworkError, case let .unacceptableStatusCode(statusCode, _, response) = networkError, 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/AuthorizationViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationViewModel.swift index 6a16f7ae..c1fb92fc 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Authorization/AuthorizationViewModel.swift @@ -9,7 +9,9 @@ import Foundation import Networking final class AuthorizationViewModel: ObservableObject { + @NetworkingActor private lazy var authManager = SampleAuthorizationManager() + @NetworkingActor private lazy var apiManager: APIManager = { let authorizationInterceptor = AuthorizationTokenInterceptor(authorizationManager: authManager) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift index 9349e11b..9f825828 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Download/DownloadProgressViewModel.swift @@ -19,7 +19,7 @@ final class DownloadProgressViewModel: ObservableObject { } func startObservingDownloadProgress() async { - let stream = DownloadAPIManager.shared.progressStream(for: task) + let stream = await DownloadAPIManager.shared.progressStream(for: task) for try await downloadState in stream { var newState = DownloadProgressState() diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index ebf78050..95ce761f 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -25,11 +25,7 @@ final class FormUploadsViewModel: ObservableObject { return fileName } - private let uploadService: UploadService - - init(uploadService: UploadService = .init()) { - self.uploadService = uploadService - } + private let uploadService = UploadService.shared } extension FormUploadsViewModel { @@ -58,7 +54,7 @@ extension FormUploadsViewModel { // MARK: - Prepare multipartForm data private extension FormUploadsViewModel { func createMultipartFormData() throws -> MultipartFormData { - let multipartFormData = MultipartFormData() + var multipartFormData = MultipartFormData() multipartFormData.append(Data(username.utf8), name: "username-textfield") if let fileUrl { try multipartFormData.append(from: fileUrl, name: "attachment") diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift index c47e14e1..e910e5b6 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadItemViewModel.swift @@ -6,6 +6,8 @@ // import Foundation +// This import suppresses warning: Non-sendable type 'AsyncPublisher>' ... +@preconcurrency import Combine @MainActor final class UploadItemViewModel: ObservableObject { diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 9d745649..761fd6ee 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -8,16 +8,11 @@ import Foundation import Networking -final class UploadService { - private let uploadManager: UploadAPIManaging +@NetworkingActor +final class UploadService: Sendable { + static let shared = UploadService() - init(uploadManager: UploadAPIManaging = UploadAPIManager()) { - self.uploadManager = uploadManager - } - - deinit { - uploadManager.invalidateSession(shouldFinishTasks: false) - } + private let uploadManager = UploadAPIManager() } extension UploadService { @@ -60,7 +55,7 @@ extension UploadService { } func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream { - await uploadManager.stateStream(for: uploadTaskId) + uploadManager.stateStream(for: uploadTaskId) } func pause(taskId: String) async { diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index a07f2485..53e73b5f 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -16,7 +16,7 @@ final class UploadsViewModel: ObservableObject { private let uploadService: UploadService - init(uploadService: UploadService = UploadService()) { + init(uploadService: UploadService = .shared) { self.uploadService = uploadService } } 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 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/Package.swift b/Package.swift index 231c4530..c3c71b45 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,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: "SwiftLintPlugin", package: "SwiftLint")] ), .testTarget( diff --git a/Sources/Networking/Core/APIManager.swift b/Sources/Networking/Core/APIManager.swift index 238fe93c..9ea6e07a 100644 --- a/Sources/Networking/Core/APIManager.swift +++ b/Sources/Networking/Core/APIManager.swift @@ -14,8 +14,8 @@ open class APIManager: APIManaging, Retryable { private let errorProcessors: [ErrorProcessing] private let responseProvider: ResponseProviding private let sessionId: String - internal var retryCounter = Counter() - + let retryCounter = Counter() + public init( urlSession: URLSession = .init(configuration: .default), requestAdapters: [RequestAdapting] = [], @@ -69,7 +69,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 d7766d83..4fcff361 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/DownloadAPIManager.swift b/Sources/Networking/Core/DownloadAPIManager.swift index 4b106848..5ff7a7dd 100644 --- a/Sources/Networking/Core/DownloadAPIManager.swift +++ b/Sources/Networking/Core/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 open class DownloadAPIManager: NSObject, Retryable { @@ -16,10 +17,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 { @@ -116,19 +121,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) @@ -156,47 +159,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 @@ -204,23 +208,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/DownloadAPIManaging.swift b/Sources/Networking/Core/DownloadAPIManaging.swift index 5b0fb33c..e372fb62 100644 --- a/Sources/Networking/Core/DownloadAPIManaging.swift +++ b/Sources/Networking/Core/DownloadAPIManaging.swift @@ -13,7 +13,8 @@ public typealias DownloadResult = (URLSessionDownloadTask, Response) /// A definition of an API layer with methods for handling data downloading. /// Recommended to be used as singleton. /// If you wish to use multiple instances, make sure you manually invalidate url session by calling the `invalidateSession` method. -public protocol DownloadAPIManaging { +@NetworkingActor +public protocol DownloadAPIManaging: Sendable { /// List of all currently ongoing download tasks. var allTasks: [URLSessionDownloadTask] { get async } @@ -38,7 +39,7 @@ public protocol DownloadAPIManaging { /// Provides real time download updates for a given `URLSessionTask` /// - Parameter task: The task whose updates are requested. /// - Returns: An async stream of download states describing the task's download progress. - func progressStream(for task: URLSessionTask) -> AsyncStream + func progressStream(for task: URLSessionTask) async -> AsyncStream } // MARK: - Provide request with default nil resumable data, retry configuration diff --git a/Sources/Networking/Core/EndpointRequest.swift b/Sources/Networking/Core/EndpointRequest.swift index 248ef39f..62488848 100644 --- a/Sources/Networking/Core/EndpointRequest.swift +++ b/Sources/Networking/Core/EndpointRequest.swift @@ -11,7 +11,7 @@ import Foundation // MARK: - Struct wrapping one call to the API endpoint /// 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 ef244efd..ae0a7696 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/MockResponseProvider.swift b/Sources/Networking/Core/MockResponseProvider.swift index 307c471c..efb90a3a 100644 --- a/Sources/Networking/Core/MockResponseProvider.swift +++ b/Sources/Networking/Core/MockResponseProvider.swift @@ -17,6 +17,7 @@ import Foundation // 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 @@ -62,11 +63,11 @@ 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 = 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/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 0b66719f..d2ecda94 100644 --- a/Sources/Networking/Core/Requestable.swift +++ b/Sources/Networking/Core/Requestable.swift @@ -11,7 +11,7 @@ import Foundation // MARK: - Endpoint definition /// A type that represents an API endpoint. -public protocol Requestable: EndpointIdentifiable { +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/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/Upload/MultipartFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift index 043b0c09..d647eea7 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 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 d5fc6b76..8f3929fa 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 501d85d5..9bdb0a98 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -9,21 +9,18 @@ import Combine import Foundation /// Default upload API manager -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, @@ -61,7 +58,7 @@ open class UploadAPIManager: NSObject { // MARK: URLSessionDataDelegate extension UploadAPIManager: URLSessionDataDelegate { - public func urlSession( + nonisolated public func urlSession( _ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data @@ -93,7 +90,7 @@ extension UploadAPIManager: URLSessionDataDelegate { // MARK: - URLSessionTaskDelegate extension UploadAPIManager: URLSessionTaskDelegate { - public func urlSession( + nonisolated public func urlSession( _ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, @@ -107,7 +104,7 @@ extension UploadAPIManager: URLSessionTaskDelegate { } } - public func urlSession( + nonisolated public func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? @@ -130,8 +127,8 @@ extension UploadAPIManager: URLSessionTaskDelegate { } // MARK: - UploadAPIManaging -extension UploadAPIManager: UploadAPIManaging { - public func invalidateSession(shouldFinishTasks: Bool) { +public extension UploadAPIManager { + func invalidateSession(shouldFinishTasks: Bool) { if shouldFinishTasks { urlSession.finishTasksAndInvalidate() } else { @@ -139,7 +136,7 @@ extension UploadAPIManager: UploadAPIManaging { } } - public func upload( + func upload( data: Data, to endpoint: Requestable ) async throws -> UploadTask { @@ -150,7 +147,7 @@ extension UploadAPIManager: UploadAPIManaging { ) } - public func upload( + func upload( fromFile fileUrl: URL, to endpoint: Requestable ) async throws -> UploadTask { @@ -161,7 +158,7 @@ extension UploadAPIManager: UploadAPIManaging { ) } - public func upload( + func upload( multipartFormData: MultipartFormData, sizeThreshold: UInt64 = 10_000_000, to endpoint: Requestable @@ -189,15 +186,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, @@ -205,11 +202,8 @@ 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 } @@ -230,14 +224,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 @@ -251,8 +245,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, @@ -279,7 +273,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( @@ -336,11 +330,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 ac0c5d05..77fff3cf 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -8,7 +8,8 @@ import Combine import Foundation -public protocol UploadAPIManaging { +@NetworkingActor +public protocol UploadAPIManaging: Sendable { typealias StateStream = AsyncPublisher> /// Currently active upload tasks. @@ -63,13 +64,13 @@ 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. /// /// The internal implementation uses Apple's delegate pattern which retains a strong reference to the delegate. You must call this method to allow the manager to be released from the memory, otherwise your app will be leaking until your app exits or the session is invalidated. /// - Parameter shouldFinishTasks: Determines whether all outstanding tasks should finish before invalidating the session or be immediately cancelled. - func invalidateSession(shouldFinishTasks: Bool) + func invalidateSession(shouldFinishTasks: Bool) async } public extension UploadAPIManaging { diff --git a/Sources/Networking/Core/Upload/UploadTask+State.swift b/Sources/Networking/Core/Upload/UploadTask+State.swift index 8b5bd2e5..37251b6e 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 35d79f61..fcf34c2e 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -5,11 +5,12 @@ // Created by Tony Ngo on 12.06.2023. // -import Combine +// The @preconcurrency suppresses non-sendable warning for CurrentValueSubject. +@preconcurrency 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 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 0417013e..43b5600c 100644 --- a/Sources/Networking/Misc/URLSessionTask+DownloadState.swift +++ b/Sources/Networking/Misc/URLSessionTask+DownloadState.swift @@ -8,7 +8,7 @@ import Foundation public extension URLSessionTask { - 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/AuthorizationData.swift b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationData.swift index e6d0a6b5..45e2cc56 100644 --- a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationData.swift +++ b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationData.swift @@ -7,7 +7,7 @@ import Foundation -public struct AuthorizationData { +public struct AuthorizationData: Sendable { public let accessToken: String public let refreshToken: String public let expiresIn: Date? diff --git a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationManaging.swift b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationManaging.swift index f177adb9..36f8dc02 100644 --- a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationManaging.swift +++ b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationManaging.swift @@ -9,7 +9,8 @@ import Foundation /// AuthorizationManaging authorizes requests and manages refresh token mechanism /// AuthorizationStorageManaging is required to read & store `AuthorizationData` -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 4939e932..042111cf 100644 --- a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationStorageManaging.swift +++ b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationStorageManaging.swift @@ -9,7 +9,8 @@ import Foundation /// Basic operations to store `AuthorizationData` /// To keep consistency all operations are async -public protocol AuthorizationStorageManaging { +@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 2c2c5d29..d3e302c6 100644 --- a/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationTokenInterceptor.swift +++ b/Sources/Networking/Modifiers/Interceptors/Authorization/AuthorizationTokenInterceptor.swift @@ -8,7 +8,7 @@ import Foundation // MARK: - Defines authentication handling in requests -public final actor AuthorizationTokenInterceptor: RequestInterceptor { +open class AuthorizationTokenInterceptor: RequestInterceptor { private var authorizationManager: AuthorizationManaging private var refreshTask: Task? @@ -79,10 +79,10 @@ private extension AuthorizationTokenInterceptor { /// Perform the actual refresh logic. try await self?.authorizationManager.refreshAuthorizationData() /// Make sure to clear refreshTask property after refreshing finishes. - await self?.clearRefreshTask() + self?.clearRefreshTask() } catch { /// Make sure to clear refreshTask property after refreshing finishes. - await self?.clearRefreshTask() + self?.clearRefreshTask() throw error } } diff --git a/Sources/Networking/Modifiers/Interceptors/LoggingInterceptor.swift b/Sources/Networking/Modifiers/Interceptors/LoggingInterceptor.swift index 4cb36cff..2ef7327e 100644 --- a/Sources/Networking/Modifiers/Interceptors/LoggingInterceptor.swift +++ b/Sources/Networking/Modifiers/Interceptors/LoggingInterceptor.swift @@ -26,7 +26,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 } @@ -37,7 +37,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 } @@ -47,7 +47,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 3b02cfcc..2bf95a46 100644 --- a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageModel.swift +++ b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageModel.swift @@ -10,7 +10,7 @@ import Foundation // MARK: - Defines data model storing full endpoint request /// 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 91ec57a2..315c1ec3 100644 --- a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageProcessor.swift +++ b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/EndpointRequestStorageProcessor.swift @@ -11,6 +11,7 @@ import Foundation import os #else import OSLog + import UIKit #endif // MARK: - Modifier storing endpoint requests @@ -24,23 +25,37 @@ open class EndpointRequestStorageProcessor: ResponseProcessing, ErrorProcessing private let fileManager: FileManager private let jsonEncoder: JSONEncoder private let config: Config - 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 { + + // This would ideally also be a lazy var, however it has to be async, because UIDevice.current.name needs to be called on MainActor. + private var _multipeerConnectivityManager: MultipeerConnectivityManager? + private var multipeerConnectivityManager: MultipeerConnectivityManager? { + get async { + // Initialise only in DEBUG mode otherwise it could pose a security risk for production apps. + #if DEBUG + guard _multipeerConnectivityManager == nil else { + return _multipeerConnectivityManager + } + + guard let multiPeerSharingConfig = config.multiPeerSharing else { + return nil + } + + let initialBuffer = multiPeerSharingConfig.shareHistory ? getAllStoredModels() : [] + + _multipeerConnectivityManager = MultipeerConnectivityManager( + buffer: initialBuffer, + deviceName: await MainActor.run { UIDevice.current.name } + ) + return _multipeerConnectivityManager + #else return nil + #endif } - - let initialBuffer = multiPeerSharingConfig.shareHistory ? getAllStoredModels() : [] - return .init(buffer: initialBuffer) - #else - return nil - #endif - }() - + + } + // MARK: Default shared instance public static let shared = EndpointRequestStorageProcessor( config: .init( @@ -57,7 +72,7 @@ open class EndpointRequestStorageProcessor: ResponseProcessing, ErrorProcessing self.fileManager = fileManager self.jsonEncoder = jsonEncoder ?? .default self.config = config - + deleteStoredSessionsExceedingLimit() } @@ -101,7 +116,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. @@ -118,7 +133,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 @@ -138,12 +153,12 @@ private extension EndpointRequestStorageProcessor { endpointRequest: EndpointRequest, urlRequest: URLRequest ) { - Task(priority: .background) { [weak self] in + Task.detached(priority: .background) { [weak self] in guard let self else { return } - self.createFolderIfNeeded(endpointRequest.sessionId) + await self.createFolderIfNeeded(endpointRequest.sessionId) // for http responses read headers let httpResponse = response.response as? HTTPURLResponse @@ -183,7 +198,7 @@ private extension EndpointRequestStorageProcessor { fileUrl: self.createFileUrl(endpointRequest) ) - multipeerConnectivityManager?.send(model: storageModel) + await multipeerConnectivityManager?.send(model: storageModel) } } @@ -201,8 +216,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 5475ab58..8cae914e 100644 --- a/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/MultipeerConnectivityManager.swift +++ b/Sources/Networking/Modifiers/Processors/EndpointRequestStorageProcessor/MultipeerConnectivityManager.swift @@ -11,44 +11,48 @@ import OSLog #endif -import MultipeerConnectivity +// @preconcurrency suppresses a swift concurrency warning: Non-sendable type ... +@preconcurrency 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() @@ -84,11 +88,25 @@ 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 { - public func advertiser( + nonisolated public func advertiser( _ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, @@ -100,22 +118,14 @@ extension MultipeerConnectivityManager: MCNearbyServiceAdvertiserDelegate { // 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?) {} } diff --git a/Sources/Networking/Utils/FileManager+Sendable.swift b/Sources/Networking/Utils/FileManager+Sendable.swift new file mode 100644 index 00000000..f591499d --- /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 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 3980fde9..d549e170 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 fileManager = FileManager.default 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/MockResponseProviderTests.swift b/Tests/NetworkingTests/MockResponseProviderTests.swift index c3ca6027..6675b242 100644 --- a/Tests/NetworkingTests/MockResponseProviderTests.swift +++ b/Tests/NetworkingTests/MockResponseProviderTests.swift @@ -8,6 +8,7 @@ @testable import Networking import XCTest +@NetworkingActor final class MockResponseProviderTests: 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/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/UploadAPIManagerTests.swift b/Tests/NetworkingTests/UploadAPIManagerTests.swift new file mode 100644 index 00000000..260350ce --- /dev/null +++ b/Tests/NetworkingTests/UploadAPIManagerTests.swift @@ -0,0 +1,58 @@ +// +// UploadAPIManagerTests.swift +// +// +// Created by Matej Molnár on 01.01.2024. +// + +@testable import Networking +import XCTest + +@NetworkingActor +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, to: UploadRouter.mock) + } + } + + try await group.waitForAll() + expectation.fulfill() + } + } catch { + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: 1) + } +}