Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions Sources/Request/HTTPRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,59 @@ public protocol HTTPRequest {
}

public protocol HTTPRequestActionable {
func response() async throws -> HTTPResponse
var responsePublisher: any Publisher<HTTPResponse, Error> { get }
}

public protocol HTTPRequestDecodedActionable {
associatedtype ResponseType: Codable
func response(queue: DispatchQueue) async -> Result<ResponseType, RequestError>
var responsePublisher: any Publisher<Result<ResponseType, RequestError>, Never> { get }
func responseDecoded() async throws -> Response<ResponseType>
var responseDecodedPublisher: any Publisher<ResponseType, Error> { get }
}

public struct QHHTTPRequest: HTTPRequest, URLRequestProtocol {
public struct QHHTTPRequest<T: Codable>: HTTPRequest, URLRequestProtocol, HTTPRequestActionable, HTTPRequestDecodedActionable {
public typealias ResponseType = T

public let headers: [String : String]
public let body: Data?
public let url: String
public let method: HTTPMethod
private let requestFactory: NetworkRequestFactory
private let jsonDecoder: JSONDecoder

public init(headers: [String : String] = [:],
body: Data? = nil,
url: String,
method: HTTPMethod) {
method: HTTPMethod,
requestFactory: NetworkRequestFactory = URLSessionRequestFactory(urlSession: URLSession.shared),
jsonDecoder: JSONDecoder = JSONDecoder()) {
self.headers = headers
self.body = body
self.url = url
self.method = method
self.requestFactory = requestFactory
self.jsonDecoder = jsonDecoder
}

public func response() async throws -> any HTTPResponse {
let urlRequest = try asURLRequest()
return try await requestFactory.data(request: urlRequest)
}

public func responseDecoded() async throws -> Response<T> {
let urlRequest = try asURLRequest()
return try await requestFactory.response(request: urlRequest, jsonDecoder: jsonDecoder)
}

public var responseDecodedPublisher: any Publisher<T, Error> {
guard let urlRequest = try? asURLRequest() else { return Fail(error: RequestError.malformedRequest) }
return requestFactory.response(urlRequest: urlRequest,
jsonDecoder: jsonDecoder)
.map { $0.data }
}
public var responsePublisher: any Publisher<any HTTPResponse, Error> {
guard let urlRequest = try? asURLRequest() else { return Fail(error: RequestError.malformedRequest) }
return requestFactory.dataPublisher(request: urlRequest)
}

public func asURLRequest() throws -> URLRequest {
Expand Down
58 changes: 57 additions & 1 deletion Tests/Mocks/CertificatePinning/Mocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//
import QuickHatchHTTP
import Foundation
import Combine

public class PinningStrategyMock: PinningStrategy {

Expand Down Expand Up @@ -55,7 +56,7 @@ public final class MockAuthenticationChallengeSender: NSObject, URLAuthenticatio

}

public final class MockURLProtectionSpace: URLProtectionSpace {
public final class MockURLProtectionSpace: URLProtectionSpace, @unchecked Sendable {
private let trust: SecTrust?
public init(serverTrust: SecTrust?,host: String, port: Int, authenticationMethod: String? = nil) {
self.trust = serverTrust
Expand All @@ -70,3 +71,58 @@ public final class MockURLProtectionSpace: URLProtectionSpace {
return trust
}
}

public final class NetworkRequestFactoryMock: NetworkRequestFactory {

public var invokedDataRequest = false
public var invokedDataRequestCount = 0
public var invokedDataRequestParameters: (request: URLRequest, dispatchQueue: DispatchQueue)?
public var invokedDataRequestParametersList = [(request: URLRequest, dispatchQueue: DispatchQueue)]()
public var stubbedDataRequestCompletionResult: (Result<HTTPResponse, Error>, Void)?
public var stubbedDataRequestResult: DataTask!

public func data(request: URLRequest,
dispatchQueue: DispatchQueue,
completionHandler completion: @Sendable @escaping (Result<HTTPResponse, Error>) -> Void) -> DataTask {
invokedDataRequest = true
invokedDataRequestCount += 1
invokedDataRequestParameters = (request, dispatchQueue)
invokedDataRequestParametersList.append((request, dispatchQueue))
if let result = stubbedDataRequestCompletionResult {
completion(result.0)
}
return stubbedDataRequestResult
}

public var invokedDataPublisher = false
public var invokedDataPublisherCount = 0
public var invokedDataPublisherParameters: (request: URLRequest, Void)?
public var invokedDataPublisherParametersList = [(request: URLRequest, Void)]()
public var subject = PassthroughSubject<HTTPResponse, Error>()

public func dataPublisher(request: URLRequest) -> AnyPublisher<HTTPResponse,Error> {
invokedDataPublisher = true
invokedDataPublisherCount += 1
invokedDataPublisherParameters = (request, ())
invokedDataPublisherParametersList.append((request, ()))
return subject.eraseToAnyPublisher()
}

public var invokedAsyncData = false
public var invokedAsyncDataCount = 0
public var invokedAsyncDataParameters: (request: URLRequest, Void)?
public var invokedAsyncDataParametersList = [(request: URLRequest, Void)]()
public var asyncDataResponseResult: HTTPResponse!
public var asyncDataErrorThrown: Error?

public func data(request: URLRequest) async throws -> HTTPResponse {
invokedAsyncData = true
invokedAsyncDataCount += 1
invokedAsyncDataParameters = (request, ())
invokedAsyncDataParametersList.append((request, ()))
if let asyncDataErrorThrown = asyncDataErrorThrown {
throw asyncDataErrorThrown
}
return asyncDataResponseResult
}
}
6 changes: 3 additions & 3 deletions Tests/Mocks/URLSessionProtocol/ResponsesStubs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ public struct URLSessionMocks {
return Data()
}

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

}
6 changes: 3 additions & 3 deletions Tests/Mocks/URLSessionProtocol/URLSessionDataTaskMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import Foundation

class URLSessionDataTaskMock: URLSessionDataTask {
class URLSessionDataTaskMock: URLSessionDataTask, @unchecked Sendable {

private let closure: () -> Void

Expand All @@ -21,7 +21,7 @@ class URLSessionDataTaskMock: URLSessionDataTask {
}
}

class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock {
class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock, @unchecked Sendable {
private let delay: Double

init(delay: Double, closure: @escaping () -> Void) {
Expand All @@ -40,7 +40,7 @@ class URLSessionDataTaskMockWithDelay: URLSessionDataTaskMock {
}
}

class FakeURLSessionDataTask: URLSessionDataTask {
class FakeURLSessionDataTask: URLSessionDataTask, @unchecked Sendable {
public var resumed = false
override func resume() {
resumed = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import Foundation

class URLSessionDownloadMock: URLSession {
class URLSessionDownloadMock: URLSession, @unchecked Sendable {
private let downloadingURL: URL?
private let error: Error?
private let urlResponse: URLResponse?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import Foundation

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

init(closure: @escaping () -> Void) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import Foundation
import QuickHatchHTTP

class URLSessionMockWithDelay: URLSessionProtocolMock {
class URLSessionMockWithDelay: URLSessionProtocolMock, @unchecked Sendable {
private var delay: Double

init(data: Data? = nil, error: Error? = nil, urlResponse: URLResponse? = nil, delay: Double) {
Expand Down
151 changes: 147 additions & 4 deletions Tests/TestCases/Request/HTTPRequestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@
// Created by Daniel Koster on 10/13/25.
//
import QuickHatchHTTP
@testable import QuickHatchHTTPMocks
import Foundation
import Testing
import Combine

struct HTTPRequestTests {
final class HTTPRequestTests {
private var cancellables = Set<AnyCancellable>()

@Test
func asURLRequest_expectHeadersCorrectlyParsed() throws {
let sut = QHHTTPRequest(headers: ["Content-Type": "json"],
url: "quickhatch.com",
method: .get)
let sut = QHHTTPRequest<DataModel>(headers: ["Content-Type": "json"],
url: "quickhatch.com",
method: .get)

let result = try sut.asURLRequest()

Expand All @@ -26,4 +29,144 @@ struct HTTPRequestTests {
#expect(headers == ["Content-Type": "json"])
#expect(method == "GET")
}

@Test
func response_whenNoErrorSpecified_expectCorrectResponse() async throws {
let requestFactoryMock = NetworkRequestFactoryMock()
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse()

do {
_ = try await sut.response()
#expect(requestFactoryMock.invokedAsyncDataCount == 1)
}
catch _ {
#expect(Bool(false))
}
}

@Test func response_whenErrorThrown_expectCatchToWork() async throws {
let requestFactoryMock = NetworkRequestFactoryMock()
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse()
requestFactoryMock.asyncDataErrorThrown = RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest)

do {
_ = try await sut.response()
}
catch let error as RequestError {
#expect(error == .requestWithError(statusCode: .badRequest))
#expect(requestFactoryMock.invokedAsyncDataCount == 1)
}
}

@Test
func responseDecoded_whenNoErrorSpecified_expectCorrectResponse() async throws {
let requestFactoryMock = NetworkRequestFactoryMock()
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample)

do {
let response = try await sut.responseDecoded()
let dataExpected = DataModel(name: "dan", nick: "sp", age: 12)
#expect(requestFactoryMock.invokedAsyncDataCount == 1)
#expect(dataExpected.name == response.data.name)
}
catch _ {
#expect(Bool(false))
}
}

@Test func responseDecoded_whenErrorThrown_expectCatchToWork() async throws {
let requestFactoryMock = NetworkRequestFactoryMock()
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
requestFactoryMock.asyncDataResponseResult = URLSessionMocks.anyResponse()
requestFactoryMock.asyncDataErrorThrown = RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest)

do {
_ = try await sut.responseDecoded()
}
catch let error as RequestError {
#expect(error == .requestWithError(statusCode: .badRequest))
#expect(requestFactoryMock.invokedAsyncDataCount == 1)
}
}

@Test func responseDecodedPublisher_whenErrorThrown_expectCatchToWork() async throws {
let requestFactoryMock = NetworkRequestFactoryMock()
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
requestFactoryMock.subject.send(completion: Subscribers.Completion.failure(RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest)))

await confirmation("") { confirmation in
sut.responseDecodedPublisher.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error as RequestError):
#expect(error == .requestWithError(statusCode: HTTPStatusCode.badRequest))
confirmation.confirm()
case .failure(_): break
case .finished: break
}
},
receiveValue: { dataModel in }).store(in: &cancellables)
}
}

@Test func responseDecodedPublisher_whenNoErrorThrown_expectCorrectResponse() async throws {
let requestFactoryMock = NetworkRequestFactoryMock()
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
let response = DataModel(name: "dan", nick: "sp", age: 12)

await confirmation("") { confirmation in
sut.responseDecodedPublisher.sink(receiveCompletion: { completion in
switch completion {
case .failure(_): break
case .finished: break
}
},
receiveValue: { dataModel in
#expect(dataModel.name == response.name)
confirmation.confirm()
}).store(in: &cancellables)
requestFactoryMock.subject.send(URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample))
}
}

@Test func responsePublisher_whenErrorThrown_expectCatchToWork() async throws {
let requestFactoryMock = NetworkRequestFactoryMock()
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)
requestFactoryMock.subject.send(completion: Subscribers.Completion.failure(RequestError.requestWithError(statusCode: HTTPStatusCode.badRequest)))

await confirmation("") { confirmation in
sut.responsePublisher.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error as RequestError):
#expect(error == .requestWithError(statusCode: HTTPStatusCode.badRequest))
confirmation.confirm()
case .failure(_): break
case .finished: break
}
},
receiveValue: { _ in }).store(in: &cancellables)
}
}

@Test func responsePublisher_whenNoErrorThrown_expectCorrectResponse() async throws {
let requestFactoryMock = NetworkRequestFactoryMock()
let sut = QHHTTPRequest<DataModel>(url: "quickhatch.com", method: .get, requestFactory: requestFactoryMock)

await confirmation("") { confirmation in
sut.responsePublisher.sink(receiveCompletion: { completion in
switch completion {
case .failure(_): break
case .finished: break
}
},
receiveValue: { response in
#expect(response.body != nil)
confirmation.confirm()
}).store(in: &cancellables)
requestFactoryMock.subject.send(URLSessionMocks.anyResponse(withData: URLSessionMocks.anyDataModelSample))
}
}

}