diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ef577b2..68bc728 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,7 +22,7 @@ concurrency: jobs: analyze: name: Analyze - runs-on: [ macos-latest ] + runs-on: macos-14-xlarge permissions: actions: read contents: read @@ -39,6 +39,14 @@ jobs: with: submodules: recursive + - name: Select Xcode + run: | + sudo xcode-select -switch /Applications/Xcode_15.2.app + + - name: Log xcodebuild Version + run: | + xcodebuild -version + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 @@ -48,7 +56,7 @@ jobs: - name: Build run: | - xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest" + xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index 5499c36..cc91273 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -25,17 +25,25 @@ jobs: verify-pr: name: Verify PR - runs-on: macos-latest + runs-on: macos-14-xlarge needs: lint steps: - name: Checkout repository uses: actions/checkout@v3 + - name: Select Xcode + run: | + sudo xcode-select -switch /Applications/Xcode_15.2.app + + - name: Log xcodebuild Version + run: | + xcodebuild -version + - name: Build the Package run: | - set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest" + set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" - name: Run Tests run: | - set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest" test + set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" test diff --git a/Sources/CheckoutNetwork/CheckoutClientInterface.swift b/Sources/CheckoutNetwork/CheckoutClientInterface.swift index 08e08a2..527e8de 100644 --- a/Sources/CheckoutNetwork/CheckoutClientInterface.swift +++ b/Sources/CheckoutNetwork/CheckoutClientInterface.swift @@ -9,8 +9,6 @@ import Foundation /// Interface for a network client that can support Checkout networking requirements public protocol CheckoutClientInterface { - - // MARK: Type Assignments /// Completion handler that will return a result containing a decodable object or an error typealias CompletionHandler = ((Result) -> Void) diff --git a/Sources/CheckoutNetwork/CheckoutNetworkClient+AsyncWrappers.swift b/Sources/CheckoutNetwork/CheckoutNetworkClient+AsyncWrappers.swift new file mode 100644 index 0000000..56b648e --- /dev/null +++ b/Sources/CheckoutNetwork/CheckoutNetworkClient+AsyncWrappers.swift @@ -0,0 +1,38 @@ +// +// CheckoutNetworkClient+AsyncWrappers.swift +// +// +// Created by Okhan Okbay on 20/02/2024. +// + +import Foundation + +public extension CheckoutNetworkClient { + + func runRequest(with configuration: RequestConfiguration) async throws -> T { + return try await withCheckedThrowingContinuation { continuation in + runRequest(with: configuration) { (result: Result) in + switch result { + case .success(let response): + continuation.resume(returning: response) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + func runRequest(with configuration: RequestConfiguration) async throws { + return try await withCheckedThrowingContinuation { continuation in + runRequest(with: configuration) { (error: Error?) in + + guard let error = error else { + continuation.resume(returning: Void()) + return + } + + continuation.resume(throwing: error) + } + } + } +} diff --git a/Sources/CheckoutNetwork/CheckoutNetworkClient+ErrorHandling.swift b/Sources/CheckoutNetwork/CheckoutNetworkClient+ErrorHandling.swift new file mode 100644 index 0000000..ce68c51 --- /dev/null +++ b/Sources/CheckoutNetwork/CheckoutNetworkClient+ErrorHandling.swift @@ -0,0 +1,48 @@ +// +// CheckoutNetworkClient+ErrorHandling.swift +// +// +// Created by Okhan Okbay on 20/02/2024. +// + +import Foundation + +extension CheckoutNetworkClient { + func getErrorFromResponse(_ response: URLResponse?, data: Data?) -> Error? { + guard let response = response as? HTTPURLResponse else { + return CheckoutNetworkError.invalidURLResponse + } + + guard response.statusCode != 422 else { + do { + let errorReason = try JSONDecoder().decode(ErrorReason.self, from: data ?? Data()) + return CheckoutNetworkError.unprocessableContent(reason: errorReason) + } catch { + return CheckoutNetworkError.noDataResponseReceived + } + } + + guard (200..<300).contains(response.statusCode) else { + return CheckoutNetworkError.unexpectedHTTPResponse(code: response.statusCode) + } + return nil + } + + func convertDataTaskErrorsToCheckoutNetworkError(error: Error) -> CheckoutNetworkError { + let error = error as NSError + + switch error.code { + case NSURLErrorNotConnectedToInternet, + NSURLErrorTimedOut, + NSURLErrorNetworkConnectionLost, + NSURLErrorInternationalRoamingOff, + NSURLErrorCannotConnectToHost, + NSURLErrorServerCertificateUntrusted: + + return .connectivity + + default: + return .other(underlyingError: error) + } + } +} diff --git a/Sources/CheckoutNetwork/CheckoutNetworkClient.swift b/Sources/CheckoutNetwork/CheckoutNetworkClient.swift index 952679d..ba2605e 100644 --- a/Sources/CheckoutNetwork/CheckoutNetworkClient.swift +++ b/Sources/CheckoutNetwork/CheckoutNetworkClient.swift @@ -32,7 +32,7 @@ public class CheckoutNetworkClient: CheckoutClientInterface { self?.tasks.removeValue(forKey: taskID) guard let self = self else { return } if let error = error { - completionHandler(.failure(error)) + completionHandler(.failure(convertDataTaskErrorsToCheckoutNetworkError(error: error))) return } @@ -88,56 +88,4 @@ public class CheckoutNetworkClient: CheckoutClientInterface { } return taskID } - - private func getErrorFromResponse(_ response: URLResponse?, data: Data?) -> Error? { - guard let response = response as? HTTPURLResponse else { - return CheckoutNetworkError.unexpectedResponseCode(code: 0) - } - - guard response.statusCode != 422 else { - do { - let errorReason = try JSONDecoder().decode(ErrorReason.self, from: data ?? Data()) - return CheckoutNetworkError.invalidData(reason: errorReason) - } catch { - return CheckoutNetworkError.noDataResponseReceived - } - } - - guard response.statusCode >= 200, - response.statusCode < 300 else { - return CheckoutNetworkError.unexpectedResponseCode(code: response.statusCode) - } - return nil - } -} - -// MARK: Async Wrappers -public extension CheckoutNetworkClient { - - func runRequest(with configuration: RequestConfiguration) async throws -> T { - return try await withCheckedThrowingContinuation { continuation in - runRequest(with: configuration) { (result: Result) in - switch result { - case .success(let response): - continuation.resume(returning: response) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - func runRequest(with configuration: RequestConfiguration) async throws { - return try await withCheckedThrowingContinuation { continuation in - runRequest(with: configuration) { (error: Error?) in - - guard let error = error else { - continuation.resume(returning: Void()) - return - } - - continuation.resume(throwing: error) - } - } - } } diff --git a/Sources/CheckoutNetwork/CheckoutNetworkError.swift b/Sources/CheckoutNetwork/CheckoutNetworkError.swift index a98ba20..0d31c36 100644 --- a/Sources/CheckoutNetwork/CheckoutNetworkError.swift +++ b/Sources/CheckoutNetwork/CheckoutNetworkError.swift @@ -1,23 +1,67 @@ // // CheckoutNetworkError.swift -// +// // // Created by Alex Ioja-Yang on 20/06/2022. // import Foundation -public enum CheckoutNetworkError: Error, Equatable { - - /// Review the url you provided - case invalidURL - - /// Network response was not in the 200 range - case unexpectedResponseCode(code: Int) - - /// Network call and completion appear valid but no data was returned making the parsing impossible. Use runRequest method with NoDataResponseCompletionHandler if no data is expected (HTTP 204 is a success case with no content being returned) - case noDataResponseReceived - - /// Network response returned with HTTP Code 422 - case invalidData(reason: ErrorReason) +public enum CheckoutNetworkError: LocalizedError, Equatable { + + /// Review the url you provided + case invalidURL + + /// When a response could not be bound to an instance of HTTPURLResponse + case invalidURLResponse + + /// Network response was not in the 200 range + case unexpectedHTTPResponse(code: Int) + + /// Network call and completion appear valid but no data was returned making the parsing impossible. Use runRequest method with NoDataResponseCompletionHandler if no data is expected (HTTP 204 is a success case with no content being returned) + case noDataResponseReceived + + /// Network response returned with HTTP Code 422 + case unprocessableContent(reason: ErrorReason) + + /// Connectivity errors mapped from URLSession.dataTask()'s error + /// + /// Only the following are mapped into this error case: + /// NSURLErrorNotConnectedToInternet + /// NSURLErrorTimedOut + /// NSURLErrorNetworkConnectionLost + /// NSURLErrorInternationalRoamingOff + /// NSURLErrorCannotConnectToHost + /// NSURLErrorServerCertificateUntrusted + case connectivity + + /// All the other errors that can be received from URLSession. + /// Use the underlying error if you need more granular error handling. + /// Underlying errors here are in NSURLErrorDomain. + case other(underlyingError: NSError) + + public var errorDescription: String? { + switch self { + case .invalidURL: + return "Could not instantiate a URL with the provided String value" + + case .invalidURLResponse: + return "Could not instantiate an HTTPURLResponse with the received response value" + + case .unexpectedHTTPResponse(let code): + return "Received an unexpected HTTP response: \(code)" + + case .noDataResponseReceived: + return "No data is received in the response body. Use the method with NoDataResponseCompletionHandler if no data was expected" + + case .unprocessableContent(let reason): + return "HTTP response 422 is received with: \(reason)" + + case .connectivity: + return "There is a problem with the internet connection" + + case .other(let error): + return "An unhandled error is produced: \(error)" + } + } } diff --git a/Sources/CheckoutNetwork/Models/ErrorReason.swift b/Sources/CheckoutNetwork/Models/ErrorReason.swift index 9193a60..6faba02 100644 --- a/Sources/CheckoutNetwork/Models/ErrorReason.swift +++ b/Sources/CheckoutNetwork/Models/ErrorReason.swift @@ -18,3 +18,9 @@ public struct ErrorReason: Decodable, Equatable { case errorCodes = "error_codes" } } + +extension ErrorReason: CustomStringConvertible { + public var description: String { + "\(requestID) \(errorType) \(errorCodes.joined(separator: ", "))" + } +} diff --git a/Tests/CheckoutNetworkTests/CheckoutNetworkClientTests.swift b/Tests/CheckoutNetworkTests/CheckoutNetworkClientTests.swift index 3e1cc3c..b67adcc 100644 --- a/Tests/CheckoutNetworkTests/CheckoutNetworkClientTests.swift +++ b/Tests/CheckoutNetworkTests/CheckoutNetworkClientTests.swift @@ -62,7 +62,7 @@ final class CheckoutNetworkClientTests: XCTestCase { let expectedData = "nothing".data(using: .utf8) let expectedResponse = URLResponse() let expectedError = NSError(domain: "fail", code: 12345) - + let expect = expectation(description: "Ensure completion handler is called") client.runRequest(with: testConfig) { (result: Result) in expect.fulfill() @@ -70,7 +70,7 @@ final class CheckoutNetworkClientTests: XCTestCase { case .success(_): XCTFail("Test expects a specific error to be returned") case .failure(let failure): - XCTAssertEqual(failure as NSError, expectedError) + XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.other(underlyingError: expectedError)) } } @@ -100,7 +100,7 @@ final class CheckoutNetworkClientTests: XCTestCase { case .success(_): XCTFail("Test expects a specific error to be returned") case .failure(let failure): - XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: testResponseCode)) + XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.unexpectedHTTPResponse(code: testResponseCode)) } } @@ -126,7 +126,7 @@ final class CheckoutNetworkClientTests: XCTestCase { case .success(_): XCTFail("Test expects a specific error to be returned") case .failure(let failure): - XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: 0)) + XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.invalidURLResponse) } } @@ -297,7 +297,7 @@ final class CheckoutNetworkClientTests: XCTestCase { let expect = expectation(description: "Ensure completion handler is called") client.runRequest(with: testConfig) { expect.fulfill() - XCTAssertEqual($0 as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: testResponseCode)) + XCTAssertEqual($0 as? CheckoutNetworkError, CheckoutNetworkError.unexpectedHTTPResponse(code: testResponseCode)) } XCTAssertFalse(client.tasks.isEmpty) @@ -318,7 +318,7 @@ final class CheckoutNetworkClientTests: XCTestCase { let expect = expectation(description: "Ensure completion handler is called") client.runRequest(with: testConfig) { expect.fulfill() - XCTAssertEqual($0 as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: 0)) + XCTAssertEqual($0 as? CheckoutNetworkError, CheckoutNetworkError.invalidURLResponse) } XCTAssertFalse(client.tasks.isEmpty)