Skip to content

Commit 9d341f7

Browse files
authored
Add make request, validate request functions and tests
Adds function to make a request with status code validation. By default the request will fail if the status code is not between 200 - 299 A request can be made with a validator to override that range: { statusCode == 202 }
2 parents 60a8902 + efdfe1a commit 9d341f7

File tree

5 files changed

+240
-1
lines changed

5 files changed

+240
-1
lines changed

Sources/HTTPEngine/HTTPEngine.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import Foundation
2+
import Combine
23

34
public typealias Header = [String: String]
5+
public typealias ValidResponse = Bool
6+
public typealias ResponseValidationClosure = (Int) -> ValidResponse
47

58
public struct HTTPEngine {
69
public init() {}
@@ -30,4 +33,41 @@ public struct HTTPEngine {
3033
return request
3134
}
3235

36+
public func makeRequest(
37+
method: HTTPMethod,
38+
url urlString: String,
39+
body: Data? = nil,
40+
header: Header? = nil,
41+
validator: ResponseValidationClosure? = nil
42+
) -> AnyPublisher<Data, Error> {
43+
44+
guard let url = URL(string: urlString) else {
45+
return ThrowingPublisher(forType: Data.self, throws: Errors.Request.invalidURL)
46+
}
47+
48+
return buildRequest(method: method, url: url, body: body, header: header)
49+
.dataTaskPublisher()
50+
.eraseToAnyPublisher()
51+
.tryMap { value -> Data in
52+
try self.validateResponse(value.response, validator: validator)
53+
return value.data
54+
}
55+
.eraseToAnyPublisher()
56+
}
57+
58+
func validateResponse(_ response: URLResponse?, validator: ResponseValidationClosure? = nil) throws {
59+
let response = try response as? HTTPURLResponse ??? Errors.Response.couldNotRetrieveStatusCode
60+
61+
guard let validator = validator else {
62+
if let error = Errors.Response.errorWith(statusCode: response.statusCode) {
63+
throw error
64+
}
65+
return
66+
}
67+
68+
guard validator(response.statusCode) else {
69+
throw Errors.Response.unexpectedStatusCode(response)
70+
}
71+
}
72+
3373
}

Sources/HTTPEngine/Utilities.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
import Combine
3+
4+
public extension URLRequest {
5+
func dataTaskPublisher() -> URLSession.DataTaskPublisher {
6+
return URLSession.shared.dataTaskPublisher(for: self)
7+
}
8+
}
9+
10+
public func ThrowingPublisher<T>(forType type: T.Type, throws error: Error) -> AnyPublisher<T, Error> {
11+
Result<T?, Error> { nil }
12+
.publisher
13+
.eraseToAnyPublisher()
14+
.tryMap {
15+
guard let value = $0 else {
16+
throw error
17+
}
18+
return value
19+
}.eraseToAnyPublisher()
20+
}
21+
22+
infix operator ??? : TernaryPrecedence
23+
public func ???<T>(_ left: Optional<T>, right: Error) throws -> T {
24+
guard let value = left else { throw right }
25+
return value
26+
}

Tests/HTTPEngineTests/HTTPEngineTests.swift

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import XCTest
22
import Foundation
3+
import OHHTTPStubs
4+
import OHHTTPStubsSwift
35
@testable import HTTPEngine
46

