diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f32d2aa --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: macos-11 + + steps: + - name: Checkout repo + uses: actions/checkout@v2 + + - name: Run tests + run: swift test --enable-test-discovery diff --git a/Sources/Pinata/Foundation/Publisher+Validate.swift b/Sources/Pinata/Foundation/Publisher+Validate.swift new file mode 100644 index 0000000..f71f940 --- /dev/null +++ b/Sources/Pinata/Foundation/Publisher+Validate.swift @@ -0,0 +1,31 @@ +#if canImport(Combine) + +import Foundation +import Combine + +/// A function converting data when a http error occur into a custom error +public typealias DataErrorConverter = (Data) throws -> Error + +extension Publisher where Output == URLSession.DataTaskPublisher.Output { + /// validate publisher result optionally converting HTTP error into a custom one + /// - Parameter converter: called when error is `HTTPError` and data was found in the output. Use it to convert + /// data in a custom `Error` that will be returned of the http one. + public func validate(_ converter: DataErrorConverter? = nil) -> AnyPublisher { + tryMap { output in + do { + try (output.response as? HTTPURLResponse)?.validate() + return output + } + catch { + if let _ = error as? HTTPError, let convert = converter, !output.data.isEmpty { + throw try convert(output.data) + } + + throw error + } + } + .eraseToAnyPublisher() + } +} + +#endif diff --git a/Sources/Pinata/Foundation/URLRequest+Encode.swift b/Sources/Pinata/Foundation/URLRequest+Encode.swift new file mode 100644 index 0000000..aab59ee --- /dev/null +++ b/Sources/Pinata/Foundation/URLRequest+Encode.swift @@ -0,0 +1,22 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public extension URLRequest { + func encodedBody(_ body: T, encoder: JSONEncoder) throws -> Self { + var request = self + + try request.encodeBody(body, encoder: encoder) + + return request + } + + /// Use a `JSONEncoder` object as request body and set the "Content-Type" header associated to the encoder + mutating func encodeBody(_ body: T, encoder: JSONEncoder) throws { + httpBody = try encoder.encode(body) + setValue("Content-Type", forHTTPHeaderField: "application/json") + } + +} diff --git a/Sources/Pinata/Foundation/URLResponse+Validate.swift b/Sources/Pinata/Foundation/URLResponse+Validate.swift new file mode 100644 index 0000000..612849c --- /dev/null +++ b/Sources/Pinata/Foundation/URLResponse+Validate.swift @@ -0,0 +1,14 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension HTTPURLResponse { + /// check whether a response is valid or not + public func validate() throws { + guard (200..<300).contains(statusCode) else { + throw HTTPError(statusCode: statusCode) + } + } +} diff --git a/Sources/Pinata/HTTP/HTTPError.swift b/Sources/Pinata/HTTP/HTTPError.swift new file mode 100644 index 0000000..649dd86 --- /dev/null +++ b/Sources/Pinata/HTTP/HTTPError.swift @@ -0,0 +1,39 @@ +import Foundation + +/// An error generated by a HTTP response +public struct HTTPError: Error, Equatable, ExpressibleByIntegerLiteral { + public let statusCode: Int + + public init(statusCode: Int) { + self.statusCode = statusCode + } + + public init(integerLiteral value: IntegerLiteralType) { + self.init(statusCode: value) + } +} + +public extension HTTPError { + static let badRequest: Self = 400 + + static let unauthorized: Self = 401 + + /// Request contained valid data and was understood by the server, but the server is refusing action + static let forbidden: Self = 403 + + static let notFound: Self = 404 + + static let requestTimeout: Self = 408 + + /// Generic error message when an unexpected condition was encountered and no more specific message is suitable + static let serverError: Self = 500 + + /// Server was acting as a gateway or proxy and received an invalid response from the upstream server + static let badGateway: Self = 502 + + /// Server cannot handle the request (because it is overloaded or down for maintenance) + static let serviceUnavailable: Self = 503 + + /// Server was acting as a gateway or proxy and did not receive a timely response from the upstream server + static let gatewayTimeout: Self = 504 +} diff --git a/Tests/PinataTests/Foundation/PublisherTests+Validate.swift b/Tests/PinataTests/Foundation/PublisherTests+Validate.swift new file mode 100644 index 0000000..8abf922 --- /dev/null +++ b/Tests/PinataTests/Foundation/PublisherTests+Validate.swift @@ -0,0 +1,53 @@ +import XCTest +import Combine +import Pinata + +class PublisherValidateTests: XCTestCase { + var cancellables: Set = [] + + override func tearDown() { + cancellables = [] + } + + func test_validate_responseIsError_dataIsEmpty_converterIsNotCalled() throws { + let output: URLSession.DataTaskPublisher.Output = (data: Data(), response: HTTPURLResponse.notFound) + let transformer: DataErrorConverter = { _ in + XCTFail("transformer should not be called when data is empty") + throw NSError() + } + + Just(output) + .validate(transformer) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + .store(in: &cancellables) + } + + func test_validate_responseIsError_dataIsNotEmpty_returnCustomError() throws { + let customError = CustomError(code: 22, message: "custom message") + let output: URLSession.DataTaskPublisher.Output = ( + data: try JSONEncoder().encode(customError), + response: HTTPURLResponse.notFound + ) + let transformer: DataErrorConverter = { data in + return try JSONDecoder().decode(CustomError.self, from: data) + } + + Just(output) + .validate(transformer) + .sink( + receiveCompletion: { + guard case let .failure(error) = $0 else { + return XCTFail() + } + + XCTAssertEqual(error as? CustomError, customError) + }, + receiveValue: { _ in }) + .store(in: &cancellables) + } +} + +private struct CustomError: Error, Equatable, Codable { + let code: Int + let message: String +} diff --git a/Tests/PinataTests/Foundation/URLRequestsTests+Encode.swift b/Tests/PinataTests/Foundation/URLRequestsTests+Encode.swift new file mode 100644 index 0000000..e2daca6 --- /dev/null +++ b/Tests/PinataTests/Foundation/URLRequestsTests+Encode.swift @@ -0,0 +1,17 @@ +import XCTest +import Pinata + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +class URLRequestEncodeTests: XCTest { + + func test_encodedBody_itSetContentTypeHeader() throws { + let body: [String:String] = [:] + let request = try URLRequest(url: URL(string: "/")!) + .encodedBody(body, encoder: JSONEncoder()) + + XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") + } +} diff --git a/Tests/PinataTests/Foundation/URLResponseValidateTests.swift b/Tests/PinataTests/Foundation/URLResponseValidateTests.swift new file mode 100644 index 0000000..3e5aa57 --- /dev/null +++ b/Tests/PinataTests/Foundation/URLResponseValidateTests.swift @@ -0,0 +1,27 @@ +import XCTest +import Pinata + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +class URLResponseValidateTests: XCTest { + let url = URL(string: "/")! + + func test_validate_statusCodeIsOK_itThrowNoError() throws { + try HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!.validate() + } + + // we should never have redirection that's why we consider it as an error + func test_validate_statusCodeIsRedirection_itThrow() { + XCTAssertThrowsError( + try HTTPURLResponse(url: url, statusCode: 302, httpVersion: nil, headerFields: nil)!.validate() + ) + } + + func test_validate_statusCodeIsClientError_itThrow() { + XCTAssertThrowsError( + try HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!.validate() + ) + } +} diff --git a/Tests/PinataTests/HTTPURLResponse+Fixture.swift b/Tests/PinataTests/HTTPURLResponse+Fixture.swift new file mode 100644 index 0000000..effdf4a --- /dev/null +++ b/Tests/PinataTests/HTTPURLResponse+Fixture.swift @@ -0,0 +1,17 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension HTTPURLResponse { + convenience init(statusCode: Int) { + self.init(url: URL(string: "/")!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + } +} + +extension URLResponse { + static let success = HTTPURLResponse(statusCode: 200) + static let unauthorized = HTTPURLResponse(statusCode: 401) + static let notFound = HTTPURLResponse(statusCode: 404) +}