Skip to content

Commit a4374d6

Browse files
authored
feat(foundation): Foundation API (#1)
1 parent 1d9090e commit a4374d6

File tree

9 files changed

+240
-0
lines changed

9 files changed

+240
-0
lines changed

.github/workflows/test.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
11+
jobs:
12+
test:
13+
runs-on: macos-11
14+
15+
steps:
16+
- name: Checkout repo
17+
uses: actions/checkout@v2
18+
19+
- name: Run tests
20+
run: swift test --enable-test-discovery
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#if canImport(Combine)
2+
3+
import Foundation
4+
import Combine
5+
6+
/// A function converting data when a http error occur into a custom error
7+
public typealias DataErrorConverter = (Data) throws -> Error
8+
9+
extension Publisher where Output == URLSession.DataTaskPublisher.Output {
10+
/// validate publisher result optionally converting HTTP error into a custom one
11+
/// - Parameter converter: called when error is `HTTPError` and data was found in the output. Use it to convert
12+
/// data in a custom `Error` that will be returned of the http one.
13+
public func validate(_ converter: DataErrorConverter? = nil) -> AnyPublisher<Output, Error> {
14+
tryMap { output in
15+
do {
16+
try (output.response as? HTTPURLResponse)?.validate()
17+
return output
18+
}
19+
catch {
20+
if let _ = error as? HTTPError, let convert = converter, !output.data.isEmpty {
21+
throw try convert(output.data)
22+
}
23+
24+
throw error
25+
}
26+
}
27+
.eraseToAnyPublisher()
28+
}
29+
}
30+
31+
#endif
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
3+
#if canImport(FoundationNetworking)
4+
import FoundationNetworking
5+
#endif
6+
7+
public extension URLRequest {
8+
func encodedBody<T: Encodable>(_ body: T, encoder: JSONEncoder) throws -> Self {
9+
var request = self
10+
11+
try request.encodeBody(body, encoder: encoder)
12+
13+
return request
14+
}
15+
16+
/// Use a `JSONEncoder` object as request body and set the "Content-Type" header associated to the encoder
17+
mutating func encodeBody<T: Encodable>(_ body: T, encoder: JSONEncoder) throws {
18+
httpBody = try encoder.encode(body)
19+
setValue("Content-Type", forHTTPHeaderField: "application/json")
20+
}
21+
22+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foundation
2+
3+
#if canImport(FoundationNetworking)
4+
import FoundationNetworking
5+
#endif
6+
7+
extension HTTPURLResponse {
8+
/// check whether a response is valid or not
9+
public func validate() throws {
10+
guard (200..<300).contains(statusCode) else {
11+
throw HTTPError(statusCode: statusCode)
12+
}
13+
}
14+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
3+
/// An error generated by a HTTP response
4+
public struct HTTPError: Error, Equatable, ExpressibleByIntegerLiteral {
5+
public let statusCode: Int
6+
7+
public init(statusCode: Int) {
8+
self.statusCode = statusCode
9+
}
10+
11+
public init(integerLiteral value: IntegerLiteralType) {
12+
self.init(statusCode: value)
13+
}
14+
}
15+
16+
public extension HTTPError {
17+
static let badRequest: Self = 400
18+
19+
static let unauthorized: Self = 401
20+
21+
/// Request contained valid data and was understood by the server, but the server is refusing action
22+
static let forbidden: Self = 403
23+
24+
static let notFound: Self = 404
25+
26+
static let requestTimeout: Self = 408
27+
28+
/// Generic error message when an unexpected condition was encountered and no more specific message is suitable
29+
static let serverError: Self = 500
30+
31+
/// Server was acting as a gateway or proxy and received an invalid response from the upstream server
32+
static let badGateway: Self = 502
33+
34+
/// Server cannot handle the request (because it is overloaded or down for maintenance)
35+
static let serviceUnavailable: Self = 503
36+
37+
/// Server was acting as a gateway or proxy and did not receive a timely response from the upstream server
38+
static let gatewayTimeout: Self = 504
39+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import XCTest
2+
import Combine
3+
import Pinata
4+
5+
class PublisherValidateTests: XCTestCase {
6+
var cancellables: Set<AnyCancellable> = []
7+
8+
override func tearDown() {
9+
cancellables = []
10+
}
11+
12+
func test_validate_responseIsError_dataIsEmpty_converterIsNotCalled() throws {
13+
let output: URLSession.DataTaskPublisher.Output = (data: Data(), response: HTTPURLResponse.notFound)
14+
let transformer: DataErrorConverter = { _ in
15+
XCTFail("transformer should not be called when data is empty")
16+
throw NSError()
17+
}
18+
19+
Just(output)
20+
.validate(transformer)
21+
.sink(receiveCompletion: { _ in }, receiveValue: { _ in })
22+
.store(in: &cancellables)
23+
}
24+
25+
func test_validate_responseIsError_dataIsNotEmpty_returnCustomError() throws {
26+
let customError = CustomError(code: 22, message: "custom message")
27+
let output: URLSession.DataTaskPublisher.Output = (
28+
data: try JSONEncoder().encode(customError),
29+
response: HTTPURLResponse.notFound
30+
)
31+
let transformer: DataErrorConverter = { data in
32+
return try JSONDecoder().decode(CustomError.self, from: data)
33+
}
34+
35+
Just(output)
36+
.validate(transformer)
37+
.sink(
38+
receiveCompletion: {
39+
guard case let .failure(error) = $0 else {
40+
return XCTFail()
41+
}
42+
43+
XCTAssertEqual(error as? CustomError, customError)
44+
},
45+
receiveValue: { _ in })
46+
.store(in: &cancellables)
47+
}
48+
}
49+
50+
private struct CustomError: Error, Equatable, Codable {
51+
let code: Int
52+
let message: String
53+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import XCTest
2+
import Pinata
3+
4+
#if canImport(FoundationNetworking)
5+
import FoundationNetworking
6+
#endif
7+
8+
class URLRequestEncodeTests: XCTest {
9+
10+
func test_encodedBody_itSetContentTypeHeader() throws {
11+
let body: [String:String] = [:]
12+
let request = try URLRequest(url: URL(string: "/")!)
13+
.encodedBody(body, encoder: JSONEncoder())
14+
15+
XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json")
16+
}
17+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import XCTest
2+
import Pinata
3+
4+
#if canImport(FoundationNetworking)
5+
import FoundationNetworking
6+
#endif
7+
8+
class URLResponseValidateTests: XCTest {
9+
let url = URL(string: "/")!
10+
11+
func test_validate_statusCodeIsOK_itThrowNoError() throws {
12+
try HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!.validate()
13+
}
14+
15+
// we should never have redirection that's why we consider it as an error
16+
func test_validate_statusCodeIsRedirection_itThrow() {
17+
XCTAssertThrowsError(
18+
try HTTPURLResponse(url: url, statusCode: 302, httpVersion: nil, headerFields: nil)!.validate()
19+
)
20+
}
21+
22+
func test_validate_statusCodeIsClientError_itThrow() {
23+
XCTAssertThrowsError(
24+
try HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!.validate()
25+
)
26+
}
27+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Foundation
2+
3+
#if canImport(FoundationNetworking)
4+
import FoundationNetworking
5+
#endif
6+
7+
extension HTTPURLResponse {
8+
convenience init(statusCode: Int) {
9+
self.init(url: URL(string: "/")!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
10+
}
11+
}
12+
13+
extension URLResponse {
14+
static let success = HTTPURLResponse(statusCode: 200)
15+
static let unauthorized = HTTPURLResponse(statusCode: 401)
16+
static let notFound = HTTPURLResponse(statusCode: 404)
17+
}

0 commit comments

Comments
 (0)