From 64629c300231db15d35b804be6c801646c4c7f2d Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Mon, 21 Jul 2025 19:56:45 +0200 Subject: [PATCH 1/9] Starting to do some work Signed-off-by: Henrik Panhans --- Package.resolved | 7 +++--- Package.swift | 2 +- Sources/HPNetwork/ConnectionMonitor.swift | 11 ++++++-- Sources/HPNetwork/NetworkClient.swift | 18 ++++++------- Sources/HPNetwork/Requests/DataRequest.swift | 23 ++++++----------- .../HPNetwork/Requests/DownloadRequest.swift | 13 +++------- .../HPNetwork/Requests/NetworkRequest.swift | 25 ++++++++----------- .../HPNetwork/Responses/NetworkResponse.swift | 2 ++ Sources/HPNetworkMock/NetworkClientMock.swift | 19 ++++++-------- 9 files changed, 54 insertions(+), 66 deletions(-) diff --git a/Package.resolved b/Package.resolved index ec6537f..a144032 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,15 @@ { + "originHash" : "5e622942c76f4d4209500dbb6fff5f6d0f9b9efba8257e394a75bc55998430bc", "pins" : [ { "identity" : "swift-http-types", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", - "version" : "1.3.0" + "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", + "version" : "1.4.0" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index a25a7ed..bcab322 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. // swift-format-ignore-file diff --git a/Sources/HPNetwork/ConnectionMonitor.swift b/Sources/HPNetwork/ConnectionMonitor.swift index a864225..fead77e 100644 --- a/Sources/HPNetwork/ConnectionMonitor.swift +++ b/Sources/HPNetwork/ConnectionMonitor.swift @@ -1,7 +1,7 @@ import Foundation import Network -public final class ConnectionMonitor: ObservableObject { +public final class ConnectionMonitor: ObservableObject, @unchecked Sendable { // MARK: - Properties @@ -42,7 +42,9 @@ public final class ConnectionMonitor: ObservableObject { private func emitNotification(_ path: NWPath) { let userInfo = [ConnectionMonitor.updatedPathKey: path] - currentPath = path + Task { [weak self] in + await self?.updateState(path) + } switch path.status { case .satisfied: @@ -68,4 +70,9 @@ public final class ConnectionMonitor: ObservableObject { } } + @MainActor + private func updateState(_ path: NWPath) { + currentPath = path + } + } diff --git a/Sources/HPNetwork/NetworkClient.swift b/Sources/HPNetwork/NetworkClient.swift index 4937b48..5223b40 100644 --- a/Sources/HPNetwork/NetworkClient.swift +++ b/Sources/HPNetwork/NetworkClient.swift @@ -11,14 +11,12 @@ public protocol NetworkClientProtocol { func result( _ request: Request, delegate: (any URLSessionTaskDelegate)? - ) async -> Request.RequestResult + ) async -> Request.NetworkResult func schedule( _ request: Request, - delegate: (any URLSessionTaskDelegate)?, - finishingQueue: DispatchQueue, - completion: @escaping (Request.RequestResult) -> Void - ) -> Task + delegate: (any URLSessionTaskDelegate)? + ) -> Request.NetworkTask } @@ -44,17 +42,15 @@ public final class NetworkClient: NetworkClientProtocol { public func result( _ request: Request, delegate: (any URLSessionTaskDelegate)? = nil - ) async -> Request.RequestResult { + ) async -> Request.NetworkResult { await request.result(urlSession: urlSession, delegate: delegate) } - public func schedule( + public func schedule( _ request: Request, delegate: (any URLSessionTaskDelegate)? = nil, - finishingQueue: DispatchQueue = .main, - completion: @escaping (Request.RequestResult) -> Void - ) -> Task where Request: NetworkRequest { - request.schedule(urlSession: urlSession, delegate: delegate, finishingQueue: finishingQueue, completion: completion) + ) -> Request.NetworkTask where Request.Output: Sendable { + request.schedule(urlSession: urlSession, delegate: delegate) } } diff --git a/Sources/HPNetwork/Requests/DataRequest.swift b/Sources/HPNetwork/Requests/DataRequest.swift index cca146a..d2b7e55 100644 --- a/Sources/HPNetwork/Requests/DataRequest.swift +++ b/Sources/HPNetwork/Requests/DataRequest.swift @@ -31,7 +31,7 @@ public protocol DataRequest: NetworkRequest { /// - urlSession: The `URLSession` instance to use to execute the request /// - delegate: The delegate to use /// - Returns: The result of the network request - func result(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async -> RequestResult + func result(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async -> NetworkResult /// Executes the request and calls the completion handler with the result. /// - Parameters: @@ -42,10 +42,8 @@ public protocol DataRequest: NetworkRequest { /// - Returns: A cancellable `Task` instance func schedule( urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)?, - finishingQueue: DispatchQueue, - completion: @escaping (RequestResult) -> Void - ) -> Task + delegate: (any URLSessionTaskDelegate)? + ) -> NetworkTask } @@ -99,7 +97,7 @@ extension DataRequest { @discardableResult public func result( urlSession: URLSession, delegate: (any URLSessionTaskDelegate)? - ) async -> RequestResult { + ) async -> NetworkResult { do { let result = try await response(urlSession: urlSession, delegate: delegate) return .success(result) @@ -110,15 +108,10 @@ extension DataRequest { @discardableResult public func schedule( urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)?, - finishingQueue: DispatchQueue = .main, - completion: @escaping (RequestResult) -> Void - ) -> Task { - Task { - let result = await result(urlSession: urlSession, delegate: delegate) - finishingQueue.async { - completion(result) - } + delegate: (any URLSessionTaskDelegate)? + ) -> NetworkTask where Output: Sendable { + NetworkTask { + try await response(urlSession: urlSession, delegate: delegate) } } diff --git a/Sources/HPNetwork/Requests/DownloadRequest.swift b/Sources/HPNetwork/Requests/DownloadRequest.swift index b35132f..9bbe3be 100644 --- a/Sources/HPNetwork/Requests/DownloadRequest.swift +++ b/Sources/HPNetwork/Requests/DownloadRequest.swift @@ -61,7 +61,7 @@ extension DownloadRequest { @discardableResult public func result( urlSession: URLSession, delegate: (any URLSessionTaskDelegate)? - ) async -> RequestResult { + ) async -> NetworkResult { do { let result = try await response(urlSession: urlSession, delegate: delegate) return .success(result) @@ -73,14 +73,9 @@ extension DownloadRequest { public func schedule( urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?, - finishingQueue: DispatchQueue = .main, - completion: @escaping (RequestResult) -> Void - ) -> Task { - Task { - let result = await result(urlSession: urlSession, delegate: delegate) - finishingQueue.async { - completion(result) - } + ) -> NetworkTask where Output: Sendable { + NetworkTask { + try await response(urlSession: urlSession, delegate: delegate) } } diff --git a/Sources/HPNetwork/Requests/NetworkRequest.swift b/Sources/HPNetwork/Requests/NetworkRequest.swift index 03f6ee5..b0dea2f 100644 --- a/Sources/HPNetwork/Requests/NetworkRequest.swift +++ b/Sources/HPNetwork/Requests/NetworkRequest.swift @@ -5,13 +5,16 @@ import HTTPTypesFoundation // MARK: - NetworkRequest /// A base protocol to define network requests. -public protocol NetworkRequest { +public protocol NetworkRequest: Sendable { /// The expected output type returned in the network request. associatedtype Output /// The result of a network request. - typealias RequestResult = Result, Error> + typealias NetworkResult = Result, Error> + + /// A typed task of a network request. + typealias NetworkTask = Task, Error> /// The header fields that will be send with the network request. /// @@ -50,7 +53,7 @@ public protocol NetworkRequest { /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running /// - Returns: a result with either a wrapper object containing an instance of ``Output`` along with the elapsed time for /// both networking and processing in seconds or an error - func result(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async -> RequestResult + func result(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async -> NetworkResult /// Uses all the provided information to create a `URLRequest` and schedules that request. /// - Parameters: @@ -61,10 +64,8 @@ public protocol NetworkRequest { /// - Returns: A task that wraps the running network request func schedule( urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)?, - finishingQueue: DispatchQueue, - completion: @escaping (RequestResult) -> Void - ) -> Task + delegate: (any URLSessionTaskDelegate)? + ) -> NetworkTask /// A method that can be used to validate the response of a network request before any further processing will be attempted. /// @@ -154,7 +155,7 @@ extension NetworkRequest { /// - Parameter urlSession: The `URLSession` instance to use to execute this network request /// - Returns: a result with either a wrapper object containing an instance of ``Output`` along with the elapsed time for /// both networking and processing in seconds or an error - public func result(urlSession: URLSession) async -> RequestResult { + public func result(urlSession: URLSession) async -> NetworkResult { await result(urlSession: urlSession, delegate: nil) } @@ -164,12 +165,8 @@ extension NetworkRequest { /// - finishingQueue: The `DispatchQueue` that the completion handler will be called on /// - completion: The block that will be executed with the result of the network request /// - Returns: A task that wraps the running network request - public func schedule( - urlSession: URLSession, - finishingQueue: DispatchQueue = .main, - completion: @escaping (RequestResult) -> Void - ) -> Task { - schedule(urlSession: urlSession, delegate: nil, finishingQueue: finishingQueue, completion: completion) + public func schedule(urlSession: URLSession) -> NetworkTask { + schedule(urlSession: urlSession, delegate: nil) } } diff --git a/Sources/HPNetwork/Responses/NetworkResponse.swift b/Sources/HPNetwork/Responses/NetworkResponse.swift index 8e17d8e..2c4d87e 100644 --- a/Sources/HPNetwork/Responses/NetworkResponse.swift +++ b/Sources/HPNetwork/Responses/NetworkResponse.swift @@ -37,3 +37,5 @@ public struct NetworkResponse { } } + +extension NetworkResponse: Sendable where Output: Sendable {} diff --git a/Sources/HPNetworkMock/NetworkClientMock.swift b/Sources/HPNetworkMock/NetworkClientMock.swift index 6de25bd..b184f5e 100644 --- a/Sources/HPNetworkMock/NetworkClientMock.swift +++ b/Sources/HPNetworkMock/NetworkClientMock.swift @@ -52,7 +52,7 @@ public final class NetworkClientMock: NetworkClientProtocol { public func result( _ request: Request, delegate: (any URLSessionTaskDelegate)? = nil - ) async -> Request.RequestResult { + ) async -> Request.NetworkResult { do { let response = try await response(request, delegate: delegate) return .success(response) @@ -63,15 +63,10 @@ public final class NetworkClientMock: NetworkClientProtocol { public func schedule( _ request: Request, - delegate: (any URLSessionTaskDelegate)? = nil, - finishingQueue: DispatchQueue = .main, - completion: @escaping (Request.RequestResult) -> Void - ) -> Task { - Task { - let result = await result(request, delegate: delegate) - finishingQueue.async { - completion(result) - } + delegate: (any URLSessionTaskDelegate)? + ) -> Request.NetworkTask where Request.Output: Sendable { + Request.NetworkTask { + try await response(request, delegate: delegate) } } @@ -89,7 +84,9 @@ public final class NetworkClientMock: NetworkClientProtocol { mockedRequests[typeName] = ConcreteMockedRequest(handler: handler) } - private func mockedRequest(forType type: Request.Type) -> ConcreteMockedRequest? { + private func mockedRequest(forType type: Request.Type) + -> ConcreteMockedRequest? + { let typeName = String(describing: type.self) return mockedRequests[typeName] as? ConcreteMockedRequest } From 27245247d557b68ee62b2d717ca8754ae936f700 Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Mon, 21 Jul 2025 22:01:35 +0200 Subject: [PATCH 2/9] Sendability Signed-off-by: Henrik Panhans --- .../Authorization/Authorization.swift | 2 +- Sources/HPNetwork/Requests/DataRequest.swift | 7 +- .../HPNetwork/Requests/NetworkRequest.swift | 7 +- .../HPNetwork/Responses/NetworkResponse.swift | 15 -- Sources/HPNetworkMock/NetworkClientMock.swift | 139 +++++++++++++----- Sources/HPNetworkMock/URLSessionMock.swift | 134 ++++++++++------- Tests/HPNetworkTests/DataRequestTests.swift | 16 +- .../HPNetworkTests/DownloadRequestTests.swift | 23 +-- .../NetworkClientMockTests.swift | 66 +++++---- 9 files changed, 229 insertions(+), 180 deletions(-) diff --git a/Sources/HPNetwork/Authorization/Authorization.swift b/Sources/HPNetwork/Authorization/Authorization.swift index d114a04..e7cc49e 100644 --- a/Sources/HPNetwork/Authorization/Authorization.swift +++ b/Sources/HPNetwork/Authorization/Authorization.swift @@ -1,7 +1,7 @@ import Foundation /// A type that specifies authorization for a network request. -public protocol Authorization { +public protocol Authorization: Sendable { /// The value that the `Authorization` header-field will be set to. var headerString: String { get } diff --git a/Sources/HPNetwork/Requests/DataRequest.swift b/Sources/HPNetwork/Requests/DataRequest.swift index d2b7e55..9d6cdb4 100644 --- a/Sources/HPNetwork/Requests/DataRequest.swift +++ b/Sources/HPNetwork/Requests/DataRequest.swift @@ -37,13 +37,8 @@ public protocol DataRequest: NetworkRequest { /// - Parameters: /// - urlSession: The `URLSession` instance to use to execute the request /// - delegate: The delegate to use - /// - finishingQueue: The `DispatchQueue` that the `completion` will be called on - /// - completion: The completion handler /// - Returns: A cancellable `Task` instance - func schedule( - urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)? - ) -> NetworkTask + func schedule(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) -> NetworkTask } diff --git a/Sources/HPNetwork/Requests/NetworkRequest.swift b/Sources/HPNetwork/Requests/NetworkRequest.swift index b0dea2f..308be25 100644 --- a/Sources/HPNetwork/Requests/NetworkRequest.swift +++ b/Sources/HPNetwork/Requests/NetworkRequest.swift @@ -59,8 +59,6 @@ public protocol NetworkRequest: Sendable { /// - Parameters: /// - urlSession: The `URLSession` instance to use to execute this network request /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running - /// - finishingQueue: The `DispatchQueue` that the completion handler will be called on - /// - completion: The block that will be executed with the result of the network request /// - Returns: A task that wraps the running network request func schedule( urlSession: URLSession, @@ -160,10 +158,7 @@ extension NetworkRequest { } /// Uses all the provided information to create a `URLRequest` and schedules that request. - /// - Parameters: - /// - urlSession: The `URLSession` instance to use to execute this network request - /// - finishingQueue: The `DispatchQueue` that the completion handler will be called on - /// - completion: The block that will be executed with the result of the network request + /// - Parameter urlSession: The `URLSession` instance to use to execute this network request /// - Returns: A task that wraps the running network request public func schedule(urlSession: URLSession) -> NetworkTask { schedule(urlSession: urlSession, delegate: nil) diff --git a/Sources/HPNetwork/Responses/NetworkResponse.swift b/Sources/HPNetwork/Responses/NetworkResponse.swift index 2c4d87e..907e423 100644 --- a/Sources/HPNetwork/Responses/NetworkResponse.swift +++ b/Sources/HPNetwork/Responses/NetworkResponse.swift @@ -21,21 +21,6 @@ public struct NetworkResponse { /// The time that elapsed during the processing of the network request's result. public let processingDuration: TimeInterval - /// Creates a new `NetworkResponse`. - /// - Parameters: - /// - output: The actual output of the network request. - /// - url: The URL that handled the request - /// - response: The original response of the network call. - /// - networkingDuration: The time that elapsed during the actual network request. - /// - processingDuration: The time that elapsed during the processing of the network request's result. - public init(output: Output, url: URL, response: HTTPResponse, networkingDuration: TimeInterval, processingDuration: TimeInterval) { - self.output = output - self.url = url - self.response = response - self.networkingDuration = networkingDuration - self.processingDuration = processingDuration - } - } extension NetworkResponse: Sendable where Output: Sendable {} diff --git a/Sources/HPNetworkMock/NetworkClientMock.swift b/Sources/HPNetworkMock/NetworkClientMock.swift index b184f5e..c47ddef 100644 --- a/Sources/HPNetworkMock/NetworkClientMock.swift +++ b/Sources/HPNetworkMock/NetworkClientMock.swift @@ -1,7 +1,8 @@ import Foundation -import HPNetwork import HTTPTypes +@testable import HPNetwork + public enum NetworkClientMockError: Error { case noMockConfiguredForRequest } @@ -11,48 +12,119 @@ private protocol MockedRequest { typealias RequestHandler = (Request) async throws -> Request.Output } -public final class NetworkClientMock: NetworkClientProtocol { +// MARK: - Actor for Concurrency Safety + +/// Stores all mutable state for NetworkClientMock, ensuring thread safety. +private actor MockedRequestsStore { + + var fallbackToURLSessionIfNoMatchingMock: Bool = true + var urlSession: URLSession = .shared + private var mockedRequests: [String: any MockedRequest] = [:] + + func getFallbackToURLSession() -> Bool { + fallbackToURLSessionIfNoMatchingMock + } + + func setFallbackToURLSession(_ value: Bool) { + fallbackToURLSessionIfNoMatchingMock = value + } + + func getURLSession() -> URLSession { + urlSession + } + + func setURLSession(_ session: URLSession) { + urlSession = session + } + + func removeAllMocks() { + mockedRequests.removeAll() + } + + func mockRequest( + ofType type: Request.Type, + handler: @escaping (Request) async throws -> Request.Output + ) { + let typeName = String(describing: type.self) + mockedRequests[typeName] = NetworkClientMock.ConcreteMockedRequest(handler: handler) + } + + /// Handles the mock request internally and returns the output or nil if not found. + func handleMockedRequest( + for request: Request + ) async throws -> Request.Output? where Request.Output: Sendable { + let typeName = String(describing: Request.self) + guard + let concrete = mockedRequests[typeName] + as? NetworkClientMock.ConcreteMockedRequest + else { + return nil + } + return try await concrete.handler(request) + } + +} + +/// A mockable network client. +public final class NetworkClientMock: NetworkClientProtocol, Sendable { // MARK: - Nested Types // periphery:ignore - private struct ConcreteMockedRequest: MockedRequest { + fileprivate struct ConcreteMockedRequest: MockedRequest { let handler: RequestHandler } // MARK: - Properties - public var fallbackToURLSessionIfNoMatchingMock = true - public var urlSession: URLSession = .shared - private var mockedRequests: [String: any MockedRequest] = [:] + /// All mutable state is stored in this actor for concurrency safety. + private let store = MockedRequestsStore() + + // MARK: - Thread-safe accessors for mutable state + /// Gets the ``fallbackToURLSessionIfNoMatchingMock`` flag. + public func getFallbackToURLSessionIfNoMatchingMock() async -> Bool { + await store.getFallbackToURLSession() + } + /// Sets the ``fallbackToURLSessionIfNoMatchingMock`` flag. + public func setFallbackToURLSessionIfNoMatchingMock(_ value: Bool) async { + await store.setFallbackToURLSession(value) + } + /// Gets the `URLSession`. + public func getURLSession() async -> URLSession { + await store.getURLSession() + } + /// Sets the `URLSession`. + public func setURLSession(_ session: URLSession) async { + await store.setURLSession(session) + } // MARK: - NetworkClientProtocol public func response( _ request: Request, delegate: (any URLSessionTaskDelegate)? = nil - ) async throws -> NetworkResponse { - guard let mockedRequest = mockedRequest(forType: Request.self) else { - if fallbackToURLSessionIfNoMatchingMock { - return try await request.response(urlSession: urlSession, delegate: delegate) - } + ) async throws -> NetworkResponse where Request.Output: Sendable { + if let output = try await store.handleMockedRequest(for: request) { + // swift-format-ignore + return NetworkResponse( + output: output, + url: URL(string: "https://apple.com")!, + response: HTTPResponse(status: .ok, headerFields: HTTPFields()), + networkingDuration: 0.00, + processingDuration: 0.00 + ) + } else if await store.getFallbackToURLSession() { + let session = await store.getURLSession() + return try await request.response(urlSession: session, delegate: delegate) + } else { throw NetworkClientMockError.noMockConfiguredForRequest } - let output = try await mockedRequest.handler(request) - // swift-format-ignore - return NetworkResponse( - output: output, - url: URL(string: "https://apple.com")!, - response: HTTPResponse(status: .ok, headerFields: HTTPFields()), - networkingDuration: 0.00, - processingDuration: 0.00 - ) } public func result( _ request: Request, delegate: (any URLSessionTaskDelegate)? = nil - ) async -> Request.NetworkResult { + ) async -> Request.NetworkResult where Request.Output: Sendable { do { let response = try await response(request, delegate: delegate) return .success(response) @@ -63,32 +135,27 @@ public final class NetworkClientMock: NetworkClientProtocol { public func schedule( _ request: Request, - delegate: (any URLSessionTaskDelegate)? + delegate: (any URLSessionTaskDelegate)? = nil ) -> Request.NetworkTask where Request.Output: Sendable { + // The closure is safe because all mutable state is in the actor Request.NetworkTask { - try await response(request, delegate: delegate) + try await self.response(request, delegate: delegate) } } // MARK: - Mocking - public func removeAllMocks() { - mockedRequests.removeAll() + /// Removes all registered mocks. + public func removeAllMocks() async { + await store.removeAllMocks() } + /// Registers a mock handler for a specific request type. public func mockRequest( ofType type: Request.Type, - handler: @escaping (Request) async throws -> Request.Output - ) { - let typeName = String(describing: type.self) - mockedRequests[typeName] = ConcreteMockedRequest(handler: handler) - } - - private func mockedRequest(forType type: Request.Type) - -> ConcreteMockedRequest? - { - let typeName = String(describing: type.self) - return mockedRequests[typeName] as? ConcreteMockedRequest + handler: @escaping @Sendable (Request) async throws -> Request.Output + ) async where Request.Output: Sendable { + await store.mockRequest(ofType: type, handler: handler) } } diff --git a/Sources/HPNetworkMock/URLSessionMock.swift b/Sources/HPNetworkMock/URLSessionMock.swift index ae5d833..03caf20 100644 --- a/Sources/HPNetworkMock/URLSessionMock.swift +++ b/Sources/HPNetworkMock/URLSessionMock.swift @@ -2,45 +2,99 @@ import Foundation import HPNetwork import HTTPTypes import HTTPTypesFoundation +import Synchronization import XCTest +#if canImport(Testing) +import Testing +#endif + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) public enum URLSessionMockError: Error { case cantCreateURL case noURL case noMockedRequest } +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) public final class URLSessionMock: URLProtocol { + // MARK: - Overridden methods + + public override class func canInit(with request: URLRequest) -> Bool { + true + } + + public override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + public override func startLoading() { + guard let url = request.url else { + XCTFail("URLRequest has no URL") + client?.urlProtocol(self, didFailWithError: URLSessionMockError.noURL) + return + } + guard let mockedRequest = MockedRequestStore.shared.mockedRequest(for: url) else { + XCTFail("No mocked request configured for url \"\(url.absoluteString)\"") + client?.urlProtocol(self, didFailWithError: URLSessionMockError.noMockedRequest) + return + } + + do { + let (data, response) = try mockedRequest.handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + XCTFail("No response returned for url \"\(url.absoluteString)\"") + } + } + + public override func stopLoading() {} + +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +final class MockedRequestStore: Sendable { + // MARK: - Nested Types - private struct MockedNetworkRequest { + public typealias MockedRequestHandler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) + + struct MockedNetworkRequest: Sendable { let url: URL let ignoresQuery: Bool - let handler: (URLRequest) throws -> (Data, HTTPURLResponse) + let handler: MockedRequestHandler let id = UUID() } // MARK: - Properties - private static var mockedRequests: [UUID: MockedNetworkRequest] = [:] + static let shared = MockedRequestStore() + + private let mockedRequests = Mutex([UUID: MockedNetworkRequest]()) // MARK: - Registering Mocks @discardableResult - public static func mockRequest(to url: URL, ignoresQuery: Bool, handler: @escaping (URLRequest) throws -> (Data, HTTPURLResponse)) - -> UUID - { + public func mockRequest( + to url: URL, + ignoresQuery: Bool, + handler: @escaping MockedRequestHandler + ) -> UUID { let mockedRequest = MockedNetworkRequest(url: url, ignoresQuery: ignoresQuery, handler: handler) - mockedRequests[mockedRequest.id] = mockedRequest + mockedRequests.withLock { requests in + requests[mockedRequest.id] = mockedRequest + } return mockedRequest.id } @discardableResult - public static func mockRequest( + public func mockRequest( to urlString: String, ignoresQuery: Bool, - handler: @escaping (URLRequest) throws -> (Data, HTTPURLResponse) + handler: @escaping MockedRequestHandler ) throws -> UUID { guard let url = URL(string: urlString) else { throw URLSessionMockError.cantCreateURL @@ -48,15 +102,19 @@ public final class URLSessionMock: URLProtocol { return mockRequest(to: url, ignoresQuery: ignoresQuery, handler: handler) } - public static func unregisterMockedRequest(with id: UUID) { - mockedRequests[id] = nil + public func unregisterMockedRequest(with id: UUID) { + mockedRequests.withLock { requests in + requests[id] = nil + } } - public static func unregisterAllMockedRequests() { - mockedRequests.removeAll() + public func unregisterAllMockedRequests() { + mockedRequests.withLock { requests in + requests.removeAll() + } } - private static func mockedRequest(for url: URL) -> MockedNetworkRequest? { + func mockedRequest(for url: URL) -> MockedNetworkRequest? { guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } @@ -64,48 +122,16 @@ public final class URLSessionMock: URLProtocol { urlComponents.query = nil let urlWithoutQuery = urlComponents.url - return mockedRequests.values.first { request in - if request.url == url { - return true - } else if request.ignoresQuery, let urlWithoutQuery { - return request.url == urlWithoutQuery + return mockedRequests.withLock { requests in + requests.values.first { request in + if request.url == url { + return true + } else if request.ignoresQuery, let urlWithoutQuery { + return request.url == urlWithoutQuery + } + return false } - return false - } - } - - // MARK: - Overridden methods - - public override class func canInit(with request: URLRequest) -> Bool { - true - } - - public override class func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - public override func startLoading() { - guard let url = request.url else { - XCTFail("URLRequest has no URL") - client?.urlProtocol(self, didFailWithError: URLSessionMockError.noURL) - return - } - guard let mockedRequest = Self.mockedRequest(for: url) else { - XCTFail("No mocked request configured for url \"\(url.absoluteString)\"") - client?.urlProtocol(self, didFailWithError: URLSessionMockError.noMockedRequest) - return - } - - do { - let (data, response) = try mockedRequest.handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - XCTFail("No response returned for url \"\(url.absoluteString)\"") } } - public override func stopLoading() {} - } diff --git a/Tests/HPNetworkTests/DataRequestTests.swift b/Tests/HPNetworkTests/DataRequestTests.swift index 0943ee4..e2adaba 100644 --- a/Tests/HPNetworkTests/DataRequestTests.swift +++ b/Tests/HPNetworkTests/DataRequestTests.swift @@ -3,6 +3,7 @@ import XCTest @testable import HPNetwork @testable import HPNetworkMock +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) final class DataRequestTests: XCTestCase { // MARK: - Properties @@ -43,23 +44,14 @@ final class DataRequestTests: XCTestCase { mockNetworkRequest(url: url, dataToReturn: "{}".data(using: .utf8)) let request = BasicDecodableRequest(url: url) - let expection = XCTestExpectation(description: "Networking finished") - _ = networkClient.schedule(request) { result in - expection.fulfill() - switch result { - case .success(let response): - XCTAssertEqual(response.output, EmptyStruct()) - case .failure(let error): - XCTFail(error.localizedDescription) - } - } - await fulfillment(of: [expection], timeout: 10) + let response = try await networkClient.schedule(request).value + XCTAssertEqual(response.output, EmptyStruct()) } // MARK: - Helpers private func mockNetworkRequest(url: URL, dataToReturn data: Data?) { - _ = URLSessionMock.mockRequest(to: url, ignoresQuery: false) { _ in + MockedRequestStore.shared.mockRequest(to: url, ignoresQuery: false) { _ in let response = HTTPURLResponse( url: url, statusCode: 200, diff --git a/Tests/HPNetworkTests/DownloadRequestTests.swift b/Tests/HPNetworkTests/DownloadRequestTests.swift index 2bd27e6..c9d0e37 100644 --- a/Tests/HPNetworkTests/DownloadRequestTests.swift +++ b/Tests/HPNetworkTests/DownloadRequestTests.swift @@ -3,6 +3,7 @@ import XCTest @testable import HPNetwork @testable import HPNetworkMock +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) final class DownloadRequestTests: XCTestCase { // MARK: - Properties @@ -57,29 +58,15 @@ final class DownloadRequestTests: XCTestCase { mockNetworkRequest(url: url, dataToReturn: jsonString.data(using: .utf8)) let request = BasicDownloadRequest(url: url) - let expection = XCTestExpectation(description: "Networking finished") - _ = networkClient.schedule(request) { [weak self] result in - expection.fulfill() - switch result { - case .success(let response): - self?.fileURL = response.output - do { - let downloadedContents = try String(contentsOf: response.output) - XCTAssertEqual(downloadedContents, self?.jsonString) - } catch { - XCTFail(error.localizedDescription) - } - case .failure(let error): - XCTFail(error.localizedDescription) - } - } - await fulfillment(of: [expection], timeout: 10) + let response = try await networkClient.schedule(request).value + let downloadedContents = try String(contentsOf: response.output) + XCTAssertEqual(downloadedContents, jsonString) } // MARK: - Helpers private func mockNetworkRequest(url: URL, dataToReturn data: Data?) { - _ = URLSessionMock.mockRequest(to: url, ignoresQuery: false) { _ in + MockedRequestStore.shared.mockRequest(to: url, ignoresQuery: false) { _ in let response = HTTPURLResponse( url: url, statusCode: 200, diff --git a/Tests/HPNetworkTests/NetworkClientMockTests.swift b/Tests/HPNetworkTests/NetworkClientMockTests.swift index f4ea6eb..cb27f22 100644 --- a/Tests/HPNetworkTests/NetworkClientMockTests.swift +++ b/Tests/HPNetworkTests/NetworkClientMockTests.swift @@ -9,16 +9,12 @@ class NetworkClientMockTests: XCTestCase { let url = URL(string: "https://ipapi.co/json")! - lazy var networkClient: NetworkClientMock = { - let client = NetworkClientMock() - client.fallbackToURLSessionIfNoMatchingMock = false - return client - }() - // MARK: - Tests func testBasicRequest_Async_Mocked() async throws { - networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + let networkClient = await makeNetworkClient() + + await networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in 32 } @@ -28,6 +24,8 @@ class NetworkClientMockTests: XCTestCase { } func testBasicRequest_Async_Unmocked() async throws { + let networkClient = await makeNetworkClient() + let request = BasicDecodableRequest(url: url) do { _ = try await networkClient.response(request, delegate: nil) @@ -38,7 +36,9 @@ class NetworkClientMockTests: XCTestCase { } func testBasicRequest_Result_Mocked() async throws { - networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + let networkClient = await makeNetworkClient() + + await networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in 32 } @@ -52,6 +52,8 @@ class NetworkClientMockTests: XCTestCase { } func testBasicRequest_Result_Unmocked() async throws { + let networkClient = await makeNetworkClient() + let request = BasicDecodableRequest(url: url) switch await networkClient.result(request) { case .success: @@ -62,41 +64,33 @@ class NetworkClientMockTests: XCTestCase { } func testBasicRequest_Completion_Mocked() async throws { - networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + let networkClient = await makeNetworkClient() + + await networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in 32 } let request = BasicDecodableRequest(url: url) - let expection = XCTestExpectation(description: "Networking finished") - _ = networkClient.schedule(request) { result in - expection.fulfill() - switch result { - case .success(let response): - XCTAssertEqual(response.output, 32) - case .failure(let error): - XCTFail(error.localizedDescription) - } - } - await fulfillment(of: [expection], timeout: 10) + let response = try await networkClient.schedule(request).value + XCTAssertEqual(response.output, 32) } func testBasicRequest_Completion_Unmocked() async throws { + let networkClient = await makeNetworkClient() + let request = BasicDecodableRequest(url: url) - let expection = XCTestExpectation(description: "Networking finished") - _ = networkClient.schedule(request) { result in - expection.fulfill() - switch result { - case .success: - XCTFail("Request should not succeed") - case .failure(let error): - XCTAssertEqual(error as? NetworkClientMockError, .noMockConfiguredForRequest) - } + do { + _ = try await networkClient.schedule(request).value + XCTFail("Request should not succeed") + } catch { + XCTAssertEqual(error as? NetworkClientMockError, .noMockConfiguredForRequest) } - await fulfillment(of: [expection], timeout: 10) } func testNetworkClientMock_RemovesAllMocks() async throws { - networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + let networkClient = await makeNetworkClient() + + await networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in 32 } @@ -104,7 +98,7 @@ class NetworkClientMockTests: XCTestCase { let response = try await networkClient.response(request, delegate: nil) XCTAssertEqual(response.output, 32) - networkClient.removeAllMocks() + await networkClient.removeAllMocks() do { _ = try await networkClient.response(request, delegate: nil) @@ -114,4 +108,12 @@ class NetworkClientMockTests: XCTestCase { } } + // MARK: - Helpers + + private func makeNetworkClient() async -> NetworkClientMock { + let client = NetworkClientMock() + await client.setFallbackToURLSessionIfNoMatchingMock(false) + return client + } + } From e3e2b89430946f6ba34ece319759bf091c75f759 Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Mon, 21 Jul 2025 22:04:27 +0200 Subject: [PATCH 3/9] Downgrade to swift-tools 6.0 Signed-off-by: Henrik Panhans --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index bcab322..df98b8a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.1 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. // swift-format-ignore-file From 3e607095824daea1e9d5ae1bd0d75e00839280af Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Mon, 21 Jul 2025 22:07:44 +0200 Subject: [PATCH 4/9] Fix trailing commas Signed-off-by: Henrik Panhans --- Sources/HPNetwork/NetworkClient.swift | 2 +- Sources/HPNetwork/Requests/DownloadRequest.swift | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Sources/HPNetwork/NetworkClient.swift b/Sources/HPNetwork/NetworkClient.swift index 5223b40..e373418 100644 --- a/Sources/HPNetwork/NetworkClient.swift +++ b/Sources/HPNetwork/NetworkClient.swift @@ -48,7 +48,7 @@ public final class NetworkClient: NetworkClientProtocol { public func schedule( _ request: Request, - delegate: (any URLSessionTaskDelegate)? = nil, + delegate: (any URLSessionTaskDelegate)? = nil ) -> Request.NetworkTask where Request.Output: Sendable { request.schedule(urlSession: urlSession, delegate: delegate) } diff --git a/Sources/HPNetwork/Requests/DownloadRequest.swift b/Sources/HPNetwork/Requests/DownloadRequest.swift index 9bbe3be..2c9b6fb 100644 --- a/Sources/HPNetwork/Requests/DownloadRequest.swift +++ b/Sources/HPNetwork/Requests/DownloadRequest.swift @@ -70,10 +70,7 @@ extension DownloadRequest { } } - public func schedule( - urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)?, - ) -> NetworkTask where Output: Sendable { + public func schedule(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) -> NetworkTask where Output: Sendable { NetworkTask { try await response(urlSession: urlSession, delegate: delegate) } From 2765ad109f26e784bdcdeaf9875c41447ae17484 Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Mon, 21 Jul 2025 22:31:04 +0200 Subject: [PATCH 5/9] Some scripts fixes Signed-off-by: Henrik Panhans --- .github/workflows/swift.yml | 8 ++++---- Scripts/format-swift-code | 2 +- Scripts/lint-swift-code | 2 +- Sources/HPNetworkMock/URLSessionMock.swift | 1 - Tests/HPNetworkTests/HTTPFieldBuilderTests.swift | 1 - 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index db3d5db..9578fdb 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -20,7 +20,7 @@ on: jobs: test-swift: name: Test Swift Code - runs-on: macos-14 + runs-on: macos-15 steps: - name: Configure Xcode uses: maxim-lobanov/setup-xcode@v1 @@ -51,7 +51,7 @@ jobs: lint-code: name: Lint Swift Code - runs-on: macos-14 + runs-on: macos-15 steps: - name: Configure Xcode uses: maxim-lobanov/setup-xcode@v1 @@ -67,8 +67,8 @@ jobs: restore-keys: | ${{ runner.os }}-spm- - name: Install SwiftLint - run: brew install swift-format peripheryapp/periphery/periphery + run: brew install peripheryapp/periphery/periphery - name: Lint code run: Scripts/lint-swift-code - name: Scan for dead code - run: periphery scan --strict --config config/periphery.yml + run: periphery scan --config config/periphery.yml --relative-results --format github-actions diff --git a/Scripts/format-swift-code b/Scripts/format-swift-code index 4e66902..1c41729 100755 --- a/Scripts/format-swift-code +++ b/Scripts/format-swift-code @@ -1,6 +1,6 @@ #!/usr/bin/env bash -swift-format format \ +xcrun swift-format format \ --recursive \ --parallel \ --in-place \ diff --git a/Scripts/lint-swift-code b/Scripts/lint-swift-code index 9af98be..ba8253b 100755 --- a/Scripts/lint-swift-code +++ b/Scripts/lint-swift-code @@ -1,6 +1,6 @@ #!/usr/bin/env bash -swift-format lint \ +xcrun swift-format lint \ --recursive \ --parallel \ --strict \ diff --git a/Sources/HPNetworkMock/URLSessionMock.swift b/Sources/HPNetworkMock/URLSessionMock.swift index 03caf20..39f1235 100644 --- a/Sources/HPNetworkMock/URLSessionMock.swift +++ b/Sources/HPNetworkMock/URLSessionMock.swift @@ -1,5 +1,4 @@ import Foundation -import HPNetwork import HTTPTypes import HTTPTypesFoundation import Synchronization diff --git a/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift b/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift index 70d8554..a2c42c9 100644 --- a/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift +++ b/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift @@ -1,7 +1,6 @@ import XCTest @testable import HPNetwork -@testable import HPNetworkMock final class HTTPFieldBuilderTests: XCTestCase { From 1dcb0b7f5d8f2be2a26ec7504a3268f007689a66 Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Tue, 22 Jul 2025 22:10:33 +0200 Subject: [PATCH 6/9] Cleaner sendability Signed-off-by: Henrik Panhans --- .../HPNetworkMock/MockedRequestStore.swift | 90 +++++++++++++++++++ .../HPNetworkMock/MockedRequestsStore.swift | 39 ++++++++ Sources/HPNetworkMock/NetworkClientMock.swift | 86 +++--------------- Sources/HPNetworkMock/URLSessionMock.swift | 83 +---------------- .../NetworkClientMockTests.swift | 4 +- 5 files changed, 144 insertions(+), 158 deletions(-) create mode 100644 Sources/HPNetworkMock/MockedRequestStore.swift create mode 100644 Sources/HPNetworkMock/MockedRequestsStore.swift diff --git a/Sources/HPNetworkMock/MockedRequestStore.swift b/Sources/HPNetworkMock/MockedRequestStore.swift new file mode 100644 index 0000000..2b7fa92 --- /dev/null +++ b/Sources/HPNetworkMock/MockedRequestStore.swift @@ -0,0 +1,90 @@ +// +// MockedRequestStore.swift +// HPNetwork +// +// Created by Henrik Panhans on 22.07.25. +// + +import Foundation +import Synchronization + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +final class MockedRequestStore: Sendable { + + // MARK: - Nested Types + + public typealias MockedRequestHandler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) + + struct MockedNetworkRequest: Sendable { + let url: URL + let ignoresQuery: Bool + let handler: MockedRequestHandler + let id = UUID() + } + + // MARK: - Properties + + static let shared = MockedRequestStore() + + private let mockedRequests = Mutex([UUID: MockedNetworkRequest]()) + + // MARK: - Registering Mocks + + @discardableResult + public func mockRequest( + to url: URL, + ignoresQuery: Bool, + handler: @escaping MockedRequestHandler + ) -> UUID { + let mockedRequest = MockedNetworkRequest(url: url, ignoresQuery: ignoresQuery, handler: handler) + mockedRequests.withLock { requests in + requests[mockedRequest.id] = mockedRequest + } + return mockedRequest.id + } + + @discardableResult + public func mockRequest( + to urlString: String, + ignoresQuery: Bool, + handler: @escaping MockedRequestHandler + ) throws(URLSessionMockError) -> UUID { + guard let url = URL(string: urlString) else { + throw URLSessionMockError.cantCreateURL + } + return mockRequest(to: url, ignoresQuery: ignoresQuery, handler: handler) + } + + public func unregisterMockedRequest(with id: UUID) { + mockedRequests.withLock { requests in + requests[id] = nil + } + } + + public func unregisterAllMockedRequests() { + mockedRequests.withLock { requests in + requests.removeAll() + } + } + + func mockedRequest(for url: URL) -> MockedNetworkRequest? { + guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + urlComponents.query = nil + let urlWithoutQuery = urlComponents.url + + return mockedRequests.withLock { requests in + requests.values.first { request in + if request.url == url { + return true + } else if request.ignoresQuery, let urlWithoutQuery { + return request.url == urlWithoutQuery + } + return false + } + } + } + +} diff --git a/Sources/HPNetworkMock/MockedRequestsStore.swift b/Sources/HPNetworkMock/MockedRequestsStore.swift new file mode 100644 index 0000000..25a8df5 --- /dev/null +++ b/Sources/HPNetworkMock/MockedRequestsStore.swift @@ -0,0 +1,39 @@ +// +// MockedRequestsStore.swift +// HPNetwork +// +// Created by Henrik Panhans on 22.07.25. +// + +import Foundation + +@testable import HPNetwork + +/// Stores all mutable state for NetworkClientMock, ensuring thread safety. +actor MockedRequestsStore { + + // MARK: - Properties + + private var mockedRequests: [String: any MockedRequest] = [:] + + // MARK: - Methods + + func removeAllMocks() { + mockedRequests.removeAll() + } + + func mockRequest(ofType type: Request.Type, handler: @escaping (Request) async throws -> Request.Output) { + let typeName = String(describing: type.self) + mockedRequests[typeName] = NetworkClientMock.ConcreteMockedRequest(handler: handler) + } + + /// Handles the mock request internally and returns the output or nil if not found. + func handleMockedRequest(for request: Request) async throws -> Request.Output? where Request.Output: Sendable { + let typeName = String(describing: Request.self) + guard let concrete = mockedRequests[typeName] as? NetworkClientMock.ConcreteMockedRequest else { + return nil + } + return try await concrete.handler(request) + } + +} diff --git a/Sources/HPNetworkMock/NetworkClientMock.swift b/Sources/HPNetworkMock/NetworkClientMock.swift index c47ddef..7a82111 100644 --- a/Sources/HPNetworkMock/NetworkClientMock.swift +++ b/Sources/HPNetworkMock/NetworkClientMock.swift @@ -7,95 +7,34 @@ public enum NetworkClientMockError: Error { case noMockConfiguredForRequest } -private protocol MockedRequest { +protocol MockedRequest { associatedtype Request: NetworkRequest typealias RequestHandler = (Request) async throws -> Request.Output } -// MARK: - Actor for Concurrency Safety - -/// Stores all mutable state for NetworkClientMock, ensuring thread safety. -private actor MockedRequestsStore { - - var fallbackToURLSessionIfNoMatchingMock: Bool = true - var urlSession: URLSession = .shared - private var mockedRequests: [String: any MockedRequest] = [:] - - func getFallbackToURLSession() -> Bool { - fallbackToURLSessionIfNoMatchingMock - } - - func setFallbackToURLSession(_ value: Bool) { - fallbackToURLSessionIfNoMatchingMock = value - } - - func getURLSession() -> URLSession { - urlSession - } - - func setURLSession(_ session: URLSession) { - urlSession = session - } - - func removeAllMocks() { - mockedRequests.removeAll() - } - - func mockRequest( - ofType type: Request.Type, - handler: @escaping (Request) async throws -> Request.Output - ) { - let typeName = String(describing: type.self) - mockedRequests[typeName] = NetworkClientMock.ConcreteMockedRequest(handler: handler) - } - - /// Handles the mock request internally and returns the output or nil if not found. - func handleMockedRequest( - for request: Request - ) async throws -> Request.Output? where Request.Output: Sendable { - let typeName = String(describing: Request.self) - guard - let concrete = mockedRequests[typeName] - as? NetworkClientMock.ConcreteMockedRequest - else { - return nil - } - return try await concrete.handler(request) - } - -} - /// A mockable network client. public final class NetworkClientMock: NetworkClientProtocol, Sendable { // MARK: - Nested Types // periphery:ignore - fileprivate struct ConcreteMockedRequest: MockedRequest { + struct ConcreteMockedRequest: MockedRequest { let handler: RequestHandler } // MARK: - Properties + public let urlSession: URLSession + public let fallbackToURLSessionIfNoMatchingMock: Bool + /// All mutable state is stored in this actor for concurrency safety. private let store = MockedRequestsStore() - // MARK: - Thread-safe accessors for mutable state - /// Gets the ``fallbackToURLSessionIfNoMatchingMock`` flag. - public func getFallbackToURLSessionIfNoMatchingMock() async -> Bool { - await store.getFallbackToURLSession() - } - /// Sets the ``fallbackToURLSessionIfNoMatchingMock`` flag. - public func setFallbackToURLSessionIfNoMatchingMock(_ value: Bool) async { - await store.setFallbackToURLSession(value) - } - /// Gets the `URLSession`. - public func getURLSession() async -> URLSession { - await store.getURLSession() - } - /// Sets the `URLSession`. - public func setURLSession(_ session: URLSession) async { - await store.setURLSession(session) + // MARK: - Init + + public init(urlSession: URLSession = .shared, fallbackToURLSessionIfNoMatchingMock: Bool = false) { + self.urlSession = urlSession + self.fallbackToURLSessionIfNoMatchingMock = fallbackToURLSessionIfNoMatchingMock } // MARK: - NetworkClientProtocol @@ -113,9 +52,8 @@ public final class NetworkClientMock: NetworkClientProtocol, Sendable { networkingDuration: 0.00, processingDuration: 0.00 ) - } else if await store.getFallbackToURLSession() { - let session = await store.getURLSession() - return try await request.response(urlSession: session, delegate: delegate) + } else if fallbackToURLSessionIfNoMatchingMock { + return try await request.response(urlSession: urlSession, delegate: delegate) } else { throw NetworkClientMockError.noMockConfiguredForRequest } diff --git a/Sources/HPNetworkMock/URLSessionMock.swift b/Sources/HPNetworkMock/URLSessionMock.swift index 39f1235..9bbe9d4 100644 --- a/Sources/HPNetworkMock/URLSessionMock.swift +++ b/Sources/HPNetworkMock/URLSessionMock.swift @@ -8,6 +8,7 @@ import XCTest import Testing #endif +/// An error that can be thrown by ``URLSessionMock``. @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) public enum URLSessionMockError: Error { case cantCreateURL @@ -15,6 +16,7 @@ public enum URLSessionMockError: Error { case noMockedRequest } +/// A class that can be used to mock and handle network requests. @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) public final class URLSessionMock: URLProtocol { @@ -53,84 +55,3 @@ public final class URLSessionMock: URLProtocol { public override func stopLoading() {} } - -@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) -final class MockedRequestStore: Sendable { - - // MARK: - Nested Types - - public typealias MockedRequestHandler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) - - struct MockedNetworkRequest: Sendable { - let url: URL - let ignoresQuery: Bool - let handler: MockedRequestHandler - let id = UUID() - } - - // MARK: - Properties - - static let shared = MockedRequestStore() - - private let mockedRequests = Mutex([UUID: MockedNetworkRequest]()) - - // MARK: - Registering Mocks - - @discardableResult - public func mockRequest( - to url: URL, - ignoresQuery: Bool, - handler: @escaping MockedRequestHandler - ) -> UUID { - let mockedRequest = MockedNetworkRequest(url: url, ignoresQuery: ignoresQuery, handler: handler) - mockedRequests.withLock { requests in - requests[mockedRequest.id] = mockedRequest - } - return mockedRequest.id - } - - @discardableResult - public func mockRequest( - to urlString: String, - ignoresQuery: Bool, - handler: @escaping MockedRequestHandler - ) throws -> UUID { - guard let url = URL(string: urlString) else { - throw URLSessionMockError.cantCreateURL - } - return mockRequest(to: url, ignoresQuery: ignoresQuery, handler: handler) - } - - public func unregisterMockedRequest(with id: UUID) { - mockedRequests.withLock { requests in - requests[id] = nil - } - } - - public func unregisterAllMockedRequests() { - mockedRequests.withLock { requests in - requests.removeAll() - } - } - - func mockedRequest(for url: URL) -> MockedNetworkRequest? { - guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return nil - } - - urlComponents.query = nil - let urlWithoutQuery = urlComponents.url - - return mockedRequests.withLock { requests in - requests.values.first { request in - if request.url == url { - return true - } else if request.ignoresQuery, let urlWithoutQuery { - return request.url == urlWithoutQuery - } - return false - } - } - } - -} diff --git a/Tests/HPNetworkTests/NetworkClientMockTests.swift b/Tests/HPNetworkTests/NetworkClientMockTests.swift index cb27f22..caf7708 100644 --- a/Tests/HPNetworkTests/NetworkClientMockTests.swift +++ b/Tests/HPNetworkTests/NetworkClientMockTests.swift @@ -111,9 +111,7 @@ class NetworkClientMockTests: XCTestCase { // MARK: - Helpers private func makeNetworkClient() async -> NetworkClientMock { - let client = NetworkClientMock() - await client.setFallbackToURLSessionIfNoMatchingMock(false) - return client + NetworkClientMock(urlSession: .shared, fallbackToURLSessionIfNoMatchingMock: false) } } From 6c49308b92a04cd56aaf7fd28f2b25371ad69958 Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Wed, 23 Jul 2025 08:57:57 +0200 Subject: [PATCH 7/9] rework monitor Signed-off-by: Henrik Panhans --- Sources/HPNetwork/ConnectionMonitor.swift | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Sources/HPNetwork/ConnectionMonitor.swift b/Sources/HPNetwork/ConnectionMonitor.swift index fead77e..9b8ecf9 100644 --- a/Sources/HPNetwork/ConnectionMonitor.swift +++ b/Sources/HPNetwork/ConnectionMonitor.swift @@ -1,7 +1,8 @@ import Foundation import Network -public final class ConnectionMonitor: ObservableObject, @unchecked Sendable { +@MainActor +public final class ConnectionMonitor: ObservableObject { // MARK: - Properties @@ -32,7 +33,9 @@ public final class ConnectionMonitor: ObservableObject, @unchecked Sendable { self.pathMonitor = pathMonitor self.pathMonitor.pathUpdateHandler = { [weak self] path in - self?.emitNotification(path) + Task { [weak self] in + await self?.emitNotification(path) + } } self.pathMonitor.start(queue: queue) } @@ -42,9 +45,7 @@ public final class ConnectionMonitor: ObservableObject, @unchecked Sendable { private func emitNotification(_ path: NWPath) { let userInfo = [ConnectionMonitor.updatedPathKey: path] - Task { [weak self] in - await self?.updateState(path) - } + currentPath = path switch path.status { case .satisfied: @@ -70,9 +71,4 @@ public final class ConnectionMonitor: ObservableObject, @unchecked Sendable { } } - @MainActor - private func updateState(_ path: NWPath) { - currentPath = path - } - } From 45453e2f733681befd75f1731193b5dede26052e Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Thu, 24 Jul 2025 08:44:28 +0200 Subject: [PATCH 8/9] Improve mock store Signed-off-by: Henrik Panhans --- Sources/HPNetwork/ConnectionMonitor.swift | 74 --------------- Sources/HPNetwork/Documentation.docc/index.md | 29 +++--- .../MockedNetworkRequestFilter.swift | 61 +++++++++++++ .../HPNetworkMock/MockedRequestStore.swift | 90 ------------------- .../HPNetworkMock/MockedRequestsStore.swift | 39 -------- Sources/HPNetworkMock/NetworkClientMock.swift | 24 +---- .../NetworkRequestMockStore.swift | 42 +++++++++ .../HPNetworkMock/URLRequestMockStore.swift | 39 ++++++++ Sources/HPNetworkMock/URLSessionMock.swift | 13 +-- .../ConnectionMonitorTests.swift | 20 ----- Tests/HPNetworkTests/DataRequestTests.swift | 4 +- .../HPNetworkTests/DownloadRequestTests.swift | 4 +- .../NetworkClientMockTests.swift | 15 ++-- 13 files changed, 180 insertions(+), 274 deletions(-) delete mode 100644 Sources/HPNetwork/ConnectionMonitor.swift create mode 100644 Sources/HPNetworkMock/MockedNetworkRequestFilter.swift delete mode 100644 Sources/HPNetworkMock/MockedRequestStore.swift delete mode 100644 Sources/HPNetworkMock/MockedRequestsStore.swift create mode 100644 Sources/HPNetworkMock/NetworkRequestMockStore.swift create mode 100644 Sources/HPNetworkMock/URLRequestMockStore.swift delete mode 100644 Tests/HPNetworkTests/ConnectionMonitorTests.swift diff --git a/Sources/HPNetwork/ConnectionMonitor.swift b/Sources/HPNetwork/ConnectionMonitor.swift deleted file mode 100644 index 9b8ecf9..0000000 --- a/Sources/HPNetwork/ConnectionMonitor.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import Network - -@MainActor -public final class ConnectionMonitor: ObservableObject { - - // MARK: - Properties - - public static let connectionBecameSatisfiedNotification = Notification.Name("ConnectionBecameSatisfiedNotification") - public static let connectionBecameUnsatisfiedNotification = Notification.Name("ConnectionBecameSatisfiedNotification") - public static let connectionRequiresConnectionNoticication = Notification.Name("ConnectionRequiresConnectionNoticication") - public static let updatedPathKey = "ConnectionMonitorUpdatedPathKey" - - @Published public private(set) var currentPath: NWPath - - private let pathMonitor: NWPathMonitor - - // MARK: - Init - - public convenience init(requiredInterfaceType: NWInterface.InterfaceType) { - self.init(pathMonitor: NWPathMonitor(requiredInterfaceType: requiredInterfaceType)) - } - - public convenience init() { - self.init(pathMonitor: NWPathMonitor()) - } - - private init( - pathMonitor: NWPathMonitor, - queue: DispatchQueue = DispatchQueue(label: "dev.panhans.ConnectionMonitor", qos: .background) - ) { - self._currentPath = Published(initialValue: pathMonitor.currentPath) - self.pathMonitor = pathMonitor - - self.pathMonitor.pathUpdateHandler = { [weak self] path in - Task { [weak self] in - await self?.emitNotification(path) - } - } - self.pathMonitor.start(queue: queue) - } - - // MARK: - State Changes - - private func emitNotification(_ path: NWPath) { - let userInfo = [ConnectionMonitor.updatedPathKey: path] - - currentPath = path - - switch path.status { - case .satisfied: - NotificationCenter.default.post( - name: ConnectionMonitor.connectionBecameSatisfiedNotification, - object: self, - userInfo: userInfo - ) - case .unsatisfied: - NotificationCenter.default.post( - name: ConnectionMonitor.connectionBecameUnsatisfiedNotification, - object: self, - userInfo: userInfo - ) - case .requiresConnection: - NotificationCenter.default.post( - name: ConnectionMonitor.connectionRequiresConnectionNoticication, - object: self, - userInfo: userInfo - ) - @unknown default: - break - } - } - -} diff --git a/Sources/HPNetwork/Documentation.docc/index.md b/Sources/HPNetwork/Documentation.docc/index.md index 14bc8b9..613de5d 100644 --- a/Sources/HPNetwork/Documentation.docc/index.md +++ b/Sources/HPNetwork/Documentation.docc/index.md @@ -8,9 +8,9 @@ A flexible, protocol-based networking stack written in Swift. ## Installation -Starting with v4 HPNetwork is only available via Swift Package Manager. +`HPNetwork` is available via Swift Package Manager: -- Package.swift: `.package(url: "https://github.com/henrik-dmg/HPNetwork", from: "4.0.0")` +- Package.swift: `.package(url: "https://github.com/henrik-dmg/HPNetwork", from: "5.0.0")` - Xcode: `https://github.com/henrik-dmg/HPNetwork` ## Scheduling Requests @@ -21,7 +21,7 @@ Scheduling a request is as easy as this: let response = try await request.response() ``` -The `response` is a ``NetworkResponse`` containing the output and statisticsof the request. +The `response` is a ``NetworkResponse`` containing the output and statistics of the request. You can also get an async result: @@ -42,7 +42,7 @@ let task = request.schedule { result in } ``` -You can also use pretty much the same API with ``NetworkClient`` (useful for being able to mock requests in tests). +You can also use pretty much use the same API with ``NetworkClient`` (useful for being able to mock requests in tests). ## Creating Requests @@ -56,15 +56,15 @@ In the most simple terms, that means you supply a `URL` and a request method. ```swift struct BasicDataRequest: DataRequest { -typealias Output = Data + typealias Output = Data -var requestMethod: HTTPRequest.Method { - .get -} + var requestMethod: HTTPRequest.Method { + .get + } -func makeURL() throws -> URL { - // construct your URL here -} + func makeURL() throws -> URL { + // construct your URL here + } } ``` @@ -86,8 +86,8 @@ struct BasicDataRequest: DataRequest { } let basicRequest = BasicDataRequest( -url: URL(string: "https://panhans.dev/"), -requestMethod: .get + url: URL(string: "https://panhans.dev/"), + requestMethod: .get ) ``` @@ -120,4 +120,5 @@ struct BasicDecodableRequest: DecodableRequest { ### Request Authorization To add authorization to a request, simply supply a ``Authorization`` instance to your request. -You can either use ``BasicAuthorization`` for basic authentication with a username and password, or ``BearerAuthorization`` for bearer token authorization or implement you own custom ``Authorization`` type. +You can either use ``BasicAuthorization`` for basic authentication with a username and password, +or ``BearerAuthorization`` for bearer token authorization or implement you own custom ``Authorization`` type. diff --git a/Sources/HPNetworkMock/MockedNetworkRequestFilter.swift b/Sources/HPNetworkMock/MockedNetworkRequestFilter.swift new file mode 100644 index 0000000..9b07554 --- /dev/null +++ b/Sources/HPNetworkMock/MockedNetworkRequestFilter.swift @@ -0,0 +1,61 @@ +// +// MockedNetworkRequestFilter.swift +// HPNetwork +// +// Created by Henrik Panhans on 23.07.25. +// + +import Foundation +import HPNetwork +import Synchronization + +protocol MockedRequestFilterProtocol: Sendable { + + associatedtype Input + associatedtype Output + + func matches(_ input: Input) -> Bool + func transform(_ input: Input) throws -> Output + +} + +protocol MockedNetworkRequestFilterProtocol: MockedRequestFilterProtocol where Input: NetworkRequest { + + associatedtype Output = Input.Output + +} + +struct NetworkRequestFilter: MockedNetworkRequestFilterProtocol { + + typealias Input = Request + + let matchClosure: @Sendable (Input) -> Bool + let transformClosure: @Sendable (Input) throws -> Output + + func matches(_ input: Input) -> Bool { + matchClosure(input) + } + + func transform(_ input: Input) throws -> Output { + try transformClosure(input) + } + +} + +struct URLRequestFilter: MockedRequestFilterProtocol { + + typealias Input = URLRequest + typealias Output = (Data, URLResponse) + + let matchClosure: @Sendable (Input) -> Bool + let transformClosure: @Sendable (Input) throws -> Output + + func matches(_ input: Input) -> Bool { + matchClosure(input) + } + + func transform(_ input: Input) throws -> Output { + try transformClosure(input) + } + +} diff --git a/Sources/HPNetworkMock/MockedRequestStore.swift b/Sources/HPNetworkMock/MockedRequestStore.swift deleted file mode 100644 index 2b7fa92..0000000 --- a/Sources/HPNetworkMock/MockedRequestStore.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// MockedRequestStore.swift -// HPNetwork -// -// Created by Henrik Panhans on 22.07.25. -// - -import Foundation -import Synchronization - -@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) -final class MockedRequestStore: Sendable { - - // MARK: - Nested Types - - public typealias MockedRequestHandler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) - - struct MockedNetworkRequest: Sendable { - let url: URL - let ignoresQuery: Bool - let handler: MockedRequestHandler - let id = UUID() - } - - // MARK: - Properties - - static let shared = MockedRequestStore() - - private let mockedRequests = Mutex([UUID: MockedNetworkRequest]()) - - // MARK: - Registering Mocks - - @discardableResult - public func mockRequest( - to url: URL, - ignoresQuery: Bool, - handler: @escaping MockedRequestHandler - ) -> UUID { - let mockedRequest = MockedNetworkRequest(url: url, ignoresQuery: ignoresQuery, handler: handler) - mockedRequests.withLock { requests in - requests[mockedRequest.id] = mockedRequest - } - return mockedRequest.id - } - - @discardableResult - public func mockRequest( - to urlString: String, - ignoresQuery: Bool, - handler: @escaping MockedRequestHandler - ) throws(URLSessionMockError) -> UUID { - guard let url = URL(string: urlString) else { - throw URLSessionMockError.cantCreateURL - } - return mockRequest(to: url, ignoresQuery: ignoresQuery, handler: handler) - } - - public func unregisterMockedRequest(with id: UUID) { - mockedRequests.withLock { requests in - requests[id] = nil - } - } - - public func unregisterAllMockedRequests() { - mockedRequests.withLock { requests in - requests.removeAll() - } - } - - func mockedRequest(for url: URL) -> MockedNetworkRequest? { - guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return nil - } - - urlComponents.query = nil - let urlWithoutQuery = urlComponents.url - - return mockedRequests.withLock { requests in - requests.values.first { request in - if request.url == url { - return true - } else if request.ignoresQuery, let urlWithoutQuery { - return request.url == urlWithoutQuery - } - return false - } - } - } - -} diff --git a/Sources/HPNetworkMock/MockedRequestsStore.swift b/Sources/HPNetworkMock/MockedRequestsStore.swift deleted file mode 100644 index 25a8df5..0000000 --- a/Sources/HPNetworkMock/MockedRequestsStore.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// MockedRequestsStore.swift -// HPNetwork -// -// Created by Henrik Panhans on 22.07.25. -// - -import Foundation - -@testable import HPNetwork - -/// Stores all mutable state for NetworkClientMock, ensuring thread safety. -actor MockedRequestsStore { - - // MARK: - Properties - - private var mockedRequests: [String: any MockedRequest] = [:] - - // MARK: - Methods - - func removeAllMocks() { - mockedRequests.removeAll() - } - - func mockRequest(ofType type: Request.Type, handler: @escaping (Request) async throws -> Request.Output) { - let typeName = String(describing: type.self) - mockedRequests[typeName] = NetworkClientMock.ConcreteMockedRequest(handler: handler) - } - - /// Handles the mock request internally and returns the output or nil if not found. - func handleMockedRequest(for request: Request) async throws -> Request.Output? where Request.Output: Sendable { - let typeName = String(describing: Request.self) - guard let concrete = mockedRequests[typeName] as? NetworkClientMock.ConcreteMockedRequest else { - return nil - } - return try await concrete.handler(request) - } - -} diff --git a/Sources/HPNetworkMock/NetworkClientMock.swift b/Sources/HPNetworkMock/NetworkClientMock.swift index 7a82111..a4511bb 100644 --- a/Sources/HPNetworkMock/NetworkClientMock.swift +++ b/Sources/HPNetworkMock/NetworkClientMock.swift @@ -27,9 +27,6 @@ public final class NetworkClientMock: NetworkClientProtocol, Sendable { public let urlSession: URLSession public let fallbackToURLSessionIfNoMatchingMock: Bool - /// All mutable state is stored in this actor for concurrency safety. - private let store = MockedRequestsStore() - // MARK: - Init public init(urlSession: URLSession = .shared, fallbackToURLSessionIfNoMatchingMock: Bool = false) { @@ -43,11 +40,11 @@ public final class NetworkClientMock: NetworkClientProtocol, Sendable { _ request: Request, delegate: (any URLSessionTaskDelegate)? = nil ) async throws -> NetworkResponse where Request.Output: Sendable { - if let output = try await store.handleMockedRequest(for: request) { + if let mockedRequest = await NetworkRequestMockStore.shared.mockedRequest(for: request) { // swift-format-ignore return NetworkResponse( - output: output, - url: URL(string: "https://apple.com")!, + output: try mockedRequest.transform(request), + url: try request.makeURL(), response: HTTPResponse(status: .ok, headerFields: HTTPFields()), networkingDuration: 0.00, processingDuration: 0.00 @@ -81,19 +78,4 @@ public final class NetworkClientMock: NetworkClientProtocol, Sendable { } } - // MARK: - Mocking - - /// Removes all registered mocks. - public func removeAllMocks() async { - await store.removeAllMocks() - } - - /// Registers a mock handler for a specific request type. - public func mockRequest( - ofType type: Request.Type, - handler: @escaping @Sendable (Request) async throws -> Request.Output - ) async where Request.Output: Sendable { - await store.mockRequest(ofType: type, handler: handler) - } - } diff --git a/Sources/HPNetworkMock/NetworkRequestMockStore.swift b/Sources/HPNetworkMock/NetworkRequestMockStore.swift new file mode 100644 index 0000000..357973f --- /dev/null +++ b/Sources/HPNetworkMock/NetworkRequestMockStore.swift @@ -0,0 +1,42 @@ +// +// NetworkRequestMockStore.swift +// HPNetwork +// +// Created by Henrik Panhans on 23.07.25. +// + +import Foundation +import HPNetwork + +public final actor NetworkRequestMockStore: Sendable { + + // MARK: - Properties + + public static let shared = NetworkRequestMockStore() + private var requests: [any MockedNetworkRequestFilterProtocol] = [] + + // MARK: - NetworkRequest + + public func mockRequests( + for type: Request.Type, + filter: @escaping @Sendable (Request) -> Bool = { _ in true }, + handler: @escaping @Sendable (Request) -> Request.Output + ) { + let filter = NetworkRequestFilter(matchClosure: filter, transformClosure: handler) + requests.append(filter) + } + + func mockedRequest(for request: Request) -> NetworkRequestFilter? { + let mocks = requests.compactMap { mockedRequest in + mockedRequest as? NetworkRequestFilter + } + return mocks.first { $0.matches(request) } + } + + // MARK: - Removing + + public func removeAllMocks() { + requests.removeAll() + } + +} diff --git a/Sources/HPNetworkMock/URLRequestMockStore.swift b/Sources/HPNetworkMock/URLRequestMockStore.swift new file mode 100644 index 0000000..88802de --- /dev/null +++ b/Sources/HPNetworkMock/URLRequestMockStore.swift @@ -0,0 +1,39 @@ +// +// URLRequestMockStore.swift +// HPNetwork +// +// Created by Henrik Panhans on 23.07.25. +// + +import Foundation +import Synchronization + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +public final class URLRequestMockStore: Sendable { + + // MARK: - Properties + + public static let shared = URLRequestMockStore() + private let mockedURLRequests = Mutex([URLRequestFilter]()) + + // MARK: - URLRequest + + public func mockRequests( + for urlRequest: @escaping @Sendable (URLRequest) -> Bool = { _ in true }, + handler: @escaping @Sendable (URLRequest) -> (Data, URLResponse) + ) { + let filter = URLRequestFilter(matchClosure: urlRequest, transformClosure: handler) + mockedURLRequests.withLock { requests in + requests.append(filter) + } + } + + func mockedRequest(for request: URLRequest) -> URLRequestFilter? { + mockedURLRequests.withLock { requests in + requests.first { filter in + filter.matches(request) + } + } + } + +} diff --git a/Sources/HPNetworkMock/URLSessionMock.swift b/Sources/HPNetworkMock/URLSessionMock.swift index 9bbe9d4..b445d04 100644 --- a/Sources/HPNetworkMock/URLSessionMock.swift +++ b/Sources/HPNetworkMock/URLSessionMock.swift @@ -31,24 +31,19 @@ public final class URLSessionMock: URLProtocol { } public override func startLoading() { - guard let url = request.url else { - XCTFail("URLRequest has no URL") - client?.urlProtocol(self, didFailWithError: URLSessionMockError.noURL) - return - } - guard let mockedRequest = MockedRequestStore.shared.mockedRequest(for: url) else { - XCTFail("No mocked request configured for url \"\(url.absoluteString)\"") + guard let mockedRequest = URLRequestMockStore.shared.mockedRequest(for: request) else { + XCTFail("No mocked request configured for url \"\(request)\"") client?.urlProtocol(self, didFailWithError: URLSessionMockError.noMockedRequest) return } do { - let (data, response) = try mockedRequest.handler(request) + let (data, response) = try mockedRequest.transform(request) client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: data) client?.urlProtocolDidFinishLoading(self) } catch { - XCTFail("No response returned for url \"\(url.absoluteString)\"") + XCTFail("No response returned for url \"\(request)\"") } } diff --git a/Tests/HPNetworkTests/ConnectionMonitorTests.swift b/Tests/HPNetworkTests/ConnectionMonitorTests.swift deleted file mode 100644 index 8b9cc25..0000000 --- a/Tests/HPNetworkTests/ConnectionMonitorTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation -import Network -import XCTest - -@testable import HPNetwork - -final class ConnectionMonitorTests: XCTestCase { - - func testConnectionMonitor_StartsMonitoring() async throws { - let monitor = ConnectionMonitor() - try await Task.sleep(nanoseconds: 1_000_000) - - let expection = XCTestExpectation(description: "Networking finished") - _ = monitor.$currentPath.sink { path in - expection.fulfill() - } - await fulfillment(of: [expection], timeout: 10) - } - -} diff --git a/Tests/HPNetworkTests/DataRequestTests.swift b/Tests/HPNetworkTests/DataRequestTests.swift index e2adaba..1e9f23e 100644 --- a/Tests/HPNetworkTests/DataRequestTests.swift +++ b/Tests/HPNetworkTests/DataRequestTests.swift @@ -51,7 +51,9 @@ final class DataRequestTests: XCTestCase { // MARK: - Helpers private func mockNetworkRequest(url: URL, dataToReturn data: Data?) { - MockedRequestStore.shared.mockRequest(to: url, ignoresQuery: false) { _ in + URLRequestMockStore.shared.mockRequests { request in + request.url == url + } handler: { _ in let response = HTTPURLResponse( url: url, statusCode: 200, diff --git a/Tests/HPNetworkTests/DownloadRequestTests.swift b/Tests/HPNetworkTests/DownloadRequestTests.swift index c9d0e37..cb64c50 100644 --- a/Tests/HPNetworkTests/DownloadRequestTests.swift +++ b/Tests/HPNetworkTests/DownloadRequestTests.swift @@ -66,7 +66,9 @@ final class DownloadRequestTests: XCTestCase { // MARK: - Helpers private func mockNetworkRequest(url: URL, dataToReturn data: Data?) { - MockedRequestStore.shared.mockRequest(to: url, ignoresQuery: false) { _ in + URLRequestMockStore.shared.mockRequests { request in + request.url == url + } handler: { _ in let response = HTTPURLResponse( url: url, statusCode: 200, diff --git a/Tests/HPNetworkTests/NetworkClientMockTests.swift b/Tests/HPNetworkTests/NetworkClientMockTests.swift index caf7708..15d5127 100644 --- a/Tests/HPNetworkTests/NetworkClientMockTests.swift +++ b/Tests/HPNetworkTests/NetworkClientMockTests.swift @@ -9,12 +9,17 @@ class NetworkClientMockTests: XCTestCase { let url = URL(string: "https://ipapi.co/json")! + override func setUp() async throws { + try await super.setUp() + await NetworkRequestMockStore.shared.removeAllMocks() + } + // MARK: - Tests func testBasicRequest_Async_Mocked() async throws { let networkClient = await makeNetworkClient() - await networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + await NetworkRequestMockStore.shared.mockRequests(for: BasicDecodableRequest.self) { _ in 32 } @@ -38,7 +43,7 @@ class NetworkClientMockTests: XCTestCase { func testBasicRequest_Result_Mocked() async throws { let networkClient = await makeNetworkClient() - await networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + await NetworkRequestMockStore.shared.mockRequests(for: BasicDecodableRequest.self) { _ in 32 } @@ -66,7 +71,7 @@ class NetworkClientMockTests: XCTestCase { func testBasicRequest_Completion_Mocked() async throws { let networkClient = await makeNetworkClient() - await networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + await NetworkRequestMockStore.shared.mockRequests(for: BasicDecodableRequest.self) { _ in 32 } @@ -90,7 +95,7 @@ class NetworkClientMockTests: XCTestCase { func testNetworkClientMock_RemovesAllMocks() async throws { let networkClient = await makeNetworkClient() - await networkClient.mockRequest(ofType: BasicDecodableRequest.self) { _ in + await NetworkRequestMockStore.shared.mockRequests(for: BasicDecodableRequest.self) { _ in 32 } @@ -98,7 +103,7 @@ class NetworkClientMockTests: XCTestCase { let response = try await networkClient.response(request, delegate: nil) XCTAssertEqual(response.output, 32) - await networkClient.removeAllMocks() + await NetworkRequestMockStore.shared.removeAllMocks() do { _ = try await networkClient.response(request, delegate: nil) From 742d0a5417eb7a42fcb89c464e581da7ea13d7b8 Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Thu, 24 Jul 2025 16:13:51 +0200 Subject: [PATCH 9/9] Increase coverage Signed-off-by: Henrik Panhans --- Package.swift | 4 ++ README.md | 4 +- .../Header Fields/HTTPFieldBuilder.swift | 40 +++++++++++-------- Sources/HPNetwork/Requests/DataRequest.swift | 20 ++++------ .../HPNetwork/Requests/DownloadRequest.swift | 13 +++--- .../HPNetwork/Requests/NetworkRequest.swift | 19 +++++---- .../DataRequestTests.swift | 8 +--- .../DownloadRequestTests.swift | 12 ++---- .../Helpers/BasicDecodableRequest.swift | 4 ++ .../Helpers/BasicDownloadRequest.swift | 0 .../NetworkClientMockTests.swift | 0 .../HTTPFieldBuilderTests.swift | 22 ++++++++-- 12 files changed, 82 insertions(+), 64 deletions(-) rename Tests/{HPNetworkTests => HPNetworkMockTests}/DataRequestTests.swift (90%) rename Tests/{HPNetworkTests => HPNetworkMockTests}/DownloadRequestTests.swift (88%) rename Tests/{HPNetworkTests => HPNetworkMockTests}/Helpers/BasicDecodableRequest.swift (92%) rename Tests/{HPNetworkTests => HPNetworkMockTests}/Helpers/BasicDownloadRequest.swift (100%) rename Tests/{HPNetworkTests => HPNetworkMockTests}/NetworkClientMockTests.swift (100%) diff --git a/Package.swift b/Package.swift index df98b8a..efb9621 100644 --- a/Package.swift +++ b/Package.swift @@ -41,6 +41,10 @@ let package = Package( ), .testTarget( name: "HPNetworkTests", + dependencies: ["HPNetwork"] + ), + .testTarget( + name: "HPNetworkMockTests", dependencies: ["HPNetwork", "HPNetworkMock"] ), ] diff --git a/README.md b/README.md index a53dd6b..de6de92 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ ## Installation -Starting with v4 HPNetwork is only available via Swift Package Manager. +`HPNetwork` is available via Swift Package Manager: -- Package.swift: `.package(url: "https://github.com/henrik-dmg/HPNetwork", from: "4.0.0")` +- Package.swift: `.package(url: "https://github.com/henrik-dmg/HPNetwork", from: "5.0.0")` - Xcode: `https://github.com/henrik-dmg/HPNetwork` ## Documentation diff --git a/Sources/HPNetwork/Header Fields/HTTPFieldBuilder.swift b/Sources/HPNetwork/Header Fields/HTTPFieldBuilder.swift index cbe4156..e30fada 100644 --- a/Sources/HPNetwork/Header Fields/HTTPFieldBuilder.swift +++ b/Sources/HPNetwork/Header Fields/HTTPFieldBuilder.swift @@ -4,28 +4,34 @@ import HTTPTypes @resultBuilder public enum HTTPFieldBuilder { - public static func buildBlock(_ components: [HTTPField]...) -> [HTTPField] { - components.flatMap { $0 } + // MARK: - Expression + + public static func buildExpression(_ expression: HTTPField) -> [HTTPField] { + [expression] } - public static func buildOptional(_ component: [HTTPField]?) -> [HTTPField] { - component ?? [] + public static func buildExpression(_ expression: HTTPField?) -> [HTTPField] { + expression.flatMap { [$0] } ?? [] } - public static func buildOptional(_ component: [HTTPField?]?) -> [HTTPField] { - component?.compactMap { $0 } ?? [] + public static func buildExpression(_ expression: [HTTPField]) -> [HTTPField] { + expression } - /// Add support for both single and collections of constraints. - public static func buildExpression(_ expression: HTTPField) -> [HTTPField] { - [expression] + // MARK: - Optional + + public static func buildOptional(_ component: [HTTPField]?) -> [HTTPField] { + component ?? [] } - public static func buildExpression(_ expression: [HTTPField]) -> [HTTPField] { - expression + // MARK: - Limited Availability + + public static func buildLimitedAvailability(_ component: [HTTPField]) -> [HTTPField] { + component } - /// Add support for if statements. + // MARK: - Branching + public static func buildEither(first components: [HTTPField]) -> [HTTPField] { components } @@ -34,12 +40,14 @@ public enum HTTPFieldBuilder { components } - public static func buildArray(_ components: [[HTTPField]]) -> [HTTPField] { - components.flatMap { $0 } + // MARK: - Partial + + public static func buildPartialBlock(first components: [HTTPField]) -> [HTTPField] { + components } - public static func buildLimitedAvailability(_ component: [HTTPField]) -> [HTTPField] { - component + public static func buildPartialBlock(accumulated: [HTTPField], next: [HTTPField]) -> [HTTPField] { + accumulated + next } } diff --git a/Sources/HPNetwork/Requests/DataRequest.swift b/Sources/HPNetwork/Requests/DataRequest.swift index 9d6cdb4..9a62593 100644 --- a/Sources/HPNetwork/Requests/DataRequest.swift +++ b/Sources/HPNetwork/Requests/DataRequest.swift @@ -24,7 +24,7 @@ public protocol DataRequest: NetworkRequest { /// - delegate: The delegate to use /// - Returns: The network response containing the converted output along with some metadata /// - Throws: If the networking failed or converting the response to the desired output type failed - func response(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async throws -> NetworkResponse + func response(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async throws -> Response /// Executes the request and returns the result. /// - Parameters: @@ -46,10 +46,8 @@ public protocol DataRequest: NetworkRequest { extension DataRequest { - @discardableResult public func response( - urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)? - ) async throws -> NetworkResponse { + @discardableResult + public func response(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async throws -> Response { // Make request let request = try makeRequest() // Keep track of start time @@ -89,10 +87,8 @@ extension DataRequest { ) } - @discardableResult public func result( - urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)? - ) async -> NetworkResult { + @discardableResult + public func result(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async -> NetworkResult { do { let result = try await response(urlSession: urlSession, delegate: delegate) return .success(result) @@ -101,10 +97,8 @@ extension DataRequest { } } - @discardableResult public func schedule( - urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)? - ) -> NetworkTask where Output: Sendable { + @discardableResult + public func schedule(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) -> NetworkTask where Output: Sendable { NetworkTask { try await response(urlSession: urlSession, delegate: delegate) } diff --git a/Sources/HPNetwork/Requests/DownloadRequest.swift b/Sources/HPNetwork/Requests/DownloadRequest.swift index 2c9b6fb..4b8ed76 100644 --- a/Sources/HPNetwork/Requests/DownloadRequest.swift +++ b/Sources/HPNetwork/Requests/DownloadRequest.swift @@ -15,10 +15,8 @@ extension DownloadRequest { url } - @discardableResult public func response( - urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)? - ) async throws -> NetworkResponse { + @discardableResult + public func response(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async throws -> Response { let request = try makeRequest() let startTime = DispatchTime.now() @@ -58,10 +56,8 @@ extension DownloadRequest { ) } - @discardableResult public func result( - urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)? - ) async -> NetworkResult { + @discardableResult + public func result(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async -> NetworkResult { do { let result = try await response(urlSession: urlSession, delegate: delegate) return .success(result) @@ -70,6 +66,7 @@ extension DownloadRequest { } } + @discardableResult public func schedule(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) -> NetworkTask where Output: Sendable { NetworkTask { try await response(urlSession: urlSession, delegate: delegate) diff --git a/Sources/HPNetwork/Requests/NetworkRequest.swift b/Sources/HPNetwork/Requests/NetworkRequest.swift index 308be25..b4b8634 100644 --- a/Sources/HPNetwork/Requests/NetworkRequest.swift +++ b/Sources/HPNetwork/Requests/NetworkRequest.swift @@ -10,11 +10,14 @@ public protocol NetworkRequest: Sendable { /// The expected output type returned in the network request. associatedtype Output + /// The typed ``NetworkResponse`` + typealias Response = NetworkResponse + /// The result of a network request. - typealias NetworkResult = Result, Error> + typealias NetworkResult = Result /// A typed task of a network request. - typealias NetworkTask = Task, Error> + typealias NetworkTask = Task /// The header fields that will be send with the network request. /// @@ -45,7 +48,7 @@ public protocol NetworkRequest: Sendable { /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running /// - Returns: a wrapper object containing an instance of ``Output`` along with the elapsed time for both networking and processing in seconds /// - Throws: Throws an error when anything went wrong while executing the network request - func response(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async throws -> NetworkResponse + func response(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) async throws -> Response /// Uses all the provided information to create a `URLRequest` and handles that request's result accordingly. /// - Parameters: @@ -60,10 +63,7 @@ public protocol NetworkRequest: Sendable { /// - urlSession: The `URLSession` instance to use to execute this network request /// - delegate: The delegate that can be used to inspect and react to the network traffic while the request is running /// - Returns: A task that wraps the running network request - func schedule( - urlSession: URLSession, - delegate: (any URLSessionTaskDelegate)? - ) -> NetworkTask + func schedule(urlSession: URLSession, delegate: (any URLSessionTaskDelegate)?) -> NetworkTask /// A method that can be used to validate the response of a network request before any further processing will be attempted. /// @@ -145,7 +145,8 @@ extension NetworkRequest { /// - Parameter urlSession: The `URLSession` instance to use to execute this network request /// - Returns: a wrapper object containing an instance of ``Output`` along with the elapsed time for both networking and processing in seconds /// - Throws: Throws an error when anything went wrong while executing the network request - public func response(urlSession: URLSession) async throws -> NetworkResponse { + @discardableResult + public func response(urlSession: URLSession) async throws -> Response { try await response(urlSession: urlSession, delegate: nil) } @@ -153,6 +154,7 @@ extension NetworkRequest { /// - Parameter urlSession: The `URLSession` instance to use to execute this network request /// - Returns: a result with either a wrapper object containing an instance of ``Output`` along with the elapsed time for /// both networking and processing in seconds or an error + @discardableResult public func result(urlSession: URLSession) async -> NetworkResult { await result(urlSession: urlSession, delegate: nil) } @@ -160,6 +162,7 @@ extension NetworkRequest { /// Uses all the provided information to create a `URLRequest` and schedules that request. /// - Parameter urlSession: The `URLSession` instance to use to execute this network request /// - Returns: A task that wraps the running network request + @discardableResult public func schedule(urlSession: URLSession) -> NetworkTask { schedule(urlSession: urlSession, delegate: nil) } diff --git a/Tests/HPNetworkTests/DataRequestTests.swift b/Tests/HPNetworkMockTests/DataRequestTests.swift similarity index 90% rename from Tests/HPNetworkTests/DataRequestTests.swift rename to Tests/HPNetworkMockTests/DataRequestTests.swift index 1e9f23e..5c8c29c 100644 --- a/Tests/HPNetworkTests/DataRequestTests.swift +++ b/Tests/HPNetworkMockTests/DataRequestTests.swift @@ -32,12 +32,8 @@ final class DataRequestTests: XCTestCase { mockNetworkRequest(url: url, dataToReturn: "{}".data(using: .utf8)) let request = BasicDecodableRequest(url: url) - switch await networkClient.result(request) { - case .success(let response): - XCTAssertEqual(response.output, EmptyStruct()) - case .failure(let error): - throw error - } + let response = try await networkClient.result(request).get() + XCTAssertEqual(response.output, EmptyStruct()) } func testBasicRequest_Completion() async throws { diff --git a/Tests/HPNetworkTests/DownloadRequestTests.swift b/Tests/HPNetworkMockTests/DownloadRequestTests.swift similarity index 88% rename from Tests/HPNetworkTests/DownloadRequestTests.swift rename to Tests/HPNetworkMockTests/DownloadRequestTests.swift index cb64c50..eedfe4f 100644 --- a/Tests/HPNetworkTests/DownloadRequestTests.swift +++ b/Tests/HPNetworkMockTests/DownloadRequestTests.swift @@ -44,14 +44,10 @@ final class DownloadRequestTests: XCTestCase { mockNetworkRequest(url: url, dataToReturn: jsonString.data(using: .utf8)) let request = BasicDownloadRequest(url: url) - switch await networkClient.result(request) { - case .success(let response): - fileURL = response.output - let downloadedContents = try String(contentsOf: response.output) - XCTAssertEqual(downloadedContents, jsonString) - case .failure(let error): - throw error - } + let response = try await networkClient.result(request).get() + fileURL = response.output + let downloadedContents = try String(contentsOf: response.output) + XCTAssertEqual(downloadedContents, jsonString) } func testBasicRequest_Completion() async throws { diff --git a/Tests/HPNetworkTests/Helpers/BasicDecodableRequest.swift b/Tests/HPNetworkMockTests/Helpers/BasicDecodableRequest.swift similarity index 92% rename from Tests/HPNetworkTests/Helpers/BasicDecodableRequest.swift rename to Tests/HPNetworkMockTests/Helpers/BasicDecodableRequest.swift index 508c745..bb3f712 100644 --- a/Tests/HPNetworkTests/Helpers/BasicDecodableRequest.swift +++ b/Tests/HPNetworkMockTests/Helpers/BasicDecodableRequest.swift @@ -1,6 +1,10 @@ import Foundation import HPNetwork +enum URLError: Error { + case urlNil +} + // periphery:ignore struct BasicDecodableRequest: DecodableRequest { diff --git a/Tests/HPNetworkTests/Helpers/BasicDownloadRequest.swift b/Tests/HPNetworkMockTests/Helpers/BasicDownloadRequest.swift similarity index 100% rename from Tests/HPNetworkTests/Helpers/BasicDownloadRequest.swift rename to Tests/HPNetworkMockTests/Helpers/BasicDownloadRequest.swift diff --git a/Tests/HPNetworkTests/NetworkClientMockTests.swift b/Tests/HPNetworkMockTests/NetworkClientMockTests.swift similarity index 100% rename from Tests/HPNetworkTests/NetworkClientMockTests.swift rename to Tests/HPNetworkMockTests/NetworkClientMockTests.swift diff --git a/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift b/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift index a2c42c9..c5c08e7 100644 --- a/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift +++ b/Tests/HPNetworkTests/HTTPFieldBuilderTests.swift @@ -19,12 +19,28 @@ final class HTTPFieldBuilderTests: XCTestCase { XCTAssertEqual(fields, expectedFields) } + func testFieldBuiler_Array_Alternative() { + let expectedFields = [HTTPField.contentType(.applicationJSON), HTTPField.contentType(.applicationJSON)] + let fields = buildHTTPFields { + [HTTPField.contentType(.applicationJSON), HTTPField.contentType(.applicationJSON)] + } + XCTAssertEqual(fields, expectedFields) + } + + func testFieldBuiler_LimitedAvailability() { + let expectedFields = [HTTPField.contentType(.applicationJSON), HTTPField.contentType(.applicationJSON)] + let fields = buildHTTPFields { + if #available(iOS 18, *) { + expectedFields + } + } + XCTAssertEqual(fields, expectedFields) + } + func testFieldBuiler_Optional() { let expectedField: HTTPField? = HTTPField.contentType(.applicationJSON) let fields = buildHTTPFields { - if let expectedField { - expectedField - } + expectedField } XCTAssertEqual(fields, [expectedField]) }