diff --git a/Example/AppDelegate.swift b/Example/AppDelegate.swift index af245ae..b964f17 100644 --- a/Example/AppDelegate.swift +++ b/Example/AppDelegate.swift @@ -8,7 +8,7 @@ import UIKit import Combine -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { let controller = NetworkController() diff --git a/Networking.xcodeproj/project.pbxproj b/Networking.xcodeproj/project.pbxproj index 809aeee..8f63db5 100644 --- a/Networking.xcodeproj/project.pbxproj +++ b/Networking.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -234,8 +234,9 @@ F2B5BC1B248836C500B6A52A /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1150; - LastUpgradeCheck = 1150; + LastUpgradeCheck = 1600; ORGANIZATIONNAME = Lickability; TargetAttributes = { F2B5BC22248836C500B6A52A = { @@ -370,6 +371,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -380,6 +382,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -401,6 +404,8 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -430,6 +435,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -440,6 +446,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -454,6 +461,8 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; name = Release; @@ -467,14 +476,14 @@ DEVELOPMENT_TEAM = JL4AKR8DVC; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Example/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = net.lickability.Networking; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -488,14 +497,14 @@ DEVELOPMENT_TEAM = JL4AKR8DVC; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Example/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = net.lickability.Networking; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -503,7 +512,6 @@ F2B5BC46248836C600B6A52A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = JL4AKR8DVC; @@ -516,7 +524,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = net.lickability.NetworkingTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Networking.app/Networking"; }; @@ -525,7 +533,6 @@ F2B5BC47248836C600B6A52A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = JL4AKR8DVC; @@ -538,7 +545,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = net.lickability.NetworkingTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Networking.app/Networking"; }; diff --git a/Networking.xcodeproj/xcshareddata/xcschemes/Networking.xcscheme b/Networking.xcodeproj/xcshareddata/xcschemes/Networking.xcscheme index 3a4da30..140c9de 100644 --- a/Networking.xcodeproj/xcshareddata/xcschemes/Networking.xcscheme +++ b/Networking.xcodeproj/xcshareddata/xcschemes/Networking.xcscheme @@ -1,6 +1,6 @@ ) -> Void)?) -> NetworkSessionDataTask { + private func makeDataTask(forURLRequest urlRequest: URLRequest, behaviors: [RequestBehavior] = [], successHTTPStatusCodes: HTTPStatusCodes, completion: (@Sendable (Result) -> Void)?) -> NetworkSessionDataTask { return networkSession.makeDataTask(with: urlRequest) { data, response, error in let result: Result @@ -61,7 +61,7 @@ extension NetworkController: NetworkRequestPerformer { // MARK: - NetworkRequestPerformer - @discardableResult public func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = [], completion: ((Result) -> Void)? = nil) -> NetworkSessionDataTask { + @discardableResult public func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = [], completion: (@Sendable (Result) -> Void)? = nil) -> NetworkSessionDataTask { let behaviors = defaultRequestBehaviors + requestBehaviors let urlRequest = makeFinalizedRequest(fromOriginalRequest: request.urlRequest, behaviors: behaviors) @@ -72,12 +72,13 @@ extension NetworkController: NetworkRequestPerformer { return dataTask } - @available(iOS 13.0, *) - @discardableResult public func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = []) -> AnyPublisher { + @MainActor + @discardableResult public func send(_ request: any NetworkRequest, scheduler: some Scheduler = DispatchQueue.main, requestBehaviors: [RequestBehavior] = []) -> AnyPublisher { let behaviors = defaultRequestBehaviors + requestBehaviors let urlRequest = makeFinalizedRequest(fromOriginalRequest: request.urlRequest, behaviors: behaviors) return networkSession.dataTaskPublisher(for: urlRequest) + .receive(on: scheduler) .mapError { NetworkError.underlyingNetworkingError($0) } .tryMap { data, response in if let statusCode = (response as? HTTPURLResponse)?.statusCode, !request.successHTTPStatusCodes.contains(statusCode: statusCode) { diff --git a/Sources/Networking/NetworkError.swift b/Sources/Networking/NetworkError.swift index 18864e0..5f9fe2d 100644 --- a/Sources/Networking/NetworkError.swift +++ b/Sources/Networking/NetworkError.swift @@ -9,7 +9,7 @@ import Foundation /// Possible errors encountered during networking. -public enum NetworkError: LocalizedError { +public enum NetworkError: LocalizedError, Sendable { // MARK: - NetworkError diff --git a/Sources/Networking/NetworkRequest.swift b/Sources/Networking/NetworkRequest.swift index aec0244..cd2cf51 100644 --- a/Sources/Networking/NetworkRequest.swift +++ b/Sources/Networking/NetworkRequest.swift @@ -9,7 +9,7 @@ import Foundation /// A protocol that defines the parameters that make up a request. -public protocol NetworkRequest: Equatable { +public protocol NetworkRequest: Equatable, Sendable { /// The generated `URLRequest` to use for making network requests. Defaults to a url request built using the receiver’s properties. var urlRequest: URLRequest { get } @@ -37,7 +37,7 @@ public protocol NetworkRequest: Equatable { } /// Represents a collection of possible HTTP status codes. -public enum HTTPStatusCodes: Equatable { +public enum HTTPStatusCodes: Equatable, Sendable { /// All status codes. case all diff --git a/Sources/Networking/NetworkRequestPerformer+JSON.swift b/Sources/Networking/NetworkRequestPerformer+JSON.swift index 10082d6..05cfe30 100644 --- a/Sources/Networking/NetworkRequestPerformer+JSON.swift +++ b/Sources/Networking/NetworkRequestPerformer+JSON.swift @@ -18,7 +18,7 @@ extension NetworkRequestPerformer { /// - decoder: The JSON decoder to use when decoding the data. /// - completion: A completion closure that is called when the request has been completed. /// - Returns: The `NetworkSessionDataTask` used to send the request. The implementation must call `resume()` on the task before returning. - @discardableResult public func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = [], decoder: JSONDecoder = JSONDecoder(), completion: ((Result) -> Void)? = nil) -> NetworkSessionDataTask { + @discardableResult public func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = [], decoder: JSONDecoder = JSONDecoder(), completion: (@Sendable (Result) -> Void)? = nil) -> NetworkSessionDataTask { send(request, requestBehaviors: requestBehaviors) { result in switch result { case let .success(response): diff --git a/Sources/Networking/NetworkRequestPerformer.swift b/Sources/Networking/NetworkRequestPerformer.swift index 96cc2d8..0f2134d 100644 --- a/Sources/Networking/NetworkRequestPerformer.swift +++ b/Sources/Networking/NetworkRequestPerformer.swift @@ -10,7 +10,7 @@ import Foundation import Combine /// A protocol that defines functions needed to perform requests. -public protocol NetworkRequestPerformer { +public protocol NetworkRequestPerformer: Sendable { /// Performs the given request with the given behaviors. /// @@ -19,15 +19,16 @@ public protocol NetworkRequestPerformer { /// - requestBehaviors: The behaviors to apply to the given request. /// - completion: A completion closure that is called when the request has been completed. /// - Returns: The `NetworkSessionDataTask` used to send the request. The implementation must call `resume()` on the task before returning. - @discardableResult func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior], completion: ((Result) -> Void)?) -> NetworkSessionDataTask + @discardableResult func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior], completion: (@Sendable (Result) -> Void)?) -> NetworkSessionDataTask /// Returns a publisher that can be subscribed to, that performs the given request with the given behaviors. /// - Parameters: /// - request: The request to perform. + /// - scheduler: The scheduler to receive the call on. The scheduler passed in must match the `@MainActor` requirement to avoid data races. /// - requestBehaviors: The behaviors to apply to the given request. /// - Returns: Returns a publisher that can be subscribed to, that performs the given request with the given behaviors. - @available(iOS 13.0, *) - @discardableResult func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior]) -> AnyPublisher + @MainActor + @discardableResult func send(_ request: any NetworkRequest, scheduler: some Scheduler, requestBehaviors: [RequestBehavior]) -> AnyPublisher /// Performs the given request with the given behaviors returning a `NetworkResponse` with async/await, or throwing an error if unsuccessful. /// diff --git a/Sources/Networking/NetworkRequestStateController.swift b/Sources/Networking/NetworkRequestStateController.swift index 9a61fad..25b5770 100644 --- a/Sources/Networking/NetworkRequestStateController.swift +++ b/Sources/Networking/NetworkRequestStateController.swift @@ -7,10 +7,10 @@ // import Foundation -import Combine +@preconcurrency import Combine /// A class responsible for representing the state and value of a network request being made. -public final class NetworkRequestStateController { +public final class NetworkRequestStateController: Sendable { /// The state of a network request's lifecycle. public enum NetworkRequestState { @@ -76,43 +76,50 @@ public final class NetworkRequestStateController { } /// A `Publisher` that can be subscribed to in order to receive updates about the status of a request. - public private(set) lazy var publisher: AnyPublisher = { - return requestStatePublisher.prepend(.notInProgress).eraseToAnyPublisher() - }() + public let publisher: AnyPublisher private let requestPerformer: NetworkRequestPerformer - private let requestStatePublisher = PassthroughSubject() - private var cancellables = Set() + private let requestStatePublisher: PassthroughSubject + private let cancellablesQueue = DispatchQueue(label: "net.lickability.Networking.NetworkRequestStateController.cancellable.queue") + nonisolated(unsafe) private var cancellables = Set() /// Initializes the `NetworkRequestStateController` with the specified parameters. /// - Parameter requestPerformer: The `NetworkRequestPerformer` used to make requests. public init(requestPerformer: NetworkRequestPerformer) { + self.requestStatePublisher = PassthroughSubject() self.requestPerformer = requestPerformer + self.publisher = requestStatePublisher.prepend(.notInProgress).eraseToAnyPublisher() } /// Sends a request with the specified parameters. /// - Parameters: /// - request: The request to send. - /// - scheduler: The scheduler to receive the call on. The default value is `DispatchQueue.main`. + /// - scheduler: The scheduler to receive the call on. The scheduler passed in must match the `@MainActor` requirement to avoid data races. The default value is `DispatchQueue.main`. /// - requestBehaviors: Additional behaviors to append to the request. /// - retryCount: The number of times the action can be retried. + @MainActor public func send(request: any NetworkRequest, scheduler: some Scheduler = DispatchQueue.main, requestBehaviors: [RequestBehavior] = [], retryCount: Int = 2) { requestStatePublisher.send(.inProgress) - requestPerformer.send(request, requestBehaviors: requestBehaviors) + let cancellable = requestPerformer.send(request, scheduler: scheduler, requestBehaviors: requestBehaviors) .retry(retryCount) .mapAsResult() .receive(on: scheduler) .sink(receiveValue: { [requestStatePublisher] result in requestStatePublisher.send(.completed(result)) }) - .store(in: &cancellables) + + _ = cancellablesQueue.sync { + cancellables.insert(cancellable) + } } /// Resets the state of the `requestStatePublisher` and cancels any in flight requests that may be ongoing. Cancellation is not guaranteed, and requests that are near completion may end up finishing, despite being cancelled. public func resetState() { - cancellables.forEach { $0.cancel() } - cancellables.removeAll() + cancellablesQueue.sync { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } requestStatePublisher.send(.notInProgress) } diff --git a/Sources/Networking/NetworkResponse.swift b/Sources/Networking/NetworkResponse.swift index caa1fb9..cedeab2 100644 --- a/Sources/Networking/NetworkResponse.swift +++ b/Sources/Networking/NetworkResponse.swift @@ -9,7 +9,7 @@ import Foundation /// A defined structure for a successful network response. -public struct NetworkResponse { +public struct NetworkResponse: Sendable { /// The data contained in the response. public let data: Data? diff --git a/Sources/Networking/NetworkSession.swift b/Sources/Networking/NetworkSession.swift index 897dcbc..594b83d 100644 --- a/Sources/Networking/NetworkSession.swift +++ b/Sources/Networking/NetworkSession.swift @@ -10,7 +10,7 @@ import Foundation import Combine /// Describes an object that coordinates a group of related, network data transfer tasks. This protocol has a similar API to `URLSession` for the purpose of mocking. -public protocol NetworkSession { +public protocol NetworkSession: Sendable { /// Creates a task that retrieves the contents of a URL based on the specified URL request object, and calls a handler upon completion. /// - Parameters: diff --git a/Sources/Networking/NetworkSessionDataTask.swift b/Sources/Networking/NetworkSessionDataTask.swift index 1bb0d32..86af11d 100644 --- a/Sources/Networking/NetworkSessionDataTask.swift +++ b/Sources/Networking/NetworkSessionDataTask.swift @@ -9,7 +9,7 @@ import Foundation /// Describes a network session task that can be performed. This protocol has a similar API to `URLSessionDataTask` for the purpose of mocking. -public protocol NetworkSessionDataTask { +public protocol NetworkSessionDataTask: Sendable { /// Progress object which represents the task progress. It can be used for task progress tracking. /// diff --git a/Sources/Networking/RequestBehavior.swift b/Sources/Networking/RequestBehavior.swift index 747eb8a..1a12e22 100644 --- a/Sources/Networking/RequestBehavior.swift +++ b/Sources/Networking/RequestBehavior.swift @@ -9,7 +9,7 @@ import Foundation /// A protocol that can be used to implement behavior for requests being made. -public protocol RequestBehavior { +public protocol RequestBehavior: Sendable { /// A function that is called before a request is sent. You may modify the request at this time. /// @@ -22,11 +22,6 @@ public protocol RequestBehavior { func requestDidFinish(result: Result) } -public extension RequestBehavior { - func requestWillSend(request: inout URLRequest) { } - func requestDidFinish(result: Result) { } -} - extension Array: RequestBehavior where Element == RequestBehavior { public func requestWillSend(request: inout URLRequest) { forEach { $0.requestWillSend(request: &request) } diff --git a/Tests/MockNetworkSession.swift b/Tests/MockNetworkSession.swift index bc4109f..3a2effb 100644 --- a/Tests/MockNetworkSession.swift +++ b/Tests/MockNetworkSession.swift @@ -10,7 +10,7 @@ import Foundation import Networking /// A mocked version of `NetworkSession` to be used in tests. Allows specification of success or failure cases. -class MockNetworkSession: NetworkSession { +final class MockNetworkSession: NetworkSession { private let result: Result /// Creates a new `MockNetworkSession`. @@ -41,10 +41,13 @@ class MockNetworkSession: NetworkSession { } } -private class MockNetworkSessionDataTask: NetworkSessionDataTask { - private let resumeClosure: () -> Void - - init(closure: @escaping () -> Void) { +private final class MockNetworkSessionDataTask: NetworkSessionDataTask { + + let progress = Progress() + + private let resumeClosure: @Sendable () -> Void + + init(closure: @escaping @Sendable () -> Void) { self.resumeClosure = closure } diff --git a/Tests/NetworkControllerTests.swift b/Tests/NetworkControllerTests.swift index 9ffa09b..5e49918 100644 --- a/Tests/NetworkControllerTests.swift +++ b/Tests/NetworkControllerTests.swift @@ -61,37 +61,39 @@ class NetworkControllerTests: XCTestCase { func testAsyncAwaitBehaviors() async throws { let networkController = NetworkController(networkSession: MockNetworkSession(result: .failure(NetworkError.noResponse))) - - var requestWillSendWasCalled = false - var requestDidFinishWasCalled = false - - let behavior = TestBehavior { - requestWillSendWasCalled = true - XCTAssertFalse(requestDidFinishWasCalled, "We should’ve reached this point before `requestDidFinishWasCalled` became true.") - } didFinishClosure: { - requestDidFinishWasCalled = true + let expectation = expectation(description: "testAsyncAwaitBehaviors") + + let behavior = TestBehavior { willSend in + XCTAssertTrue(willSend) + } didFinishClosure: { didSend in + XCTAssertTrue(didSend) + expectation.fulfill() } - + do { let _: [Photo] = try await networkController.send(PhotoRequest.photosList, requestBehaviors: [behavior]) XCTFail("Should’ve caught an error before reaching here.") + } catch { + if let networkError = error as? NetworkError { + XCTAssertEqual(networkError.localizedDescription, NetworkError.noResponse.localizedDescription) + } else { + XCTFail("Expected to be able to cast error to NetworkError.") + } } - catch { - XCTAssertTrue(requestWillSendWasCalled) - XCTAssertTrue(requestDidFinishWasCalled) - } + + await fulfillment(of: [expectation], timeout: 0.5) } } private struct TestBehavior: RequestBehavior { - let willSendClosure: () -> Void - let didFinishClosure: () -> Void + let willSendClosure: @Sendable (Bool) -> Void + let didFinishClosure: @Sendable (Bool) -> Void func requestWillSend(request: inout URLRequest) { - willSendClosure() + willSendClosure(true) } func requestDidFinish(result: Result) { - didFinishClosure() + didFinishClosure(true) } }