Skip to content

Commit

Permalink
feat(foundation): Foundation API (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
pjechris authored Jan 27, 2022
1 parent 1d9090e commit a4374d6
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 0 deletions.
20 changes: 20 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions Sources/Pinata/Foundation/Publisher+Validate.swift
Original file line number Diff line number Diff line change
@@ -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<Output, Error> {
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
22 changes: 22 additions & 0 deletions Sources/Pinata/Foundation/URLRequest+Encode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public extension URLRequest {
func encodedBody<T: Encodable>(_ 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<T: Encodable>(_ body: T, encoder: JSONEncoder) throws {
httpBody = try encoder.encode(body)
setValue("Content-Type", forHTTPHeaderField: "application/json")
}

}
14 changes: 14 additions & 0 deletions Sources/Pinata/Foundation/URLResponse+Validate.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
39 changes: 39 additions & 0 deletions Sources/Pinata/HTTP/HTTPError.swift
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions Tests/PinataTests/Foundation/PublisherTests+Validate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import XCTest
import Combine
import Pinata

class PublisherValidateTests: XCTestCase {
var cancellables: Set<AnyCancellable> = []

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
}
17 changes: 17 additions & 0 deletions Tests/PinataTests/Foundation/URLRequestsTests+Encode.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
27 changes: 27 additions & 0 deletions Tests/PinataTests/Foundation/URLResponseValidateTests.swift
Original file line number Diff line number Diff line change
@@ -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()
)
}
}
17 changes: 17 additions & 0 deletions Tests/PinataTests/HTTPURLResponse+Fixture.swift
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit a4374d6

Please sign in to comment.