Skip to content

Commit b424fb7

Browse files
authored
Merge pull request #1 from galbernator/steve/add-combine-support
Add Combine
2 parents 889918a + 30f5568 commit b424fb7

5 files changed

Lines changed: 246 additions & 2 deletions

File tree

Sources/ObservableNetworking/NetworkManager.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import RxSwift
10+
import Combine
1011

1112
public final class NetworkManager: Network {
1213
typealias Header = [String : String]
@@ -47,6 +48,16 @@ public final class NetworkManager: Network {
4748
dataRequest(method: method, endpoint: endpoint, parameters: parameters, headers: authenticatedHeaders(headers), requiresAuthentication: true)
4849
}
4950

51+
@available(iOS 13.0, *)
52+
public func request(method: HTTPMethod, endpoint: String, parameters: [String : Any]? = nil, headers: [String : String]? = nil) -> AnyPublisher<Data, NetworkError> {
53+
dataRequest(method: method, endpoint: endpoint, parameters: parameters, headers: defaultHeaders(headers))
54+
}
55+
56+
@available(iOS 13.0, *)
57+
public func authenticatedRequest(method: HTTPMethod, endpoint: String, parameters: [String : Any]? = nil, headers: [String : String]? = nil) -> AnyPublisher<Data, NetworkError> {
58+
dataRequest(method: method, endpoint: endpoint, parameters: parameters, headers: authenticatedHeaders(headers))
59+
}
60+
5061
// MARK: - Helpers
5162

5263
private func dataRequest(method: HTTPMethod, endpoint: String, parameters: [String : Any]?, headers: Header?, requiresAuthentication: Bool = false) -> Observable<Result<Data, NetworkError>> {
@@ -79,6 +90,20 @@ public final class NetworkManager: Network {
7990
return result
8091
}
8192

93+
@available(iOS 13.0, *)
94+
private func dataRequest(method: HTTPMethod, endpoint: String, parameters: [String : Any]?, headers: Header?) -> AnyPublisher<Data, NetworkError> {
95+
guard let request = generateRequest(method: method, endpoint: endpoint, parameters: parameters, headers: headers) else {
96+
let error = NetworkError.unexpected
97+
return Fail(error: error).eraseToAnyPublisher()
98+
}
99+
100+
let taskPublisher: AnyPublisher<Data, NetworkError> = session.dataTaskPublisher(for: request)
101+
return taskPublisher
102+
.mapError { .failure(message: $0.localizedDescription) }
103+
.flatMap(maxPublishers: .max(1)) { CurrentValueSubject<Data, NetworkError>($0) }
104+
.eraseToAnyPublisher()
105+
}
106+
82107
private func generateRequest(method: HTTPMethod, endpoint: String, parameters: [String: Any]?, headers: [String: String]?) -> URLRequest? {
83108
guard let baseURL = URL(string: environment.url) else { return nil }
84109

Sources/ObservableNetworking/Protocols/Network.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@
77

88
import Foundation
99
import RxSwift
10+
import Combine
1011

1112
public protocol Network {
1213
// RxSwift
1314
func request(method: HTTPMethod, endpoint: String, parameters: [String : Any]?, headers: [String : String]?) -> Observable<Result<Data, NetworkError>>
1415
func authenticatedRequest(method: HTTPMethod, endpoint: String, parameters: [String : Any]?, headers: [String : String]?) -> Observable<Result<Data, NetworkError>>
16+
17+
// Combine
18+
@available(iOS 13.0, *)
19+
func request(method: HTTPMethod, endpoint: String, parameters: [String : Any]?, headers: [String : String]?) -> AnyPublisher<Data, NetworkError>
20+
@available(iOS 13.0, *)
21+
func authenticatedRequest(method: HTTPMethod, endpoint: String, parameters: [String : Any]?, headers: [String : String]?) -> AnyPublisher<Data, NetworkError>
1522
}
1623

Sources/ObservableNetworking/Protocols/Session.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,27 @@
66
//
77

88
import Foundation
9+
import Combine
910

1011
/// Protocol that allows dependecy injection for the express purpose of mocking `URLSession` during testing. This should not be used elsewhere
1112
public protocol Session {
1213
typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void
1314
func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> DataTask
15+
16+
@available(iOS 13.0, *)
17+
func dataTaskPublisher<T: TaskPublisher>(for request: URLRequest) -> T
1418
}
1519

1620
// Make URLSession connform to Session protocol
1721
extension URLSession: Session {
1822
public func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> DataTask {
1923
return dataTask(with: request, completionHandler: completionHandler) as DataTask
2024
}
25+
26+
@available(iOS 13.0, *)
27+
public func dataTaskPublisher<T: TaskPublisher>(for request: URLRequest) -> T {
28+
return dataTaskPublisher(for: request) as T
29+
}
2130
}
2231

2332
/// Protocol that allows dependecy injection for the express purpose of mocking `URLSession` during testing. This should not be used elsewhere
@@ -27,3 +36,15 @@ public protocol DataTask {
2736

2837
// Make URLSessionDataTask conform to DataTask protocol
2938
extension URLSessionDataTask: DataTask {}
39+
40+
/// Protocol that allows dependecy injection for the express purpose of mocking `URLSession` during testing. This should not be used elsewhere
41+
@available(iOS 13.0, *)
42+
public protocol TaskPublisher: Publisher {
43+
}
44+
45+
// Make DataTaskPublisher conform to TaskPublisher protocol
46+
@available(iOS 13.0, *)
47+
extension URLSession.DataTaskPublisher: TaskPublisher {}
48+
49+
@available(iOS 13.0, *)
50+
extension AnyPublisher: TaskPublisher {}

Tests/ObservableNetworkingTests/MockURLSession.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import ObservableNetworking
10+
import Combine
1011

1112
class MockURLSession: Session {
1213

@@ -45,6 +46,12 @@ class MockURLSession: Session {
4546
return dataTask
4647
}
4748

49+
@available(iOS 13.0, *)
50+
func dataTaskPublisher<T>(for request: URLRequest) -> T where T : TaskPublisher {
51+
lastURL = request.url
52+
return MockDataTaskPublisher().eraseToAnyPublisher() as! T
53+
}
54+
4855
private func createCookieHeader(for url: URL?) -> [String : String] {
4956
guard let cookieURL = url else { return [:] }
5057

@@ -70,3 +77,27 @@ class MockURLSessionDataTask: DataTask {
7077
resumeWasCalled = true
7178
}
7279
}
80+
81+
class MockDataTaskPublisher: TaskPublisher {
82+
typealias Output = Data
83+
typealias Failure = NetworkError
84+
85+
@available(iOS 13.0, *)
86+
func receive<S>(subscriber: S) where S : Subscriber, MockDataTaskPublisher.Failure == S.Failure, MockDataTaskPublisher.Output == S.Input {}
87+
}
88+
89+
@available(iOS 13.0, *)
90+
extension Data: Subscriber {
91+
public typealias Input = Self
92+
public typealias Failure = NetworkError
93+
94+
public var combineIdentifier: CombineIdentifier {
95+
CombineIdentifier()
96+
}
97+
98+
public func receive(subscription: Subscription) {}
99+
100+
public func receive(completion: Subscribers.Completion<NetworkError>) {}
101+
102+
public func receive(_ input: Data) -> Subscribers.Demand { .unlimited }
103+
}

Tests/ObservableNetworkingTests/NetworkManagerTests.swift

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ final class NetworkManagerTests: XCTestCase {
5050
}
5151
}
5252

53-
func testPostRequestInEnvironments() {
53+
// MARK: - RxSwift Tests
54+
55+
func testRXPostRequestInEnvironments() {
5456
MockEnvironment.allCases.forEach { environment in
5557
let endpoint = "something"
5658
let session = MockURLSession()
@@ -203,7 +205,165 @@ final class NetworkManagerTests: XCTestCase {
203205
}
204206
}
205207

206-
//TODO: Add tests for Combine methods
208+
// MARK: - Combine Tests
209+
210+
@available(iOS 13.0, *)
211+
func testCombinePostRequetInEnvironments() {
212+
MockEnvironment.allCases.forEach { environment in
213+
let endpoint = "combine"
214+
let session = MockURLSession()
215+
let networkManager = NetworkManager(environment: environment, session: session, sessionCookieName: "Value")
216+
217+
let _ = networkManager.request(method: .post, endpoint: endpoint)
218+
.sink(receiveCompletion: { completion in
219+
switch completion {
220+
case .failure(let error):
221+
XCTFail("Error: \(error.localizedDescription)")
222+
case .finished:
223+
return
224+
}
225+
}, receiveValue: { _ in
226+
return
227+
})
228+
229+
guard let url = URL(string: "\(environment.scheme)://\(environment.host)/\(environment.path)\(endpoint)") else {
230+
XCTFail("Invalid URL")
231+
return
232+
}
233+
234+
XCTAssert(session.lastURL == url)
235+
}
236+
}
237+
238+
@available(iOS 13.0, *)
239+
func testCombineGetRequestInEnvironments() {
240+
MockEnvironment.allCases.forEach { environment in
241+
let params = [ "userID": "1234567"]
242+
let endpoint = "something"
243+
let session = MockURLSession()
244+
let networkManager = NetworkManager(environment: environment, session: session)
245+
246+
let _ = networkManager.request(method: .get, endpoint: endpoint, parameters: params)
247+
.sink(receiveCompletion: { completion in
248+
switch completion {
249+
case .failure(let error):
250+
XCTFail("Error: \(error.localizedDescription)")
251+
case .finished:
252+
return
253+
}
254+
}, receiveValue: { _ in
255+
return
256+
})
257+
258+
guard let url = URL(string: "\(environment.scheme)://\(environment.host)/\(environment.path)\(endpoint)?\(queryString(from: params))") else {
259+
XCTFail("Invalid URL")
260+
return
261+
}
262+
263+
XCTAssert(session.lastURL == url)
264+
}
265+
}
266+
267+
@available(iOS 13.0, *)
268+
func testCombineAuthenticatedPostFaileWithNoCookie() {
269+
MockEnvironment.allCases.forEach { environment in
270+
let endpoint = "crashtastic"
271+
let session = MockURLSession()
272+
session.requiresAuthentication = true
273+
let networkManager = NetworkManager(environment: environment, session: session)
274+
275+
let _ = networkManager.request(method: .post, endpoint: endpoint)
276+
.sink(receiveCompletion: { completion in
277+
switch completion {
278+
case .failure(let error):
279+
XCTAssertEqual(error.localizedDescription, NetworkError.unauthorized.localizedDescription)
280+
case .finished:
281+
XCTFail("Test should have returned an error")
282+
}
283+
}, receiveValue: { _ in
284+
return
285+
})
286+
}
287+
}
288+
289+
@available(iOS 13.0, *)
290+
func testCombineAuthenticatedPostRequestInEnvironments() {
291+
MockEnvironment.allCases.forEach { environment in
292+
let endpoint = "something"
293+
let session = MockURLSession()
294+
let networkManager = NetworkManager(environment: environment, session: session, sessionCookieName: "Value")
295+
296+
session.requiresAuthentication = false
297+
// Send an initial request to make sett the cookie
298+
let _ = networkManager.request(method: .post, endpoint: "login")
299+
.sink(receiveCompletion: { _ in
300+
return
301+
}) { _ in
302+
return
303+
}
304+
305+
session.requiresAuthentication = true
306+
307+
let _ = networkManager.authenticatedRequest(method: .post, endpoint: endpoint)
308+
.sink(receiveCompletion: { completion in
309+
switch completion {
310+
case .failure(let error):
311+
XCTFail("Error: \(error.localizedDescription)")
312+
case .finished:
313+
return
314+
}
315+
}, receiveValue: { data in
316+
XCTAssertNotNil(data)
317+
})
318+
319+
guard let url = URL(string: "\(environment.scheme)://\(environment.host)/\(environment.path)\(endpoint)") else {
320+
XCTFail("Invalid URL")
321+
return
322+
}
323+
324+
XCTAssert(session.lastURL == url)
325+
}
326+
}
327+
328+
@available(iOS 13.0, *)
329+
func testCombineAuthenticatedGetRequestInEnvironments() {
330+
MockEnvironment.allCases.forEach { environment in
331+
let params = [ "userID": "1234567"]
332+
let endpoint = "something"
333+
let session = MockURLSession()
334+
let networkManager = NetworkManager(environment: environment, session: session, sessionCookieName: "Value")
335+
336+
session.requiresAuthentication = false
337+
// Send an initial request to make sett the cookie
338+
let _ = networkManager.request(method: .post, endpoint: "login")
339+
.sink(receiveCompletion: { _ in
340+
return
341+
}) { _ in
342+
return
343+
}
344+
345+
session.requiresAuthentication = true
346+
347+
let _ = networkManager.authenticatedRequest(method: .get, endpoint: endpoint, parameters: params)
348+
.sink(receiveCompletion: { completion in
349+
switch completion {
350+
case .failure(let error):
351+
XCTFail("Error: \(error.localizedDescription)")
352+
case .finished:
353+
return
354+
}
355+
}, receiveValue: { data in
356+
XCTAssertNotNil(data)
357+
})
358+
359+
guard let url = URL(string: "\(environment.scheme)://\(environment.host)/\(environment.path)\(endpoint)?\(queryString(from: params))") else {
360+
XCTFail("Invalid URL")
361+
return
362+
}
363+
364+
XCTAssert(session.lastURL == url)
365+
}
366+
}
207367

208368
// MARK: - Test Helpers
209369

0 commit comments

Comments
 (0)