From 30a49e46ffcc873f29ce0c5e3ea94e694372436e Mon Sep 17 00:00:00 2001 From: Alasdair Law Date: Mon, 19 Dec 2016 00:40:27 +0000 Subject: [PATCH] Implement JSON network response --- NetworkOperation.xcodeproj/project.pbxproj | 28 ++++ NetworkOperation/JSONResponse.swift | 20 +++ NetworkOperation/NetworkOperation.swift | 125 ++++++++++++++++++ NetworkOperation/NetworkResponse.swift | 16 +++ .../MockSessionDataTask.swift | 53 ++++++++ .../MockURLSession+JSON.swift | 21 +++ NetworkOperationTests/MockURLSession.swift | 31 +++++ .../NetworkOperationTests.swift | 122 +++++++++++++++-- 8 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 NetworkOperation/JSONResponse.swift create mode 100644 NetworkOperation/NetworkOperation.swift create mode 100644 NetworkOperation/NetworkResponse.swift create mode 100644 NetworkOperationTests/MockSessionDataTask.swift create mode 100644 NetworkOperationTests/MockURLSession+JSON.swift create mode 100644 NetworkOperationTests/MockURLSession.swift diff --git a/NetworkOperation.xcodeproj/project.pbxproj b/NetworkOperation.xcodeproj/project.pbxproj index 74d2a76..84ee333 100644 --- a/NetworkOperation.xcodeproj/project.pbxproj +++ b/NetworkOperation.xcodeproj/project.pbxproj @@ -7,9 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 816D2CF91E07309200300E28 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816D2CF81E07309200300E28 /* MockURLSession.swift */; }; + 816D2CFD1E07572500300E28 /* NetworkResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816D2CFC1E07572500300E28 /* NetworkResponse.swift */; }; + 816D2CFF1E07573000300E28 /* JSONResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816D2CFE1E07573000300E28 /* JSONResponse.swift */; }; + 816D2D011E0760B100300E28 /* MockSessionDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816D2D001E0760B100300E28 /* MockSessionDataTask.swift */; }; 818A205F1E05EECF00B835F6 /* NetworkOperation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 818A20551E05EECF00B835F6 /* NetworkOperation.framework */; }; 818A20641E05EECF00B835F6 /* NetworkOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818A20631E05EECF00B835F6 /* NetworkOperationTests.swift */; }; 818A20661E05EECF00B835F6 /* NetworkOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = 818A20581E05EECF00B835F6 /* NetworkOperation.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 818A20701E05EF5F00B835F6 /* NetworkOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818A206F1E05EF5F00B835F6 /* NetworkOperation.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -23,12 +28,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 816D2CF81E07309200300E28 /* MockURLSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; + 816D2CFC1E07572500300E28 /* NetworkResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkResponse.swift; sourceTree = ""; }; + 816D2CFE1E07573000300E28 /* JSONResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONResponse.swift; sourceTree = ""; }; + 816D2D001E0760B100300E28 /* MockSessionDataTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockSessionDataTask.swift; sourceTree = ""; }; 818A20551E05EECF00B835F6 /* NetworkOperation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = NetworkOperation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 818A20581E05EECF00B835F6 /* NetworkOperation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NetworkOperation.h; sourceTree = ""; }; 818A20591E05EECF00B835F6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 818A205E1E05EECF00B835F6 /* NetworkOperationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NetworkOperationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 818A20631E05EECF00B835F6 /* NetworkOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkOperationTests.swift; sourceTree = ""; }; 818A20651E05EECF00B835F6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 818A206F1E05EF5F00B835F6 /* NetworkOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkOperation.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,6 +60,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 816D2D021E0760DA00300E28 /* Mocking */ = { + isa = PBXGroup; + children = ( + 816D2CF81E07309200300E28 /* MockURLSession.swift */, + 816D2D001E0760B100300E28 /* MockSessionDataTask.swift */, + ); + name = Mocking; + sourceTree = ""; + }; 818A204B1E05EECF00B835F6 = { isa = PBXGroup; children = ( @@ -73,6 +92,9 @@ children = ( 818A20581E05EECF00B835F6 /* NetworkOperation.h */, 818A20591E05EECF00B835F6 /* Info.plist */, + 818A206F1E05EF5F00B835F6 /* NetworkOperation.swift */, + 816D2CFC1E07572500300E28 /* NetworkResponse.swift */, + 816D2CFE1E07573000300E28 /* JSONResponse.swift */, ); path = NetworkOperation; sourceTree = ""; @@ -81,6 +103,7 @@ isa = PBXGroup; children = ( 818A20631E05EECF00B835F6 /* NetworkOperationTests.swift */, + 816D2D021E0760DA00300E28 /* Mocking */, 818A20651E05EECF00B835F6 /* Info.plist */, ); path = NetworkOperationTests; @@ -199,6 +222,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 818A20701E05EF5F00B835F6 /* NetworkOperation.swift in Sources */, + 816D2CFD1E07572500300E28 /* NetworkResponse.swift in Sources */, + 816D2CFF1E07573000300E28 /* JSONResponse.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -206,7 +232,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 816D2CF91E07309200300E28 /* MockURLSession.swift in Sources */, 818A20641E05EECF00B835F6 /* NetworkOperationTests.swift in Sources */, + 816D2D011E0760B100300E28 /* MockSessionDataTask.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/NetworkOperation/JSONResponse.swift b/NetworkOperation/JSONResponse.swift new file mode 100644 index 0000000..217837d --- /dev/null +++ b/NetworkOperation/JSONResponse.swift @@ -0,0 +1,20 @@ +// +// JSONNetworkResponse.swift +// NetworkOperation +// +// Created by Alasdair Law on 18/12/2016. +// Copyright © 2016 Alasdair Law. All rights reserved. +// + +public struct JSONResponse: NetworkResponse { + public typealias T = Any + + public let response: URLResponse + public let data: T + + public init(response: URLResponse, data: Data) throws { + self.response = response + + self.data = try JSONSerialization.jsonObject(with: data) + } +} diff --git a/NetworkOperation/NetworkOperation.swift b/NetworkOperation/NetworkOperation.swift new file mode 100644 index 0000000..b7903b8 --- /dev/null +++ b/NetworkOperation/NetworkOperation.swift @@ -0,0 +1,125 @@ +// +// NetworkOperation.swift +// NetworkOperation +// +// Created by Alasdair Law on 17/12/2016. +// Copyright © 2016 Alasdair Law. All rights reserved. +// + +/** + NSOperation subclass which performs a network request. + */ +public class NetworkOperation: Operation { + private let session: URLSession + private let request: URLRequest + private let networkCompletion: (_ response: T?, _ error: Error?) -> Void + + private var task: URLSessionTask! + + private var _isFinished: Bool + override public var isFinished: Bool { + get { + return self._isFinished + } + set { + self.willChangeValue(forKey: NSStringFromSelector(#selector(setter: isFinished))) + self._isFinished = newValue + self.didChangeValue(forKey: NSStringFromSelector(#selector(setter: isFinished))) + } + } + + private var _isExecuting: Bool + override public var isExecuting: Bool { + get { + return self._isExecuting + } + set { + self.willChangeValue(forKey: NSStringFromSelector(#selector(setter: isExecuting))) + self._isExecuting = newValue + self.didChangeValue(forKey: NSStringFromSelector(#selector(setter: isExecuting))) + } + } + + override public var isAsynchronous: Bool { + return true + } + + /** + Initialises a NetworkOperation. + + - Parameters: + - request: The request the network operation should perform. + - completion: Block called after the network request has completed, or when the operation was cancelled. + */ + init(session: URLSession, request: URLRequest, completion: @escaping (_ response: T?, _ error: Error?) -> Void) { + self.session = session + self.networkCompletion = completion + self._isFinished = false + self._isExecuting = false + + self.request = request + + super.init() + + self.task = self.session.dataTask(with: self.request) { [unowned self] (data, response, error) in + try? self.handle(data: data, response: response, error: error, nr: T.self) + } + } + + /** + Initialises a NetworkOperation. + + - Parameters: + - url: The URL the network operation should point to. + - completion: Block called after the network request has completed, or when the operation was cancelled. + */ + convenience init(session: URLSession, url: URL, completion: @escaping (_ response: T?, _ error: Error?) -> Void) { + self.init(session: session, request: URLRequest(url: url), completion: completion) + } + + override public func start() { + if self.isCancelled { + self.isFinished = true + } else if !self.isExecuting { + self.isExecuting = true + + self.task.resume() + } + } + + override public func cancel() { + self.isFinished = true + + self.task.cancel() + + super.cancel() + } + + // MARK: Private + + private func handle(data: Data?, response: URLResponse?, error: Error?, nr: T.Type) throws { + var networkResponse: T? + defer { + self.completeNetworkOperation(response: networkResponse, error: error) + } + + guard let response = response as? HTTPURLResponse else { + return + } + guard let data = data else { + return + } + networkResponse = try nr.init(response: response, data: data) + } + + private func completeNetworkOperation(response: T?, error: Error?) { + if !self.isCancelled { + self.networkCompletion(response, error) + } + + if self.isExecuting { + self.isExecuting = false + self.isFinished = true + } + } +} diff --git a/NetworkOperation/NetworkResponse.swift b/NetworkOperation/NetworkResponse.swift new file mode 100644 index 0000000..e0a2dc9 --- /dev/null +++ b/NetworkOperation/NetworkResponse.swift @@ -0,0 +1,16 @@ +// +// NetworkResponse.swift +// NetworkOperation +// +// Created by Alasdair Law on 18/12/2016. +// Copyright © 2016 Alasdair Law. All rights reserved. +// + +public protocol NetworkResponse { + associatedtype T + + var response: URLResponse { get } + var data: T { get } + + init(response: URLResponse, data: Data) throws +} diff --git a/NetworkOperationTests/MockSessionDataTask.swift b/NetworkOperationTests/MockSessionDataTask.swift new file mode 100644 index 0000000..ba7a876 --- /dev/null +++ b/NetworkOperationTests/MockSessionDataTask.swift @@ -0,0 +1,53 @@ +// +// MockSessionDataTask.swift +// NetworkOperation +// +// Created by Alasdair Law on 19/12/2016. +// Copyright © 2016 Alasdair Law. All rights reserved. +// + +import Foundation + +internal protocol MockResponse { + static func response(from url: URL) -> CompletionValues +} + +internal enum MockSessionDataTaskError: Error { + case cancelled +} + +internal class MockSessionDataTask: URLSessionDataTask { + private let mockResponse: CompletionValues + private let completion: Completion + + private var _state: URLSessionTask.State + override internal var state: URLSessionTask.State { + get { + return self._state + } + set { + self._state = newValue + } + } + + init(mockResponse: CompletionValues, completion: @escaping Completion) { + self.mockResponse = mockResponse + self.completion = completion + self._state = .running + + super.init() + } + + override internal func resume() { + let response = self.mockResponse + + if self._state == .running { + self.completion(response.0, response.1, response.2) + } + } + + override internal func cancel() { + self.state = .canceling + self.completion((nil, nil, MockSessionDataTaskError.cancelled)) + } +} diff --git a/NetworkOperationTests/MockURLSession+JSON.swift b/NetworkOperationTests/MockURLSession+JSON.swift new file mode 100644 index 0000000..ad0f505 --- /dev/null +++ b/NetworkOperationTests/MockURLSession+JSON.swift @@ -0,0 +1,21 @@ +// +// MockURLSession+JSON.swift +// NetworkOperation +// +// Created by Alasdair Law on 18/12/2016. +// Copyright © 2016 Alasdair Law. All rights reserved. +// + +import Foundation + +struct MockJSONResponse: MockResponse { + static func response(from url: URL) -> (Data?, URLResponse?, Error?) { + let jsonObject = [ + "test": "success" + ] + let data = try! JSONSerialization.data(withJSONObject: jsonObject, options: []) + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "https", headerFields: nil) + + return (data, response, nil) + } +} diff --git a/NetworkOperationTests/MockURLSession.swift b/NetworkOperationTests/MockURLSession.swift new file mode 100644 index 0000000..2acd8ef --- /dev/null +++ b/NetworkOperationTests/MockURLSession.swift @@ -0,0 +1,31 @@ +// +// MockURLSession.swift +// NetworkOperation +// +// Created by Alasdair Law on 18/12/2016. +// Copyright © 2016 Alasdair Law. All rights reserved. +// + +import Foundation + +internal typealias CompletionValues = (Data?, URLResponse?, Error?) +internal typealias Completion = (CompletionValues) -> Void + +internal class MockSession: URLSession { + private var mocks = [URL: CompletionValues]() + + internal func mock(url: URL, with response: (URL) -> CompletionValues) { + self.mocks[url] = response(url) + } + + internal func removeMocks() { + self.mocks.removeAll() + } + + override internal func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { + guard let mockResponse = self.mocks[request.url!] else { + return super.dataTask(with: request, completionHandler: completionHandler) + } + return MockSessionDataTask(mockResponse: mockResponse, completion: completionHandler) + } +} diff --git a/NetworkOperationTests/NetworkOperationTests.swift b/NetworkOperationTests/NetworkOperationTests.swift index e2cf318..8970a86 100644 --- a/NetworkOperationTests/NetworkOperationTests.swift +++ b/NetworkOperationTests/NetworkOperationTests.swift @@ -9,28 +9,126 @@ import XCTest @testable import NetworkOperation -class NetworkOperationTests: XCTestCase { +private let mockJSONURL = URL(string: "https://json.test")! + +internal class NetworkOperationTests: XCTestCase { + private var operationQueue = OperationQueue() + private var mockSession = MockSession() - override func setUp() { + override internal func setUp() { super.setUp() - // Put setup code here. This method is called before the invocation of each test method in the class. + + self.mockSession = MockSession() + self.mockTestRequests(session: self.mockSession) } - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. + override internal func tearDown() { super.tearDown() + + self.mockSession.removeMocks() + } + + // Mark: Tests + + /** + Test performing a network request, receiving a JSON response. + + Intended functionality: + - Network operation added to queue. + - Network operation completes with correct response. + - Operation queue is empty after completion. + */ + internal func testNetworkOperationFromRequest() { + let expectation = self.expectation(description: "JSON response") + + let request = URLRequest(url: mockJSONURL) + let operation = NetworkOperation(session: self.mockSession, request: request) { (jsonResponse: JSONResponse?, error) in + XCTAssert(jsonResponse?.data is [String: Any]) + XCTAssert(jsonResponse?.response is HTTPURLResponse) + expectation.fulfill() + } + + XCTAssert(operation.isAsynchronous) + self.operationQueue.addOperation(operation) + + self.waitForExpectations(timeout: 1.0) { (error) in + } + + XCTAssert(self.operationQueue.operationCount == 0) } - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. + /** + Test performing a network request from a URL, receiving a JSON response. + + Intended functionality: + - Network operation added to queue. + - Network operation completes with correct response. + - Operation queue is empty after completion. + */ + internal func testNetworkOperationFromURL() { + let expectation = self.expectation(description: "JSON response") + + let operation = NetworkOperation(session: self.mockSession, url: mockJSONURL) { (jsonResponse: JSONResponse?, error) in + XCTAssert(jsonResponse?.data is [String: Any]) + XCTAssert(jsonResponse?.response is HTTPURLResponse) + expectation.fulfill() + } + + XCTAssert(operation.isAsynchronous) + self.operationQueue.addOperation(operation) + + self.waitForExpectations(timeout: 1.0) { (error) in + } + + XCTAssert(self.operationQueue.operationCount == 0) } - func testPerformanceExample() { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. + /** + Test performing a network request, but cancelling before the network operation + has completed. + + Intended functionality: + - Network operation added to queue. + - Network operation cancelled before responding. + - Operation queue is empty after cancelling all operations. + */ + internal func testCancellingNetworkOperation() { + let expectation = self.expectation(description: "JSON response") + + let request = URLRequest(url: mockJSONURL) + let operation = NetworkOperation(session: self.mockSession, request: request) { (jsonResponse: JSONResponse?, error) in + XCTAssert(jsonResponse?.data == nil) + XCTAssert(jsonResponse?.response == nil) + + XCTAssert(error is MockSessionDataTaskError) + guard let error = error as? MockSessionDataTaskError else { + return + } + XCTAssert(error == .cancelled) + + expectation.fulfill() + } + + self.operationQueue.addOperation(operation) + self.operationQueue.cancelAllOperations() + + self.waitForExpectations(timeout: 1.0) { (error) in } + + XCTAssert(self.operationQueue.operationCount == 0) } + // MARK: Private + + private func mockTestRequests(session: MockSession) { + session.mock(url: mockJSONURL) { (url: URL) -> CompletionValues in + let jsonObject = [ + "test": "success" + ] + let data = try! JSONSerialization.data(withJSONObject: jsonObject, options: []) + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "https", headerFields: nil) + + return (data, response, nil) + } + } }