diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index b127bd8..3e332df 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,30 +1,18 @@ -name: Swift +name: Test on: [pull_request] jobs: - build: - runs-on: macos-latest - - steps: - - uses: actions/checkout@v2 - - name: Build - run: swift build -v test: runs-on: macos-latest - steps: - - uses: actions/checkout@v2 - - name: Run tests - run: swift test -v --enable-code-coverage - - codecov: - runs-on: macos-latest + steps: - uses: actions/checkout@v2 - name: Generate coverage report - run: xcodebuild -scheme SwiftQuests -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 11 Pro' -enableCodeCoverage YES build test + run: | + swift test --enable-code-coverage + xcrun llvm-cov export -format="lcov" .build/debug/SwiftQuestsPackageTests.xctest/Contents/MacOS/SwiftQuestsPackageTests -instr-profile .build/debug/codecov/default.profdata > info.lcov - name: Codecov uses: codecov/codecov-action@v1.0.5 with: token: 44439f4d-d012-4000-a0c1-28647d07b545 - # file: diff --git a/Package.swift b/Package.swift index 462532c..7be2232 100644 --- a/Package.swift +++ b/Package.swift @@ -1,20 +1,20 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.3 import PackageDescription let package = Package( - name: "SwiftQuests", - products: [ - .library( - name: "SwiftQuests", - targets: ["SwiftQuests"]), - ], - targets: [ - .target( - name: "SwiftQuests", - dependencies: []), - .testTarget( - name: "SwiftQuestsTests", - dependencies: ["SwiftQuests"]), - ] + name: "SwiftQuests", + products: [ + .library( + name: "SwiftQuests", + targets: ["SwiftQuests"]), + ], + targets: [ + .target( + name: "SwiftQuests", + dependencies: []), + .testTarget( + name: "SwiftQuestsTests", + dependencies: ["SwiftQuests"]), + ] ) diff --git a/Sources/SwiftQuests/NetworkError.swift b/Sources/SwiftQuests/NetworkError.swift new file mode 100644 index 0000000..213e854 --- /dev/null +++ b/Sources/SwiftQuests/NetworkError.swift @@ -0,0 +1,76 @@ +// +// NetworkError.swift +// +// +// Created by Andrea Sacerdoti on 17/12/20. +// + +import Foundation + +/// A specialized error for network purposes. +public enum NetworkError: LocalizedError { + + /// A generic network error. + case generic + + /// An error identifying the 400 HTTP status code. + case badRequest + + /// An error identifying the 401 HTTP status code. + case unauthorized + + /// An error identifying the 403 HTTP status code. + case forbidden + + /// An error identifying the 404 HTTP status code. + case notFound + + /// An error identifying the 500 HTTP status code. + case internalServerError + + /// An error including a description provided by the backend. + case withDescription(String) + + public var errorDescription: String? { + switch self { + case .generic: + return NSLocalizedString("Generic error.", comment: "") + case .badRequest: + return NSLocalizedString("Bad request.", comment: "") + case .unauthorized: + return NSLocalizedString("Unauthorized.", comment: "") + case .forbidden: + return NSLocalizedString("Access denied.", comment: "") + case .notFound: + return NSLocalizedString("Resource not found.", comment: "") + case .internalServerError: + return NSLocalizedString("Internal server error.", comment: "") + case .withDescription(let description): + return NSLocalizedString(description, comment: "") + } + } + + /// Returns a NetworkError identifying a given HTTP error. + /// - Parameter statusCode: The given HTTP status code. + /// - Returns: A NetworkError identified by the given status code. + public static func identifying(statusCode: Int) -> NetworkError { + switch statusCode { + case 400: + return NetworkError.badRequest + case 401: + return NetworkError.unauthorized + case 403: + return NetworkError.forbidden + case 404: + return NetworkError.notFound + case 500: + return NetworkError.internalServerError + default: + return NetworkError.generic + } + } +} + +// MARK: - +extension NetworkError: Equatable { +} diff --git a/Sources/SwiftQuests/Request.swift b/Sources/SwiftQuests/Request.swift index 2ea65ec..6ae2deb 100644 --- a/Sources/SwiftQuests/Request.swift +++ b/Sources/SwiftQuests/Request.swift @@ -123,7 +123,6 @@ open class Request: AbstractRequest { /// - Parameters: /// - completionHandler: An handler called upon completion. /// - result: The response result. - /// - error: The task error. /// - Throws: An error if either the `urlRequest` property was not properly initialised, or the `completionHandler` /// throws. open func perform(_ completionHandler: @escaping (_ result: Result) throws -> Void) { @@ -134,6 +133,13 @@ open class Request: AbstractRequest { return } + if let statusCode = (response as? HTTPURLResponse)?.statusCode, + !(200..<300 ~= statusCode) { + + try? completionHandler(.failure(NetworkError.identifying(statusCode: statusCode))) + return + } + try? completionHandler(.success((data, response))) } @@ -153,7 +159,6 @@ open class Request: AbstractRequest { /// - object: An object type to decode from the response data. /// - completionHandler: An handler called upon completion. /// - result: The response result. - /// - error: The task error. open func perform(decoding object: T.Type, _ completionHandler: @escaping ( _ result: Result<(T, URLResponse?), Error>) throws -> Void) { @@ -161,7 +166,8 @@ open class Request: AbstractRequest { perform { result in switch result { case .success(let response): - guard let data = response.data else { + guard let data = response.data, + !data.isEmpty else { try completionHandler(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Data returned nil."]))) diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index f0d68d1..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import SwiftQuestsTests - -var tests = [XCTestCaseEntry]() -tests += SwiftQuestsTests.allTests() -XCTMain(tests) diff --git a/Tests/SwiftQuestsTests/Mocks/URLProtocolMock.swift b/Tests/SwiftQuestsTests/Mocks/URLProtocolMock.swift new file mode 100644 index 0000000..5ffac51 --- /dev/null +++ b/Tests/SwiftQuestsTests/Mocks/URLProtocolMock.swift @@ -0,0 +1,42 @@ +// +// URLProtocolMock.swift +// +// +// Created by Andrea Sacerdoti on 05/02/2020. +// + +import Foundation + +class URLProtocolMock: URLProtocol { + static var response: (data: Data?, response: URLResponse?, error: Error?)? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + if let (data, response, error) = URLProtocolMock.response { + + if let response = response { + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + } + + if let data = data { + client?.urlProtocol(self, didLoad: data) + } + + if let error = error { + client?.urlProtocol(self, didFailWithError: error) + } + } + + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { + } +} diff --git a/Tests/SwiftQuestsTests/Mocks/URLSessionCodableMock.swift b/Tests/SwiftQuestsTests/Mocks/URLSessionCodableMock.swift deleted file mode 100644 index 82659d0..0000000 --- a/Tests/SwiftQuestsTests/Mocks/URLSessionCodableMock.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// URLSessionCodableMock.swift -// -// -// Created by Andrea Sacerdoti on 06/02/2020. -// - -import Foundation - -class URLSessionCodableMock: URLSessionMock { - override init() { - super.init() - let user = User() - user.username = "test" - self.data = try? JSONEncoder().encode(user) - } -} diff --git a/Tests/SwiftQuestsTests/Mocks/URLSessionDataTaskMock.swift b/Tests/SwiftQuestsTests/Mocks/URLSessionDataTaskMock.swift deleted file mode 100644 index c659643..0000000 --- a/Tests/SwiftQuestsTests/Mocks/URLSessionDataTaskMock.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// URLSessionDataTaskMock.swift -// -// -// Created by Andrea Sacerdoti on 05/02/2020. -// - -import Foundation - -class URLSessionDataTaskMock: URLSessionDataTask { - private let completion: () -> Void - - init(completion: @escaping () -> Void) { - self.completion = completion - } - - override func resume() { - completion() - } -} diff --git a/Tests/SwiftQuestsTests/Mocks/URLSessionMock.swift b/Tests/SwiftQuestsTests/Mocks/URLSessionMock.swift deleted file mode 100644 index e0abb73..0000000 --- a/Tests/SwiftQuestsTests/Mocks/URLSessionMock.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// URLSessionMock.swift -// -// -// Created by Andrea Sacerdoti on 05/02/2020. -// - -import Foundation - -class URLSessionMock: URLSession { - var data: Data? - var response: URLResponse? - var error: Error? - - override func dataTask(with request: URLRequest, - completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { - URLSessionDataTaskMock { - completionHandler(self.data, self.response, self.error) - } - } -} diff --git a/Tests/SwiftQuestsTests/NetworkErrorTests.swift b/Tests/SwiftQuestsTests/NetworkErrorTests.swift new file mode 100644 index 0000000..859969c --- /dev/null +++ b/Tests/SwiftQuestsTests/NetworkErrorTests.swift @@ -0,0 +1,31 @@ +// +// NetworkErrorTests.swift +// +// +// Created by Andrea Sacerdoti on 02/07/21. +// + +import XCTest +@testable import SwiftQuests + +final class NetworkErrorTests: XCTestCase { + + func testErrorDescription() { + XCTAssertNotNil(NetworkError.generic.errorDescription) + XCTAssertNotNil(NetworkError.badRequest.errorDescription) + XCTAssertNotNil(NetworkError.unauthorized.errorDescription) + XCTAssertNotNil(NetworkError.forbidden.errorDescription) + XCTAssertNotNil(NetworkError.notFound.errorDescription) + XCTAssertNotNil(NetworkError.internalServerError.errorDescription) + XCTAssertEqual(NetworkError.withDescription("Test Description").localizedDescription, "Test Description") + } + + func testIdentifying() { + XCTAssertEqual(NetworkError.identifying(statusCode: 400), .badRequest) + XCTAssertEqual(NetworkError.identifying(statusCode: 401), .unauthorized) + XCTAssertEqual(NetworkError.identifying(statusCode: 403), .forbidden) + XCTAssertEqual(NetworkError.identifying(statusCode: 404), .notFound) + XCTAssertEqual(NetworkError.identifying(statusCode: 405), .generic) + XCTAssertEqual(NetworkError.identifying(statusCode: 500), .internalServerError) + } +} diff --git a/Tests/SwiftQuestsTests/RequestTests.swift b/Tests/SwiftQuestsTests/RequestTests.swift index d664591..9d229b6 100644 --- a/Tests/SwiftQuestsTests/RequestTests.swift +++ b/Tests/SwiftQuestsTests/RequestTests.swift @@ -11,8 +11,14 @@ import XCTest final class RequestTests: XCTestCase { var request: Request? + var session = URLSession(configuration: .ephemeral) + override func setUp() { super.setUp() + URLProtocolMock.response = nil + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolMock.self] + session = URLSession(configuration: configuration) request = try? Request(.get, atPath: "/user", parameters: ["user": "12345"], @@ -22,7 +28,7 @@ final class RequestTests: XCTestCase { using: URLCredential(user: "test", password: "testPassword", persistence: .forSession), - onSession: URLSessionMock(), + onSession: session, configuration: DefaultRequestConfiguration()) } @@ -76,8 +82,10 @@ final class RequestTests: XCTestCase { func testPerformErrorReturn() { let throwExpectation = expectation(description: "Perform should throw an error") - let session = URLSessionMock() - session.error = NSError(domain: "test", code: 42, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + URLProtocolMock.response = (nil, nil, NSError(domain: "test", + code: 42, + userInfo: [NSLocalizedDescriptionKey: "Test error"])) + try? Request(.get, atPath: "/test", @@ -86,17 +94,43 @@ final class RequestTests: XCTestCase { XCTAssertThrowsError(try result.get()) throwExpectation.fulfill() - } + } + + wait(for: [throwExpectation], timeout: 5) + } + + func testPerformStatusCodeErrorReturn() { + let throwExpectation = expectation(description: "Perform should throw an error") + URLProtocolMock.response = (nil, + HTTPURLResponse(url: URL(string: "https://test.com/test")!, + statusCode: 404, + httpVersion: nil, + headerFields: [:]), + nil) + + try? Request(.get, + atPath: "/test", + onSession: session) + .perform { result in + + if case .failure(let error) = result { + XCTAssertEqual(error as? NetworkError, NetworkError.notFound) + throwExpectation.fulfill() + } + } wait(for: [throwExpectation], timeout: 5) } func testPerformDecoding() { let decodingExpectation = expectation(description: "Object should decode correctly") + let user = User() + user.username = "test" + URLProtocolMock.response = (try? JSONEncoder().encode(user), nil, nil) try? Request(.get, atPath: "/user", - onSession: URLSessionCodableMock()) + onSession: session) .perform(decoding: User.self) { result in if let response = try? result.get(), @@ -111,12 +145,11 @@ final class RequestTests: XCTestCase { func testPerformDecodingError() { let throwingExpectation = expectation(description: "Perform should throw an error") - let sessionMock = URLSessionCodableMock() - sessionMock.data = Data(base64Encoded: "VEhJU0lTV1JPTkc=") + URLProtocolMock.response = (Data(base64Encoded: "VEhJU0lTV1JPTkc="), nil, nil) try? Request(.get, atPath: "/user", - onSession: sessionMock) + onSession: session) .perform(decoding: User.self) { result in XCTAssertThrowsError(try result.get()) @@ -128,12 +161,16 @@ final class RequestTests: XCTestCase { func testPerformDecodingNoDataError() { let throwingExpectation = expectation(description: "Perform should throw an error") - let sessionMock = URLSessionCodableMock() - sessionMock.data = nil + URLProtocolMock.response = (nil, + HTTPURLResponse(url: URL(string: "https://test.com/test")!, + statusCode: 200, + httpVersion: nil, + headerFields: [:]), + nil) try? Request(.get, atPath: "/user", - onSession: sessionMock) + onSession: session) .perform(decoding: User.self) { result in XCTAssertThrowsError(try result.get()) @@ -145,12 +182,11 @@ final class RequestTests: XCTestCase { func testPerformDecodingPerformError() { let throwingExpectation = expectation(description: "Perform should throw an error") - let sessionMock = URLSessionCodableMock() - sessionMock.error = NSError(domain: "test", code: 42, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + URLProtocolMock.response = (nil, nil, NSError(domain: "test", code: 42, userInfo: [NSLocalizedDescriptionKey: "Test error"])) try? Request(.get, atPath: "/user", - onSession: sessionMock) + onSession: session) .perform(decoding: User.self) { result in XCTAssertThrowsError(try result.get()) @@ -159,13 +195,4 @@ final class RequestTests: XCTestCase { wait(for: [throwingExpectation], timeout: 5) } - - static var allTests = [ - ("testInit", testInitMethod), - ("testInitResourcePath", testInitResourcePath), - ("testInitParameters", testInitParameters), - ("testInitHeaders", testInitHeaders), - ("testInitBody", testInitBody), - ("testPerform", testPerform), - ] } diff --git a/Tests/SwiftQuestsTests/XCTestManifests.swift b/Tests/SwiftQuestsTests/XCTestManifests.swift deleted file mode 100644 index 33ff12b..0000000 --- a/Tests/SwiftQuestsTests/XCTestManifests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(RequestTests.allTests), - testCase(RequestConfigurationTests.allTests) - ] -} -#endif