57
final class HTTPEngineTests: XCTestCase {
68

9+
// MARK: - Build Request
10+
711
func testBuildRequestContainsAcceptEncodingByDefault() {
812
HTTPMethod.allCases.forEach {
913
let request = HTTPEngine().buildRequest(method: $0, url: URL(string: "www.google.com")!)
@@ -59,6 +63,64 @@ final class HTTPEngineTests: XCTestCase {
5963
let request = HTTPEngine().buildRequest(method: .get, url: URL(string: "www.google.com")!)
6064
XCTAssertEqual(request.httpMethod, "GET")
6165
}
66+
67+
// MARK: - Make Request
68+
69+
func testMakeRequestFailsWithUnexpectedResponseCode() {
70+
stub(condition: isHost("google.com") && isMethodGET()) { _ in
71+
HTTPStubsResponse(jsonObject: [:], statusCode: 200, headers: nil)
72+
}
73+
74+
HTTPEngine()
75+
.makeRequest(method: .get, url: "https://google.com", validator: { $0 == 202 })
76+
.assertError(test: self) {
77+
switch $0 {
78+
case Errors.Response.unexpectedStatusCode(let response):
79+
XCTAssertEqual(response.statusCode, 200)
80+
default: XCTFail(#function)
81+
}
82+
}
83+
}
84+
85+
func testMakeRequestSucceedsIn200RangeByDefault() {
86+
for statusCode in (200...299) {
87+
stub(condition: isHost("google.com") && isMethodGET()) { _ in
88+
HTTPStubsResponse(jsonObject: ["key":"value"], statusCode: Int32(statusCode), headers: nil)
89+
}
90+
91+
HTTPEngine()
92+
.makeRequest(method: .get, url: "https://google.com")
93+
.assertResult(test: self) {
94+
XCTAssertEqual(
95+
["key": "value"],
96+
try? JSONSerialization.jsonObject(with: $0, options: []) as? [String: String]
97+
)
98+
}
99+
}
100+
}
101+
102+
103+
func testMakeRequestFailsOutsideOf200RangeByDefault() {
104+
stub(condition: isHost("google.com") && isMethodGET()) { _ in
105+
HTTPStubsResponse(jsonObject: [:], statusCode: 300, headers: nil)
106+
}
107+
108+
HTTPEngine()
109+
.makeRequest(method: .get, url: "https://google.com")
110+
.assertError(test: self) {
111+
XCTAssertEqual($0.localizedDescription, Errors.Response.redirect(300).localizedDescription)
112+
}
113+
114+
stub(condition: isHost("google.com") && isMethodGET()) { _ in
115+
HTTPStubsResponse(jsonObject: [:], statusCode: 199, headers: nil)
116+
}
117+
118+
HTTPEngine()
119+
.makeRequest(method: .get, url: "https://google.com")
120+
.assertError(test: self) {
121+
XCTAssertEqual($0.localizedDescription, Errors.Response.errorWith(statusCode: 199)?.localizedDescription)
122+
}
123+
}
62124

63125

64126
static var allTests = [
@@ -68,6 +130,10 @@ final class HTTPEngineTests: XCTestCase {
68130
("Non Put Patch Post do not contain content type header", testNonPostPatchPutMethodsDoNotContainDefaultContentTypeHeader),
69131
("Build request overrides content type header", testBuildRequestOverridesContentTypeHeader),
70132
("Build Request applies body to request", testBuildRequestAppliesBodyToRequest),
71-
("Build Request applies Method to request", testBuildRequestAppliesMethodToRequest)
133+
("Build Request applies Method to request", testBuildRequestAppliesMethodToRequest),
134+
("Make Request Fails with invalid status code",
135+
testMakeRequestFailsWithUnexpectedResponseCode),
136+
("Make Request Succeeds in 200 range by default", testMakeRequestSucceedsIn200RangeByDefault),
137+
("Make request fails outside of 200 range by default", testMakeRequestFailsOutsideOf200RangeByDefault)
72138
]
73139
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import XCTest
2+
import Combine
3+
4+
private var cancelables: [AnyCancellable] = []
5+
6+
extension AnyPublisher {
7+
func noFailureOnMain() -> AnyPublisher<Output, Never> {
8+
assertNoFailure()
9+
.receive(on: DispatchQueue.main)
10+
.eraseToAnyPublisher()
11+
}
12+
13+
@discardableResult
14+
func assertResult(
15+
_ function: String = #function,
16+
test: XCTestCase,
17+
_ assertions: @escaping (Output) -> Void = { _ in }
18+
) -> AnyPublisher<Output, Failure> {
19+
20+
let expectation = test.expectation(description: function)
21+
22+
self
23+
.noFailureOnMain()
24+
.sink { value in
25+
assertions(value)
26+
expectation.fulfill()
27+
}
28+
.store(in: &cancelables)
29+
30+
test.wait(for: [expectation], timeout: 1)
31+
return self
32+
}
33+
34+
@discardableResult
35+
func assertNoError(
36+
_ function: String = #function,
37+
test: XCTestCase
38+
) -> AnyPublisher<Output, Failure> {
39+
assertResult(function, test: test) { _ in }
40+
}
41+
42+
@discardableResult
43+
func assertError(
44+
_ function: String = #function,
45+
test: XCTestCase,
46+
_ assertions: @escaping (Failure) -> Void
47+
) -> AnyPublisher<Output, Failure> {
48+
49+
let expectation = test.expectation(description: function)
50+
51+
self
52+
.receive(on: DispatchQueue.main)
53+
.tryCatch { error -> Empty<Output, Error> in
54+
assertions(error)
55+
expectation.fulfill()
56+
return Empty(completeImmediately: true)
57+
}
58+
.assertNoFailure()
59+
.sink { _ in XCTFail(function) }
60+
.store(in: &cancelables)
61+
62+
test.wait(for: [expectation], timeout: 1)
63+
return self
64+
}
65+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import XCTest
2+
import Foundation
3+
@testable import HTTPEngine
4+
5+
class UtitlityTests: XCTestCase {
6+
func testTripQuestionsThrowsIfNil() {
7+
let x: Int? = nil
8+
do {
9+
_ = try x ??? Errors.Request.invalidURL
10+
XCTFail(#function)
11+
}
12+
catch let error{
13+
XCTAssertEqual(error.localizedDescription, Errors.Request.invalidURL.localizedDescription)
14+
}
15+
}
16+
17+
func testTripQuestionsUnwraps() {
18+
let x: Int? = 1
19+
do {
20+
XCTAssertEqual(try x ??? Errors.Request.invalidURL, 1)
21+
}
22+
catch {
23+
XCTFail(#function)
24+
}
25+
}
26+
27+
func testThrowingPublisherThrows() {
28+
ThrowingPublisher(forType: Int.self, throws: Errors.Request.invalidURL)
29+
.assertError(test: self) {
30+
XCTAssertEqual($0.localizedDescription, Errors.Request.invalidURL.localizedDescription)
31+
}
32+
}
33+
34+
35+
static var allTests = [
36+
("??? operator throws when unwrapping a nil", testTripQuestionsThrowsIfNil),
37+
("??? unwraps values", testTripQuestionsUnwraps),
38+
("Test throwing publisher throws", testThrowingPublisherThrows)
39+
40+
]
41+
42+
}

0 commit comments

Comments
 (0)