diff --git a/Package.swift b/Package.swift index 6fd6c612..968bf55b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.8 +// swift-tools-version: 5.9 import PackageDescription var package = Package(name: "danthorpe-networking") diff --git a/Sources/Helpers/AsyncSequence+.swift b/Sources/Helpers/AsyncSequence+.swift index c7a6d7c5..0118f43d 100644 --- a/Sources/Helpers/AsyncSequence+.swift +++ b/Sources/Helpers/AsyncSequence+.swift @@ -5,13 +5,13 @@ import Foundation extension AsyncSequence { public typealias ProcessElement = @Sendable (Element) async -> Void - public typealias ProcessError = @Sendable (Error) async -> Void + public typealias TransformError = @Sendable (Error) async -> Error? public typealias OnTermination = @Sendable () async -> Void public func redirect( into continuation: AsyncThrowingStream.Continuation, onElement processElement: ProcessElement? = nil, - onError processError: ProcessError? = nil, + mapError transformError: TransformError? = nil, onTermination: OnTermination? = nil ) async { do { @@ -22,8 +22,11 @@ extension AsyncSequence { continuation.finish() await onTermination?() } catch { - await processError?(error) - continuation.finish(throwing: error) + if let transform = transformError, let transformedError = await transform(error) { + continuation.finish(throwing: transformedError) + } else { + continuation.finish(throwing: error) + } await onTermination?() } } diff --git a/Sources/Networking/Components/Authentication/Authentication.swift b/Sources/Networking/Components/Authentication/Authentication.swift index 53f8ca8a..1a620915 100644 --- a/Sources/Networking/Components/Authentication/Authentication.swift +++ b/Sources/Networking/Components/Authentication/Authentication.swift @@ -46,12 +46,17 @@ struct Authentication: NetworkingModifier { continuation.yield(event) } continuation.finish() - } catch let StackError.unauthorized(response) { + } catch let error as NetworkingError { + guard let response = error.isUnauthorizedResponse else { + throw error + } + let newRequest = try await refresh( unauthorized: &credentials, response: response, continuation: continuation ) + await upstream.send(newRequest).redirect(into: continuation) } catch { continuation.finish(throwing: error) @@ -76,6 +81,8 @@ struct Authentication: NetworkingModifier { } } +// MARK: - Errors + public enum AuthenticationError: Error { case fetchCredentialsFailed(HTTPRequestData, AuthenticationMethod, Error) case refreshCredentialsFailed(HTTPResponseData, AuthenticationMethod, Error) diff --git a/Sources/Networking/Components/Cached.swift b/Sources/Networking/Components/Cached.swift index abe7f8e1..157167c4 100644 --- a/Sources/Networking/Components/Cached.swift +++ b/Sources/Networking/Components/Cached.swift @@ -44,11 +44,14 @@ private struct Cached: NetworkingModifier { } await upstream.send(request) - .redirect(into: continuation, onElement: { element in - if !Task.isCancelled, case let .value(response, _) = element { - await cache.insert(response, forKey: request, cost: response.cacheCost, duration: timeToLive) + .redirect( + into: continuation, + onElement: { element in + if !Task.isCancelled, case let .value(response, _) = element { + await cache.insert(response, forKey: request, cost: response.cacheCost, duration: timeToLive) + } } - }, onError: nil, onTermination: nil) + ) } return stream } diff --git a/Sources/Networking/Components/CheckedStatusCode.swift b/Sources/Networking/Components/CheckedStatusCode.swift index 46925fa7..7ac1ba28 100644 --- a/Sources/Networking/Components/CheckedStatusCode.swift +++ b/Sources/Networking/Components/CheckedStatusCode.swift @@ -22,9 +22,20 @@ struct CheckedStatusCode: NetworkingModifier { // Check for authentication issues switch response.status { case .unauthorized: - throw StackError.unauthorized(response) + throw StackError(unauthorized: response) default: - throw StackError.statusCode(response) + throw StackError(statusCode: response) } } } + +// MARK: - Error Handling + +extension StackError { + init(unauthorized response: HTTPResponseData) { + self.init(response: response, kind: .unauthorized) + } + init(statusCode response: HTTPResponseData) { + self.init(response: response, kind: .statusCode) + } +} diff --git a/Sources/Networking/Components/URLSession.swift b/Sources/Networking/Components/URLSession.swift index 9ec7a4d9..62bb1d9e 100644 --- a/Sources/Networking/Components/URLSession.swift +++ b/Sources/Networking/Components/URLSession.swift @@ -6,13 +6,16 @@ extension URLSession: NetworkingComponent { public func send(_ request: HTTPRequestData) -> ResponseStream { let request = resolve(request) .applyAuthenticationCredentials() + + @NetworkEnvironment(\.instrument) var instrument + return ResponseStream { continuation in + guard let urlRequest = URLRequest(http: request) else { + continuation.finish(throwing: StackError(createURLRequestFailed: request)) + return + } + Task { - @NetworkEnvironment(\.instrument) var instrument - guard let urlRequest = URLRequest(http: request) else { - continuation.finish(throwing: StackError.createURLRequestFailed(request)) - return - } do { await send(urlRequest) .map { partial in @@ -20,21 +23,23 @@ extension URLSession: NetworkingComponent { try HTTPResponseData(request: request, data: data, urlResponse: response) } } - .eraseToThrowingStream() .redirect( into: continuation, + mapError: { error in + StackError(error, with: .request(request)) + }, onTermination: { await instrument?.measureElapsedTime("URLSession") - }) + } + ) } catch { - await instrument?.measureElapsedTime("\(Self.self)") - continuation.finish(throwing: error) + continuation.finish(throwing: StackError(error, with: .request(request))) } } } } - @Sendable func send(_ request: URLRequest) -> ResponseStream<(Data, URLResponse)> { + func send(_ request: URLRequest) -> ResponseStream<(Data, URLResponse)> { ResponseStream<(Data, URLResponse)> { continuation in Task { do { @@ -96,3 +101,15 @@ extension URLSession: NetworkingComponent { } } } + +// MARK: - Errors + +extension StackError { + init(createURLRequestFailed request: HTTPRequestData) { + self.init( + info: .request(request), + kind: .createURLRequestFailed, + error: NoUnderlyingError() + ) + } +} diff --git a/Sources/Networking/Core/HTTPResponseData.swift b/Sources/Networking/Core/HTTPResponseData.swift index 9e2256fc..d4dfa969 100644 --- a/Sources/Networking/Core/HTTPResponseData.swift +++ b/Sources/Networking/Core/HTTPResponseData.swift @@ -35,7 +35,7 @@ public struct HTTPResponseData: Sendable { let httpUrlResponse = (urlResponse as? HTTPURLResponse), let httpResponse = httpUrlResponse.httpResponse else { - throw StackError.invalidURLResponse(request, data, urlResponse) + throw StackError(invalidURLResponse: urlResponse, request: request, data: data) } self.init(request: request, data: data, httpUrlResponse: httpUrlResponse, httpResponse: httpResponse) } @@ -54,7 +54,7 @@ public struct HTTPResponseData: Sendable { let body = try transform(payload, self) return body } catch let error as DecodingError { - throw StackError.decodeResponse(self, error) + throw StackError(decodeResponse: self, error: error) } } } @@ -183,3 +183,20 @@ private func ~= (lhs: HTTPURLResponse, rhs: HTTPURLResponse) -> Bool { && lhs.textEncodingName == rhs.textEncodingName && lhs.suggestedFilename == rhs.suggestedFilename } + +// MARK: - Error Handling + +extension StackError { + + init(invalidURLResponse urlResponse: URLResponse?, request: HTTPRequestData, data: Data) { + self.init( + info: .request(request), + kind: .invalidURLResponse(data, urlResponse), + error: NoUnderlyingError() + ) + } + + init(decodeResponse response: HTTPResponseData, error: Error) { + self.init(info: .response(response), kind: .decodingResponse, error: error) + } +} diff --git a/Sources/Networking/Core/NetworkingComponent+Data.swift b/Sources/Networking/Core/NetworkingComponent+Data.swift index 7fe4b0c1..fde3e379 100644 --- a/Sources/Networking/Core/NetworkingComponent+Data.swift +++ b/Sources/Networking/Core/NetworkingComponent+Data.swift @@ -20,7 +20,7 @@ extension NetworkingComponent { } .first(beforeTimeout: duration, using: clock()) } catch is TimeoutError { - throw StackError.timeout(request) + throw StackError(timeout: request) } } @@ -47,3 +47,16 @@ extension NetworkingComponent { request, progress: updateProgress, timeout: .seconds(request.requestTimeoutInSeconds)) } } + +// MARK: - Error Handling + +extension StackError { + + init(timeout request: HTTPRequestData) { + self.init( + info: .request(request), + kind: .timeout, + error: NoUnderlyingError() + ) + } +} diff --git a/Sources/Networking/Errors/Error+.swift b/Sources/Networking/Errors/Error+.swift index d68785bb..fc36a078 100644 --- a/Sources/Networking/Errors/Error+.swift +++ b/Sources/Networking/Errors/Error+.swift @@ -1,5 +1,9 @@ extension Error { + public var asNetworkingError: (any NetworkingError)? { + (self as? any NetworkingError) + } + public var httpRequest: HTTPRequestData? { asNetworkingError?.request } @@ -11,8 +15,4 @@ extension Error { public var httpBodyStringRepresentation: String? { asNetworkingError?.bodyStringRepresentation } - - private var asNetworkingError: (any NetworkingError)? { - (self as? any NetworkingError) - } } diff --git a/Sources/Networking/Errors/NetworkingError.swift b/Sources/Networking/Errors/NetworkingError.swift index 49ea3e70..1a556a58 100644 --- a/Sources/Networking/Errors/NetworkingError.swift +++ b/Sources/Networking/Errors/NetworkingError.swift @@ -4,6 +4,9 @@ import Helpers public protocol NetworkingError: Error { var request: HTTPRequestData { get } var response: HTTPResponseData? { get } + + var requestDidTimeout: HTTPRequestData? { get } + var isUnauthorizedResponse: HTTPResponseData? { get } } // MARK: - Conveniences @@ -20,18 +23,21 @@ extension NetworkingError { return String(decoding: response.data, as: UTF8.self) } - public var isTimeoutError: Bool { - if let status = response?.status, status == .requestTimeout { - return true + public var requestDidTimeout: HTTPRequestData? { + if let response, response.status == .requestTimeout { + return response.request + } else if let request = (self as? StackError)?.requestDidTimeout { + return request } - switch self { - case let stackError as StackError: - if case .timeout = stackError { - return true - } - return false - default: - return false + return nil + } + + public var isUnauthorizedResponse: HTTPResponseData? { + if let response = (self as? StackError)?.isUnauthorizedResponse { + return response + } else if let response, response.status == .unauthorized { + return response } + return nil } } diff --git a/Sources/Networking/Errors/StackError+NetworkingError.swift b/Sources/Networking/Errors/StackError+NetworkingError.swift new file mode 100644 index 00000000..57e405c3 --- /dev/null +++ b/Sources/Networking/Errors/StackError+NetworkingError.swift @@ -0,0 +1,37 @@ +import Foundation + +extension StackError: NetworkingError { + package var request: HTTPRequestData { + switch info { + case let .request(request): + return request + case let .response(response): + return response.request + } + } + + package var response: HTTPResponseData? { + switch info { + case .request: + return nil + case let .response(response): + return response + } + } + + package var requestDidTimeout: HTTPRequestData? { + guard case .timeout = kind, case let .request(request) = info else { + return nil + } + return request + } + + package var isUnauthorizedResponse: HTTPResponseData? { + if case .unauthorized = kind, case let .response(response) = info { + return response + } else if let response, response.status == .unauthorized { + return response + } + return nil + } +} diff --git a/Sources/Networking/Errors/StackError.swift b/Sources/Networking/Errors/StackError.swift index 7b1bf334..ef139a5f 100644 --- a/Sources/Networking/Errors/StackError.swift +++ b/Sources/Networking/Errors/StackError.swift @@ -1,57 +1,90 @@ import Foundation import Helpers -enum StackError: Error { - enum ProgrammingError: Equatable, Sendable { - // TBD +package struct StackError: Error { + enum Info { + case request(HTTPRequestData) + case response(HTTPResponseData) + } + enum Kind { + case createURLRequestFailed + case decodingResponse + case invalidURLResponse(Data, URLResponse?) + case statusCode + case timeout + case unauthorized + case unknown } - case createURLRequestFailed(HTTPRequestData) - case decodeResponse(HTTPResponseData, Error) - case invalidURLResponse(HTTPRequestData, Data, URLResponse?) - case statusCode(HTTPResponseData) - case timeout(HTTPRequestData) - case unauthorized(HTTPResponseData) + let info: Info + let kind: Kind + let error: Error } -extension StackError: NetworkingError { - var request: HTTPRequestData { - switch self { - case .createURLRequestFailed(let request), .invalidURLResponse(let request, _, _), - .timeout(let request): - return request - case .unauthorized(let response), .decodeResponse(let response, _), .statusCode(let response): - return response.request - } +// MARK: - Init + +extension StackError { + + init(request: HTTPRequestData, kind: Kind, error: Error = NoUnderlyingError()) { + self.init(info: .request(request), kind: kind, error: error) } - var response: HTTPResponseData? { - switch self { - case .createURLRequestFailed, .invalidURLResponse, .timeout: - return nil - case .unauthorized(let response), .decodeResponse(let response, _), .statusCode(let response): - return response + init(response: HTTPResponseData, kind: Kind, error: Error = NoUnderlyingError()) { + self.init(info: .response(response), kind: kind, error: error) + } + + init(_ error: Error, with info: Info) { + if let stackError = error as? StackError { + self = stackError } + self.init(info: info, kind: .unknown, error: error) } + + struct NoUnderlyingError: Error, Equatable { } } -extension StackError: Equatable { - static func == (lhs: StackError, rhs: StackError) -> Bool { +// MARK: Conformances + +extension StackError.Info: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { - case let (.createURLRequestFailed(lhs), .createURLRequestFailed(rhs)): - return lhs == rhs - case let (.decodeResponse(lhs, lhsE), .decodeResponse(rhs, rhsE)): - return lhs == rhs && _isEqual(lhsE, rhsE) - case let (.invalidURLResponse(lhs, lhsD, lhsR), .invalidURLResponse(rhs, rhsD, rhsR)): - return lhs == rhs && _isEqual(lhsD, rhsD) && lhsR == rhsR - case let (.statusCode(lhs), .statusCode(rhs)): + case let (.request(lhs), .request(rhs)): return lhs == rhs - case let (.timeout(lhs), .timeout(rhs)): - return lhs == rhs - case let (.unauthorized(lhs), .unauthorized(rhs)): + case let (.response(lhs), .response(rhs)): return lhs == rhs default: return false } } } + +extension StackError.Kind: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.createURLRequestFailed, .createURLRequestFailed), + (.decodingResponse, .decodingResponse), + (.statusCode, .statusCode), + (.timeout, .timeout), + (.unauthorized, .unauthorized), + (.unknown, .unknown): + return true + case let (.invalidURLResponse(lhsD, lhsR), .invalidURLResponse(rhsD, rhsR)): + return lhsD == rhsD && lhsR == rhsR + default: + return false + } + } +} + +extension StackError: Equatable { + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.info == rhs.info && lhs.kind == rhs.kind && _isEqual(lhs.error, rhs.error) + } +} + +// MARK: - Pattern Match + +func ~= (lhs: StackError.Kind, rhs: Error) -> Bool { + guard let stackError = rhs as? StackError else { return false } + return stackError.kind == lhs +} diff --git a/Sources/TestSupport/StubbedNetworkError.swift b/Sources/TestSupport/StubbedNetworkError.swift index 065a6f11..d88edff9 100644 --- a/Sources/TestSupport/StubbedNetworkError.swift +++ b/Sources/TestSupport/StubbedNetworkError.swift @@ -14,14 +14,17 @@ public struct StubbedNetworkError: Error { guard let httpResponse = response.httpResponse else { fatalError("Unable to create HTTPResponse from \(response)") } - self.init(StackError.statusCode( - HTTPResponseData( - request: request, - data: data, - httpUrlResponse: response, - httpResponse: httpResponse + + self.init( + StackError( + statusCode: HTTPResponseData( + request: request, + data: data, + httpUrlResponse: response, + httpResponse: httpResponse + ) ) - )) + ) } public init(request: HTTPRequestData, data: Data = Data(), status: HTTPResponse.Status = .badGateway) { diff --git a/Tests/NetworkingTests/Components/CheckedStatusCodeTests.swift b/Tests/NetworkingTests/Components/CheckedStatusCodeTests.swift index 51a5ac41..8e2627ba 100644 --- a/Tests/NetworkingTests/Components/CheckedStatusCodeTests.swift +++ b/Tests/NetworkingTests/Components/CheckedStatusCodeTests.swift @@ -40,7 +40,7 @@ final class CheckedStatusCodeTests: XCTestCase { let (network, expectedResponse) = configureNetwork(for: .internalServerError) await XCTAssertThrowsError( try await network.data(expectedResponse.request), - matches: StackError.statusCode(expectedResponse) + matches: StackError(statusCode: expectedResponse) ) } @@ -48,7 +48,7 @@ final class CheckedStatusCodeTests: XCTestCase { let (network, expectedResponse) = configureNetwork(for: .unauthorized) await XCTAssertThrowsError( try await network.data(expectedResponse.request), - matches: StackError.unauthorized(expectedResponse) + matches: StackError(unauthorized: expectedResponse) ) } } diff --git a/Tests/NetworkingTests/Core/NetworkingComponent+DataTests.swift b/Tests/NetworkingTests/Core/NetworkingComponent+DataTests.swift index 902e9ab0..a7e40172 100644 --- a/Tests/NetworkingTests/Core/NetworkingComponent+DataTests.swift +++ b/Tests/NetworkingTests/Core/NetworkingComponent+DataTests.swift @@ -43,7 +43,7 @@ final class NetworkingComponentDataTests: XCTestCase { _ = try await response.data XCTFail("Expected an error to be thrown.") } catch let error as StackError { - XCTAssertEqual(error, StackError.timeout(request)) + XCTAssertEqual(error, StackError(timeout: request)) } catch { XCTFail("Unexpected error \(error)") }