Created
July 20, 2023 06:09
-
-
Save AbdullahAshi/e410dfcf16ef9b141acfec638680e384 to your computer and use it in GitHub Desktop.
generic network service using completion handlers, protocol driven, dependency injection applied. Unit tests included
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// NetworkService.swift | |
// | |
// Created by Abdullah Al-Ashi. | |
// | |
import Foundation | |
//MARK: - URLSession | |
typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void | |
protocol URLSessionDataTaskProtocol { | |
func resume() | |
} | |
extension URLSessionDataTask: URLSessionDataTaskProtocol {} | |
protocol URLSessionProtocol { | |
func dataTask(with url: URL, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol | |
func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol | |
} | |
extension URLSession: URLSessionProtocol { | |
func dataTask(with url: URL, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol { | |
return ( dataTask(with: url, completionHandler: completionHandler) as URLSessionDataTask) as URLSessionDataTaskProtocol | |
} | |
func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol { | |
return ( dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask) as URLSessionDataTaskProtocol | |
} | |
} | |
//MARK: - NetworkService | |
typealias NetworkCompletionHandler<Model: Codable> = (Result<Model?,Error>) -> Void | |
protocol NetworkServiceProtocol { | |
func get<Model: Codable>(url: URL, completion: @escaping NetworkCompletionHandler<Model>) | |
} | |
final class NetworkService: NetworkServiceProtocol { | |
enum HttpMethod: String { | |
case get = "GET" | |
case post = "POST" | |
case put = "PUT" | |
case delete = "DELETE" | |
} | |
private let urlSession: URLSessionProtocol | |
static let shared: NetworkServiceProtocol = NetworkService() | |
init(urlSession: () -> URLSessionProtocol = { | |
let sessionConfig = URLSessionConfiguration.ephemeral | |
sessionConfig.timeoutIntervalForRequest = 10.0 | |
sessionConfig.timeoutIntervalForResource = 60.0 | |
sessionConfig.requestCachePolicy = .reloadIgnoringLocalCacheData | |
return URLSession(configuration: sessionConfig) | |
}) { | |
self.urlSession = urlSession() | |
} | |
func get<Model: Codable>(url: URL, completion: @escaping NetworkCompletionHandler<Model>) { | |
request(url: url, method: .get, completion: completion) | |
} | |
private func request<Model: Codable>(url: URL, | |
method: HttpMethod, | |
completion: @escaping NetworkCompletionHandler<Model>){ | |
var urlRequest = URLRequest(url: url) | |
urlRequest.httpMethod = method.rawValue | |
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
let task = urlSession.dataTask(with: urlRequest) { data, response, error in | |
guard error == nil else { | |
completion(.failure(error!)) | |
return | |
} | |
guard let response = (response as? HTTPURLResponse) else { | |
completion(.failure(NetworkError.invalidResponse("response is nil"))) | |
return | |
} | |
guard 200..<399 ~= response.statusCode else { | |
completion(.failure(NetworkError.invalidResponse("unsuccessful status code"))) | |
return | |
} | |
guard let data = data else { | |
completion(.failure(NetworkError.invalidResponse("data is nil"))) | |
return | |
} | |
do { | |
let object = try JSONDecoder().decode(Model.self, from: data) | |
completion(.success(object)) | |
} catch { | |
do { | |
let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String : Any] | |
print(json ?? ["": ""]) | |
} catch { | |
print(error) | |
} | |
completion(.failure(NetworkError.serializationError(error))) | |
} | |
} | |
task.resume() | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// NetworkServiceTests.swift | |
// | |
// Created by Abdullah Al-Ashi | |
// | |
@testable import //TODO: add project name here | |
import XCTest | |
class NetworkServiceTests: XCTestCase { | |
let anyUrl = URL(string: "https://www.google.com")! | |
let mockUrlSession = MockURLSession() | |
lazy var networkService = NetworkService(urlSession: { return mockUrlSession }) | |
func testSuccess() throws { | |
let response: Response = .init(id: "123451") | |
let data: Data = try JSONEncoder().encode(response) | |
mockUrlSession.set(mockInfo: (data: data, statusCode: 200, error: nil)) | |
let expectation = XCTestExpectation(description: "error should be nil, and response shouldn't be nil") | |
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in | |
guard case .success(let value) = result else { | |
return XCTFail("Expected to be a success but got a failure with \(result)") | |
} | |
XCTAssertEqual(value?.id, "123451") | |
expectation.fulfill() | |
}) | |
self.wait(for: [expectation], timeout: 3.0) | |
} | |
func testUnsuccessfulStatus() throws { | |
mockUrlSession.set(mockInfo: (data: Data(), statusCode: 404, error: nil)) | |
let expectation = XCTestExpectation(description: "error should not be nil, and response should be nil") | |
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in | |
guard case .failure(let error) = result else { | |
return XCTFail("Expected to get an error but got a success \(result)") | |
} | |
XCTAssertEqual(error as! NetworkError, NetworkError.invalidResponse("unsuccessful status code")) | |
expectation.fulfill() | |
}) | |
self.wait(for: [expectation], timeout: 3.0) | |
} | |
func testErrorResponse() throws { | |
mockUrlSession.set(mockInfo: (data: nil, statusCode: 404, error: NetworkError.unexpectedError)) | |
let expectation = XCTestExpectation(description: "error should not be nil, and response should be nil") | |
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in | |
guard case .failure(let error) = result else { | |
return XCTFail("Expected to get an error but got a success \(result)") | |
} | |
XCTAssertEqual(error as! NetworkError, NetworkError.unexpectedError) | |
expectation.fulfill() | |
}) | |
self.wait(for: [expectation], timeout: 3.0) | |
} | |
func testTimeoutError() throws { | |
mockUrlSession.set(mockInfo: (data: nil, statusCode: 404, error: NetworkError.unexpectedError)) | |
let expectation = XCTestExpectation(description: "error timout should occur") | |
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in | |
guard case .failure(_) = result else { | |
return XCTFail("Expected to get an error but got a success \(result)") | |
} | |
DispatchQueue.global().asyncAfter(deadline: .now() + 30, execute: { | |
expectation.fulfill() | |
}) | |
}) | |
let myresult = XCTWaiter().wait(for: [expectation], timeout: 3) | |
XCTAssertEqual(myresult, XCTWaiter.Result.timedOut) | |
} | |
func testResponseIsNil() throws { | |
mockUrlSession.set(mockInfo: nil) | |
let expectation = XCTestExpectation(description: "error should not be nil, and response should be nil") | |
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in | |
guard case .failure(let error) = result else { | |
return XCTFail("Expected to get an error but got a success \(result)") | |
} | |
XCTAssertEqual(error as! NetworkError, NetworkError.invalidResponse("response is nil")) | |
expectation.fulfill() | |
}) | |
self.wait(for: [expectation], timeout: 3.0) | |
} | |
func testDataIsNil() throws { | |
mockUrlSession.set(mockInfo: nil) | |
let expectation = XCTestExpectation(description: "error should not be nil, and data should be nil") | |
mockUrlSession.set(mockInfo: (data: nil, statusCode: 200, error: nil)) | |
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in | |
guard case .failure(let error) = result else { | |
return XCTFail("Expected to get an error but got a success \(result)") | |
} | |
XCTAssertEqual(error as! NetworkError, NetworkError.invalidResponse("data is nil")) | |
expectation.fulfill() | |
}) | |
self.wait(for: [expectation], timeout: 3.0) | |
} | |
func testDecodingFail() throws { | |
struct MalformedResponse: Codable { | |
let di: String | |
} | |
let data: Data = try JSONEncoder().encode(MalformedResponse(di: "123451")) | |
let expectation = XCTestExpectation(description: "error should be nil, and response shouldn't be nil") | |
mockUrlSession.set(mockInfo: (data: data, statusCode: 200, error: nil)) | |
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in | |
guard case .failure(let error) = result else { | |
return XCTFail("Expected to get an error but got a success \(result)") | |
} | |
XCTAssertEqual(error as! NetworkError, NetworkError.serializationError(error)) | |
expectation.fulfill() | |
}) | |
self.wait(for: [expectation], timeout: 3.0) | |
} | |
} | |
// MARK: - Test Model | |
private extension NetworkServiceTests { | |
struct Response: Codable { | |
enum CodingKeys: CodingKey { | |
case id | |
} | |
let id: String | |
} | |
} | |
class MockURLSession: URLSessionProtocol { | |
typealias MockInfo = (data: Data?, statusCode: Int, error: Error?) | |
private (set) var nextDataTask = MockURLSessionDataTask() | |
private var mockInfo: MockInfo? | |
private var mockResponse: HTTPURLResponse { | |
return HTTPURLResponse(url: URL(string: "https://www.google.com")!, statusCode: mockInfo!.statusCode, httpVersion: "HTTP/1.1", headerFields: nil)! | |
} | |
func dataTask(with url: URL, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol { | |
nextDataTask.resume() | |
if let mockInfo = mockInfo { | |
completionHandler(mockInfo.data, mockResponse, mockInfo.error) | |
} else { | |
completionHandler(Data(), nil, nil) | |
} | |
return nextDataTask | |
} | |
func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol { | |
nextDataTask.resume() | |
if let mockInfo = mockInfo { | |
completionHandler(mockInfo.data, mockResponse, mockInfo.error) | |
} else { | |
completionHandler(Data(), nil, nil) | |
} | |
return nextDataTask | |
} | |
func set(mockInfo: MockInfo?) { | |
self.mockInfo = mockInfo | |
} | |
} | |
class MockURLSessionDataTask: URLSessionDataTaskProtocol { | |
private (set) var resumeCalled = false | |
func resume() { | |
resumeCalled = true | |
} | |
} | |
extension URLResponse { | |
static func mock(url: URL = URL(string: "https://www.google.com")!, | |
statusCode: Int = 200, | |
httpVersion: String? = nil, | |
headerFields: [String: String]? = nil) -> URLResponse { | |
return HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: httpVersion, headerFields: headerFields)! | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment