Skip to content

Commit c3bc641

Browse files
authored
Merge pull request #7 from dkoster95/feature/QHRequest-response
Implemented QHHTTPRequest
2 parents 9205a8b + 5be9ce7 commit c3bc641

File tree

8 files changed

+251
-18
lines changed

8 files changed

+251
-18
lines changed

Sources/Request/HTTPRequest.swift

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,25 +21,59 @@ public protocol HTTPRequest {
2121
}
2222

2323
public protocol HTTPRequestActionable {
24+
func response() async throws -> HTTPResponse
25+
var responsePublisher: any Publisher<HTTPResponse, Error> { get }
26+
}
27+
28+
public protocol HTTPRequestDecodedActionable {
2429
associatedtype ResponseType: Codable
25-
func response(queue: DispatchQueue) async -> Result<ResponseType, RequestError>
26-
var responsePublisher: any Publisher<Result<ResponseType, RequestError>, Never> { get }
30+
func responseDecoded() async throws -> Response<ResponseType>
31+
var responseDecodedPublisher: any Publisher<ResponseType, Error> { get }
2732
}
2833

29-
public struct QHHTTPRequest: HTTPRequest, URLRequestProtocol {
34+
public struct QHHTTPRequest<T: Codable>: HTTPRequest, URLRequestProtocol, HTTPRequestActionable, HTTPRequestDecodedActionable {
35+
public typealias ResponseType = T
36+
3037
public let headers: [String : String]
3138
public let body: Data?
3239
public let url: String
3340
public let method: HTTPMethod
41+
private let requestFactory: NetworkRequestFactory
42+
private let jsonDecoder: JSONDecoder
3443

3544
public init(headers: [String : String] = [:],
3645
body: Data? = nil,
3746
url: String,
38-
method: HTTPMethod) {
47+
method: HTTPMethod,
48+
requestFactory: NetworkRequestFactory = URLSessionRequestFactory(urlSession: URLSession.shared),
49+
jsonDecoder: JSONDecoder = JSONDecoder()) {
3950
self.headers = headers
4051
self.body = body
4152
self.url = url
4253
self.method = method
54+
self.requestFactory = requestFactory
55+
self.jsonDecoder = jsonDecoder
56+
}
57+
58+
public func response() async throws -> any HTTPResponse {
59+
let urlRequest = try asURLRequest()
60+
return try await requestFactory.data(request: urlRequest)
61+
}
62+
63+
public func responseDecoded() async throws -> Response<T> {
64+
let urlRequest = try asURLRequest()
65+
return try await requestFactory.response(request: urlRequest, jsonDecoder: jsonDecoder)
66+
}
67+
68+
public var responseDecodedPublisher: any Publisher<T, Error> {
69+
guard let urlRequest = try? asURLRequest() else { return Fail(error: RequestError.malformedRequest) }
70+
return requestFactory.response(urlRequest: urlRequest,
71+
jsonDecoder: jsonDecoder)
72+
.map { $0.data }
73+
}
74+
public var responsePublisher: any Publisher<any HTTPResponse, Error> {
75+
guard let urlRequest = try? asURLRequest() else { return Fail(error: RequestError.malformedRequest) }
76+
return requestFactory.dataPublisher(request: urlRequest)
4377
}
4478

4579
public func asURLRequest() throws -> URLRequest {

Tests/Mocks/CertificatePinning/Mocks.swift

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77
import QuickHatchHTTP
88
import Foundation
9+
import Combine
910

1011
public class PinningStrategyMock: PinningStrategy {
1112

@@ -55,7 +56,7 @@ public final class MockAuthenticationChallengeSender: NSObject, URLAuthenticatio
5556

5657
}
5758

58-
public final class MockURLProtectionSpace: URLProtectionSpace {
59+
public final class MockURLProtectionSpace: URLProtectionSpace, @unchecked Sendable {
5960
private let trust: SecTrust?
6061
public init(serverTrust: SecTrust?,host: String, port: Int, authenticationMethod: String? = nil) {
6162
self.trust = serverTrust
@@ -70,3 +71,58 @@ public final class MockURLProtectionSpace: URLProtectionSpace {
7071
return trust
7172
}
7273
}
74+
75+
public final class NetworkRequestFactoryMock: NetworkRequestFactory {
76+
77+
public var invokedDataRequest = false
78+
public var invokedDataRequestCount = 0
79+
public var invokedDataRequestParameters: (request: URLRequest, dispatchQueue: DispatchQueue)?
80+
public var invokedDataRequestParametersList = [(request: URLRequest, dispatchQueue: DispatchQueue)]()
81+
public var stubbedDataRequestCompletionResult: (Result<HTTPResponse, Error>, Void)?
82+
public var stubbedDataRequestResult: DataTask!
83+
84+
public func data(request: URLRequest,
85+
dispatchQueue: DispatchQueue,
86+
completionHandler completion: @Sendable @escaping (Result<HTTPResponse, Error>) -> Void) -> DataTask {
87+
invokedDataRequest = true
88+
invokedDataRequestCount += 1
89+
invokedDataRequestParameters = (request, dispatchQueue)
90+
invokedDataRequestParametersList.append((request, dispatchQueue))
91+
if let result = stubbedDataRequestCompletionResult {
92+
completion(result.0)
93+
}
94+
return stubbedDataRequestResult
95+
}
96+
97+
public var invokedDataPublisher = false
98+
public var invokedDataPublisherCount = 0
99+
public var invokedDataPublisherParameters: (request: URLRequest, Void)?
100+
public var invokedDataPublisherParametersList = [(request: URLRequest, Void)]()
101+
public var subject = PassthroughSubject<HTTPResponse, Error>()
102+
103+
public func dataPublisher(request: URLRequest) -> AnyPublisher<HTTPResponse,Error> {
104+
invokedDataPublisher = true
105+
invokedDataPublisherCount += 1
106+
invokedDataPublisherParameters = (request, ())
107+
invokedDataPublisherParametersList.append((request, ()))
108+
return subject.eraseToAnyPublisher()
109+
}
110+
111+
public var invokedAsyncData = false
112+
public var invokedAsyncDataCount = 0
113+
public var invokedAsyncDataParameters: (request: URLRequest, Void)?
114+
public var invokedAsyncDataParametersList = [(request: URLRequest, Void)]()
115+
public var asyncDataResponseResult: HTTPResponse!
116+
public var asyncDataErrorThrown: Error?
117+
118+
public func data(request: URLRequest) async throws -> HTTPResponse {
119+
invokedAsyncData = true
120+
invokedAsyncDataCount += 1
121+
invokedAsyncDataParameters = (request, ())
122+
invokedAsyncDataParametersList.append((request, ()))
123+
if let asyncDataErrorThrown = asyncDataErrorThrown {
124+
throw asyncDataErrorThrown
125+
}
126+
return asyncDataResponseResult
127+
}
128+
}

Tests/Mocks/URLSessionProtocol/ResponsesStubs.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ public struct URLSessionMocks {
2424
return Data()
2525
}
2626

27-
public static func anyURLSessionLayer(urlSession: URLSessionProtocol) -> URLSessionRequestFactory {
28-
let urlSessionLayer = URLSessionRequestFactory(urlSession: urlSession)
29-
return urlSessionLayer
27+
public static func anyResponse(withData: Data? = nil, withStatusCode: Int = 200) -> HTTPResponse {
28+
return QHHTTPResponse(body: withData, urlResponse: anyResponse(statusCode: withStatusCode))
3029
}
30+
3131
}

Tests/Mocks/URLSessionProtocol/URLSessionDataTaskMock.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import Foundation
1010

11-
class URLSessionDataTaskMock: URLSessionDataTask {
11+
class URLSessionDataTaskMock: URLSessionDataTask, @unchecked Sendable {
1212

1313
private let closure: () -> Void
1414

@@ -21,7 +21,7 @@ class URLSessionDataTaskMock: URLSessionDataTask {
2121
}
2222
}
2323

24-
class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock {
24+
class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock, @unchecked Sendable {
2525
private let delay: Double
2626

2727
init(delay: Double, closure: @escaping () -> Void) {
@@ -40,7 +40,7 @@ class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock {
4040
}
4141
}
4242

43-
class FakeURLSessionDataTask: URLSessionDataTask {
43+
class FakeURLSessionDataTask: URLSessionDataTask, @unchecked Sendable {
4444
public var resumed = false
4545
override func resume() {
4646
resumed = true

Tests/Mocks/URLSessionProtocol/URLSessionDownloadMock.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import Foundation
1010

11-
class URLSessionDownloadMock: URLSession {
11+
class URLSessionDownloadMock: URLSession, @unchecked Sendable {
1212
private let downloadingURL: URL?
1313
private let error: Error?
1414
private let urlResponse: URLResponse?

Tests/Mocks/URLSessionProtocol/URLSessionDownloadTaskMock.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import Foundation
1010

11-
class URLSessionDownloadTaskMock: URLSessionDownloadTask {
11+
class URLSessionDownloadTaskMock: URLSessionDownloadTask, @unchecked Sendable {
1212
private let closure: () -> Void
1313

1414
init(closure: @escaping () -> Void) {

Tests/Mocks/URLSessionProtocol/URLSessionMockWithDelay.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import Foundation
1010
import QuickHatchHTTP
1111

12-
class URLSessionMockWithDelay: URLSessionProtocolMock {
12+
class URLSessionMockWithDelay: URLSessionProtocolMock, @unchecked Sendable {
1313
private var delay: Double
1414

1515
init(data: Data? = nil, error: Error? = nil, urlResponse: URLResponse? = nil, delay: Double) {

Tests/TestCases/Request/HTTPRequestTests.swift

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@
55
// Created by Daniel Koster on 10/13/25.
66
//
77
import QuickHatchHTTP
8+
@testable import QuickHatchHTTPMocks
89
import Foundation
910
import Testing
11+
import Combine
1012

11-
struct HTTPRequestTests {
13+
final class HTTPRequestTests {
14+
private var cancellables = Set<AnyCancellable>()
1215

1316
@Test
1417
func asURLRequest_expectHeadersCorrectlyParsed() throws {
15-
let sut = QHHTTPRequest(headers: ["Content-Type": "json"],
16-
url: "quickhatch.com",
17-
method: .get)
18+
let sut = QHHTTPRequest<DataModel>(headers: ["Content-Type": "json"],
19+
url: "quickhatch.com",
20+
method: .get)
1821

1922
let result = try sut.asURLRequest()
2023

@@ -26,4 +29,144 @@ struct HTTPRequestTests {
2629
#expect(headers == ["Content-Type": "json"])
2730
#expect(method == "GET")
2831
}
32+
33+
@Test
34+
func response_whenNoErrorSpecified_expectCorrectResponse() async throws {
35+
let requestFactoryMock = NetworkRequestFactoryMock()
36+
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
37+
requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse()
38+
39+
do {
40+
_ = try await sut.response()
41+
#expect(requestFactoryMock.invokedAsyncDataCount == 1)
42+
}
43+
catch _ {
44+
#expect(Bool(false))
45+
}
46+
}
47+
48+
@Test func response_whenErrorThrown_expectCatchToWork() async throws {
49+
let requestFactoryMock = NetworkRequestFactoryMock()
50+
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
51+
requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse()
52+
requestFactoryMock.asyncDataErrorThrown = RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest)
53+
54+
do {
55+
_ = try await sut.response()
56+
}
57+
catch let error as RequestError {
58+
#expect(error == .requestWithError(statusCode: .badRequest))
59+
#expect(requestFactoryMock.invokedAsyncDataCount == 1)
60+
}
61+
}
62+
63+
@Test
64+
func responseDecoded_whenNoErrorSpecified_expectCorrectResponse() async throws {
65+
let requestFactoryMock = NetworkRequestFactoryMock()
66+
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
67+
requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample)
68+
69+
do {
70+
let response = try await sut.responseDecoded()
71+
let dataExpected = DataModel(name: "dan", nick: "sp", age: 12)
72+
#expect(requestFactoryMock.invokedAsyncDataCount == 1)
73+
#expect(dataExpected.name == response.data.name)
74+
}
75+
catch _ {
76+
#expect(Bool(false))
77+
}
78+
}
79+
80+
@Test func responseDecoded_whenErrorThrown_expectCatchToWork() async throws {
81+
let requestFactoryMock = NetworkRequestFactoryMock()
82+
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
83+
requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse()
84+
requestFactoryMock.asyncDataErrorThrown = RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest)
85+
86+
do {
87+
_ = try await sut.responseDecoded()
88+
}
89+
catch let error as RequestError {
90+
#expect(error == .requestWithError(statusCode: .badRequest))
91+
#expect(requestFactoryMock.invokedAsyncDataCount == 1)
92+
}
93+
}
94+
95+
@Test func responseDecodedPublisher_whenErrorThrown_expectCatchToWork() async throws {
96+
let requestFactoryMock = NetworkRequestFactoryMock()
97+
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
98+
requestFactoryMock.subject.send(completion: Subscribers.Completion.failure(RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest)))
99+
100+
await confirmation("") { confirmation in
101+
sut.responseDecodedPublisher.sink(receiveCompletion: { completion in
102+
switch completion {
103+
case .failure(let error as RequestError):
104+
#expect(error == .requestWithError(statusCode: HTTPStatusCode.badRequest))
105+
confirmation.confirm()
106+
case .failure(_): break
107+
case .finished: break
108+
}
109+
},
110+
receiveValue: { dataModel in }).store(in: &cancellables)
111+
}
112+
}
113+
114+
@Test func responseDecodedPublisher_whenNoErrorThrown_expectCorrectResponse() async throws {
115+
let requestFactoryMock = NetworkRequestFactoryMock()
116+
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
117+
let response = DataModel(name: "dan", nick: "sp", age: 12)
118+
119+
await confirmation("") { confirmation in
120+
sut.responseDecodedPublisher.sink(receiveCompletion: { completion in
121+
switch completion {
122+
case .failure(_): break
123+
case .finished: break
124+
}
125+
},
126+
receiveValue: { dataModel in
127+
#expect(dataModel.name == response.name)
128+
confirmation.confirm()
129+
}).store(in: &cancellables)
130+
requestFactoryMock.subject.send(URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample))
131+
}
132+
}
133+
134+
@Test func responsePublisher_whenErrorThrown_expectCatchToWork() async throws {
135+
let requestFactoryMock = NetworkRequestFactoryMock()
136+
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
137+
requestFactoryMock.subject.send(completion: Subscribers.Completion.failure(RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest)))
138+
139+
await confirmation("") { confirmation in
140+
sut.responsePublisher.sink(receiveCompletion: { completion in
141+
switch completion {
142+
case .failure(let error as RequestError):
143+
#expect(error == .requestWithError(statusCode: HTTPStatusCode.badRequest))
144+
confirmation.confirm()
145+
case .failure(_): break
146+
case .finished: break
147+
}
148+
},
149+
receiveValue: { _ in }).store(in: &cancellables)
150+
}
151+
}
152+
153+
@Test func responsePublisher_whenNoErrorThrown_expectCorrectResponse() async throws {
154+
let requestFactoryMock = NetworkRequestFactoryMock()
155+
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
156+
157+
await confirmation("") { confirmation in
158+
sut.responsePublisher.sink(receiveCompletion: { completion in
159+
switch completion {
160+
case .failure(_): break
161+
case .finished: break
162+
}
163+
},
164+
receiveValue: { response in
165+
#expect(response.body != nil)
166+
confirmation.confirm()
167+
}).store(in: &cancellables)
168+
requestFactoryMock.subject.send(URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample))
169+
}
170+
}
171+
29172
}

0 commit comments

Comments
 (0)