diff --git a/Demo.playground/Contents.swift b/Demo.playground/Contents.swift index aea5177c..18a73aca 100644 --- a/Demo.playground/Contents.swift +++ b/Demo.playground/Contents.swift @@ -60,13 +60,16 @@ struct GetRateLimitRequest: GitHubRequest { //: Step 4: Send request let request = GetRateLimitRequest() -Session.send(request) { result in +Session.send(request, uploadProgressHandler: { progress in + print("upload progress: \(progress.fractionCompleted)") +}, downloadProgressHandler: { progress in + print("download progress: \(progress.fractionCompleted) %") +}, completionHandler: { result in switch result { case .success(let rateLimit): print("count: \(rateLimit.count)") print("reset: \(rateLimit.resetDate)") - case .failure(let error): print("error: \(error)") } -} +}) diff --git a/Sources/APIKit/Concurrency/Concurrency.swift b/Sources/APIKit/Concurrency/Concurrency.swift index c2a0e7a3..82a6d458 100644 --- a/Sources/APIKit/Concurrency/Concurrency.swift +++ b/Sources/APIKit/Concurrency/Concurrency.swift @@ -23,7 +23,7 @@ public extension Session { return try await withTaskCancellationHandler(operation: { return try await withCheckedThrowingContinuation { continuation in Task { - let sessionTask = createSessionTask(request, callbackQueue: callbackQueue, progressHandler: { _ in }) { result in + let sessionTask = createSessionTask(request, callbackQueue: callbackQueue, uploadProgressHandler: { _ in }, downloadProgressHandler: { _ in }) { result in continuation.resume(with: result) } await cancellationHandler.register(with: sessionTask) diff --git a/Sources/APIKit/Session.swift b/Sources/APIKit/Session.swift index 9a63e179..0f202db1 100644 --- a/Sources/APIKit/Session.swift +++ b/Sources/APIKit/Session.swift @@ -10,6 +10,9 @@ open class Session { /// The default callback queue for `send(_:handler:)`. public let callbackQueue: CallbackQueue + /// Closure type executed when the upload or download progress of a request. + public typealias ProgressHandler = (Progress) -> Void + /// Returns `Session` instance that is initialized with `adapter`. /// - parameter adapter: The adapter that connects lower level backend with Session interface. /// - parameter callbackQueue: The default callback queue for `send(_:handler:)`. @@ -33,11 +36,13 @@ open class Session { /// Calls `send(_:callbackQueue:handler:)` of `Session.shared`. /// - parameter request: The request to be sent. /// - parameter callbackQueue: The queue where the handler runs. If this parameters is `nil`, default `callbackQueue` of `Session` will be used. - /// - parameter handler: The closure that receives result of the request. + /// - parameter uploadProgressHandler: The closure that receives upload progress of the request. + /// - parameter downloadProgressHandler: The closure that receives download progress of the request. + /// - parameter completionHandler: The closure that receives result of the request. /// - returns: The new session task. @discardableResult - open class func send(_ request: Request, callbackQueue: CallbackQueue? = nil, progressHandler: @escaping (Progress) -> Void = { _ in }, completionHandler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { - return shared.send(request, callbackQueue: callbackQueue, progressHandler: progressHandler, completionHandler: completionHandler) + open class func send(_ request: Request, callbackQueue: CallbackQueue? = nil, uploadProgressHandler: @escaping ProgressHandler = { _ in }, downloadProgressHandler: @escaping ProgressHandler = { _ in }, completionHandler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { + return shared.send(request, callbackQueue: callbackQueue, uploadProgressHandler: uploadProgressHandler, downloadProgressHandler: downloadProgressHandler, completionHandler: completionHandler) } /// Calls `cancelRequests(with:passingTest:)` of `Session.shared`. @@ -51,11 +56,13 @@ open class Session { /// `Request.Response` is inferred from `Request` type parameter, the it changes depending on the request type. /// - parameter request: The request to be sent. /// - parameter callbackQueue: The queue where the handler runs. If this parameters is `nil`, default `callbackQueue` of `Session` will be used. - /// - parameter handler: The closure that receives result of the request. + /// - parameter uploadProgressHandler: The closure that receives upload progress of the request. + /// - parameter downloadProgressHandler: The closure that receives download progress of the request. + /// - parameter completionHandler: The closure that receives result of the request. /// - returns: The new session task. @discardableResult - open func send(_ request: Request, callbackQueue: CallbackQueue? = nil, progressHandler: @escaping (Progress) -> Void = { _ in }, completionHandler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { - let task = createSessionTask(request, callbackQueue: callbackQueue, progressHandler: progressHandler, completionHandler: completionHandler) + open func send(_ request: Request, callbackQueue: CallbackQueue? = nil, uploadProgressHandler: @escaping ProgressHandler = { _ in }, downloadProgressHandler: @escaping ProgressHandler = { _ in }, completionHandler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { + let task = createSessionTask(request, callbackQueue: callbackQueue, uploadProgressHandler: uploadProgressHandler, downloadProgressHandler: downloadProgressHandler, completionHandler: completionHandler) task?.resume() return task } @@ -77,7 +84,7 @@ open class Session { } } - internal func createSessionTask(_ request: Request, callbackQueue: CallbackQueue?, progressHandler: @escaping (Progress) -> Void, completionHandler: @escaping (Result) -> Void) -> SessionTask? { + internal func createSessionTask(_ request: Request, callbackQueue: CallbackQueue?, uploadProgressHandler: @escaping ProgressHandler, downloadProgressHandler: @escaping ProgressHandler, completionHandler: @escaping (Result) -> Void) -> SessionTask? { let callbackQueue = callbackQueue ?? self.callbackQueue let urlRequest: URLRequest do { @@ -90,8 +97,15 @@ open class Session { } let task = adapter.createTask(with: urlRequest, - progressHandler: { progress in - progressHandler(progress) + uploadProgressHandler: { progress in + callbackQueue.execute { + uploadProgressHandler(progress) + } + }, + downloadProgressHandler: { progress in + callbackQueue.execute { + downloadProgressHandler(progress) + } }, completionHandler: { data, urlResponse, error in let result: Result diff --git a/Sources/APIKit/SessionAdapter/SessionAdapter.swift b/Sources/APIKit/SessionAdapter/SessionAdapter.swift index bf6f164d..a487108f 100644 --- a/Sources/APIKit/SessionAdapter/SessionAdapter.swift +++ b/Sources/APIKit/SessionAdapter/SessionAdapter.swift @@ -11,7 +11,7 @@ public protocol SessionTask: AnyObject { /// with `Session`. public protocol SessionAdapter { /// Returns instance that conforms to `SessionTask`. `handler` must be called after success or failure. - func createTask(with URLRequest: URLRequest, progressHandler: @escaping (Progress) -> Void, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask + func createTask(with URLRequest: URLRequest, uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask /// Collects tasks from backend networking stack. `handler` must be called after collecting. func getTasks(with handler: @escaping ([SessionTask]) -> Void) diff --git a/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift b/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift index 52f7f902..93cb58e7 100644 --- a/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift +++ b/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift @@ -6,7 +6,8 @@ extension URLSessionTask: SessionTask { private var dataTaskResponseBufferKey = 0 private var taskAssociatedObjectCompletionHandlerKey = 0 -private var taskAssociatedObjectProgressHandlerKey = 0 +private var taskAssociatedObjectUploadProgressHandlerKey = 0 +private var taskAssociatedObjectDownloadProgressHandlerKey = 0 /// `URLSessionAdapter` connects `URLSession` with `Session`. /// @@ -26,12 +27,13 @@ open class URLSessionAdapter: NSObject, SessionAdapter, URLSessionDelegate, URLS } /// Creates `URLSessionDataTask` instance using `dataTaskWithRequest(_:completionHandler:)`. - open func createTask(with URLRequest: URLRequest, progressHandler: @escaping (Progress) -> Void, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask { + open func createTask(with URLRequest: URLRequest, uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask { let task = urlSession.dataTask(with: URLRequest) setBuffer(NSMutableData(), forTask: task) setHandler(completionHandler, forTask: task) - setProgressHandler(progressHandler, forTask: task) + setUploadProgressHandler(uploadProgressHandler, forTask: task) + setDownloadProgressHandler(downloadProgressHandler, forTask: task) return task } @@ -60,12 +62,20 @@ open class URLSessionAdapter: NSObject, SessionAdapter, URLSessionDelegate, URLS return objc_getAssociatedObject(task, &taskAssociatedObjectCompletionHandlerKey) as? (Data?, URLResponse?, Error?) -> Void } - private func setProgressHandler(_ progressHandler: @escaping (Progress) -> Void, forTask task: URLSessionTask) { - objc_setAssociatedObject(task, &taskAssociatedObjectProgressHandlerKey, progressHandler as Any, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + private func setUploadProgressHandler(_ progressHandler: @escaping Session.ProgressHandler, forTask task: URLSessionTask) { + objc_setAssociatedObject(task, &taskAssociatedObjectUploadProgressHandlerKey, progressHandler as Any, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - private func progressHandler(for task: URLSessionTask) -> ((Progress) -> Void)? { - return objc_getAssociatedObject(task, &taskAssociatedObjectProgressHandlerKey) as? (Progress) -> Void + private func uploadProgressHandler(for task: URLSessionTask) -> Session.ProgressHandler? { + return objc_getAssociatedObject(task, &taskAssociatedObjectUploadProgressHandlerKey) as? Session.ProgressHandler + } + + private func setDownloadProgressHandler(_ progressHandler: @escaping Session.ProgressHandler, forTask task: URLSessionTask) { + objc_setAssociatedObject(task, &taskAssociatedObjectDownloadProgressHandlerKey, progressHandler as Any, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func downloadProgressHandler(for task: URLSessionTask) -> Session.ProgressHandler? { + return objc_getAssociatedObject(task, &taskAssociatedObjectDownloadProgressHandlerKey) as? Session.ProgressHandler } // MARK: URLSessionTaskDelegate @@ -73,15 +83,24 @@ open class URLSessionAdapter: NSObject, SessionAdapter, URLSessionDelegate, URLS handler(for: task)?(buffer(for: task) as Data?, task.response, error) } + open func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + let progress = Progress(totalUnitCount: totalBytesExpectedToSend) + progress.completedUnitCount = totalBytesSent + uploadProgressHandler(for: task)?(progress) + } + // MARK: URLSessionDataDelegate open func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { buffer(for: dataTask)?.append(data) + updateDownloadProgress(dataTask) } - // MARK: URLSessionDataDelegate - open func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { - let progress = Progress(totalUnitCount: totalBytesExpectedToSend) - progress.completedUnitCount = totalBytesSent - progressHandler(for: task)?(progress) + private func updateDownloadProgress(_ task: URLSessionTask) { + let receivedData = buffer(for: task) as Data? + let totalBytesReceived = Int64(receivedData?.count ?? 0) + let totalBytesExpected = task.response?.expectedContentLength ?? NSURLSessionTransferSizeUnknown + let progress = Progress(totalUnitCount: totalBytesExpected) + progress.completedUnitCount = totalBytesReceived + downloadProgressHandler(for: task)?(progress) } } diff --git a/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterSubclassTests.swift b/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterSubclassTests.swift index 938a72d9..dd9decc0 100644 --- a/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterSubclassTests.swift +++ b/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterSubclassTests.swift @@ -64,7 +64,7 @@ class URLSessionAdapterSubclassTests: XCTestCase { let adapter = SessionAdapter(configuration: configuration) let session = Session(adapter: adapter) - session.send(request, progressHandler: { _ in + session.send(request, uploadProgressHandler: { _ in expectation.fulfill() }) diff --git a/Tests/APIKitTests/SessionTests.swift b/Tests/APIKitTests/SessionTests.swift index 5e93a163..e2fd734e 100644 --- a/Tests/APIKitTests/SessionTests.swift +++ b/Tests/APIKitTests/SessionTests.swift @@ -218,14 +218,29 @@ class SessionTests: XCTestCase { waitForExpectations(timeout: 1.0, handler: nil) } - func testProgress() { + func testUploadProgress() { let dictionary = ["key": "value"] adapter.data = try! JSONSerialization.data(withJSONObject: dictionary, options: []) let expectation = self.expectation(description: "wait for response") let request = TestRequest(method: .post) - session.send(request, progressHandler: { progress in + session.send(request, uploadProgressHandler: { progress in + XCTAssertNotNil(progress) + expectation.fulfill() + }) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testDownloadProgress() { + let dictionary = ["key": "value"] + adapter.data = try! JSONSerialization.data(withJSONObject: dictionary, options: []) + + let expectation = self.expectation(description: "wait for response") + let request = TestRequest(method: .post) + + session.send(request, downloadProgressHandler: { progress in XCTAssertNotNil(progress) expectation.fulfill() }) @@ -248,7 +263,7 @@ class SessionTests: XCTestCase { return testSesssion } - override func send(_ request: Request, callbackQueue: CallbackQueue?, progressHandler: @escaping (Progress) -> Void, completionHandler: @escaping (Result) -> Void) -> SessionTask? { + override func send(_ request: Request, callbackQueue: CallbackQueue?, uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Result) -> Void) -> SessionTask? { functionCallFlags[(#function)] = true return super.send(request) @@ -263,7 +278,7 @@ class SessionTests: XCTestCase { SessionSubclass.send(TestRequest()) SessionSubclass.cancelRequests(with: TestRequest.self) - XCTAssertEqual(testSession.functionCallFlags["send(_:callbackQueue:progressHandler:completionHandler:)"], true) + XCTAssertEqual(testSession.functionCallFlags["send(_:callbackQueue:uploadProgressHandler:downloadProgressHandler:completionHandler:)"], true) XCTAssertEqual(testSession.functionCallFlags["cancelRequests(with:passingTest:)"], true) } } diff --git a/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift b/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift index 99f0ebf8..bddeba0c 100644 --- a/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift +++ b/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift @@ -42,7 +42,8 @@ class TestSessionAdapter: SessionAdapter { if task.cancelled { task.completionHandler(nil, nil, Error.cancelled) } else { - task.progressHandler(Progress(totalUnitCount: 1)) + task.uploadProgressHandler(Progress(totalUnitCount: 1)) + task.downloadProgressHandler(Progress(totalUnitCount: 1)) task.completionHandler(data, urlResponse, error) } } @@ -50,8 +51,8 @@ class TestSessionAdapter: SessionAdapter { tasks = [] } - func createTask(with URLRequest: URLRequest, progressHandler: @escaping (Progress) -> Void, completionHandler: @escaping (Data?, URLResponse?, Swift.Error?) -> Void) -> SessionTask { - let task = TestSessionTask(progressHandler: progressHandler, completionHandler: completionHandler) + func createTask(with URLRequest: URLRequest, uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Data?, URLResponse?, Swift.Error?) -> Void) -> SessionTask { + let task = TestSessionTask(uploadProgressHandler: uploadProgressHandler, downloadProgressHandler: downloadProgressHandler, completionHandler: completionHandler) tasks.append(task) return task diff --git a/Tests/APIKitTests/TestComponents/TestSessionTask.swift b/Tests/APIKitTests/TestComponents/TestSessionTask.swift index 05e3e92a..a6f4e658 100644 --- a/Tests/APIKitTests/TestComponents/TestSessionTask.swift +++ b/Tests/APIKitTests/TestComponents/TestSessionTask.swift @@ -4,16 +4,17 @@ import APIKit class TestSessionTask: SessionTask { var completionHandler: (Data?, URLResponse?, Error?) -> Void - var progressHandler: (Progress) -> Void + var uploadProgressHandler: Session.ProgressHandler + var downloadProgressHandler: Session.ProgressHandler var cancelled = false - init(progressHandler: @escaping (Progress) -> Void, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) { + init(uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) { self.completionHandler = completionHandler - self.progressHandler = progressHandler + self.uploadProgressHandler = uploadProgressHandler + self.downloadProgressHandler = downloadProgressHandler } func resume() { - } func cancel() {