From 2730c9fb527f8156efcbde579e7ac9c0b8a4acc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ulas=CC=A7=20Sancak?= Date: Thu, 9 May 2024 15:37:24 +0300 Subject: [PATCH] Add download progress functionality --- Package.swift | 2 +- .../Helpers/URLSession+AsyncDownload.swift | 34 ----- Sources/Resting/Resting.swift | 65 ++++++++- .../RequestConfigurationTests.swift | 1 + Tests/RestingTests/RestClientTests.swift | 134 +++++++++++++++--- .../RestClientWithFailureTests.swift | 20 ++- 6 files changed, 189 insertions(+), 67 deletions(-) delete mode 100644 Sources/Resting/Helpers/URLSession+AsyncDownload.swift diff --git a/Package.swift b/Package.swift index 4f24012..4f61e9e 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Resting", defaultLocalization: "en", - platforms: [.iOS(.v13), .macOS(.v10_15)], + platforms: [.iOS(.v15), .macOS(.v12), .watchOS(.v8), .tvOS(.v15)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/Sources/Resting/Helpers/URLSession+AsyncDownload.swift b/Sources/Resting/Helpers/URLSession+AsyncDownload.swift deleted file mode 100644 index 3c82152..0000000 --- a/Sources/Resting/Helpers/URLSession+AsyncDownload.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// URLSession+AsyncDownload.swift -// -// -// Created by Ulaş Sancak on 8.05.2024. -// - -import Foundation - -/// A set of extensions for `URLSession` that provide asynchronous download capabilities. -extension URLSession { - /// Asynchronously downloads a file for a given `URLRequest`. - /// - /// - Parameters: - /// - request: The `URLRequest` to be downloaded. - /// - Returns: A tuple containing the downloaded file's URL and its associated URL response. - /// - Throws: Any errors encountered during the download operation. - func asyncDownload(for request: URLRequest) async throws -> (URL, URLResponse) { - return try await withCheckedThrowingContinuation { continuation in - let task = downloadTask(with: request) { url, response, error in - guard let url, let response else { - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(throwing: RestingError.unknown) - } - return - } - continuation.resume(returning: (url, response)) - } - task.resume() - } - } -} diff --git a/Sources/Resting/Resting.swift b/Sources/Resting/Resting.swift index 8ade9ea..96d94a7 100644 --- a/Sources/Resting/Resting.swift +++ b/Sources/Resting/Resting.swift @@ -26,17 +26,21 @@ public struct RestClientConfiguration { /// Represents a client for making RESTful network requests. /// /// /// This client utilizes a `RestClientConfiguration` to configure its behavior, including the session configuration and JSON decoding. -public class RestClient { - private let session: URLSession +public class RestClient: NSObject { + private lazy var session: URLSession = URLSession(configuration: clientConfiguration.sessionConfiguration, delegate: self, delegateQueue: nil) private let clientConfiguration: RestClientConfiguration + private var downloadCompletion: ((URL?, Error?) -> Void)? + private var progress: ((Double) -> Void)? + private var downloadTask: URLSessionDownloadTask? + /// Creates a new `RestClient` instance with the specified `RestClientConfiguration`. /// /// - Parameters: /// - configuration: The configuration used to set up the client, defaults to a new `RestClientConfiguration` instance with a default `URLSessionConfiguration`. public init(configuration: RestClientConfiguration = .init(sessionConfiguration: .default)) { self.clientConfiguration = configuration - self.session = URLSession(configuration: configuration.sessionConfiguration) + super.init() } } @@ -68,10 +72,57 @@ extension RestClient { /// - Parameter configuration: The configuration for the network request. /// - Returns: A `URL` to the downloaded file. /// - Throws: Throws an error if the request fails or if the response can't be created. - public func download(with configuration: RequestConfiguration) async throws -> URL { - let urlRequest = try configuration.createURLRequest() - let response = try await session.asyncDownload(for: urlRequest) - return response.0 + public func download(with configuration: RequestConfiguration, completion: @escaping (URL?, Error?) -> Void, progress: ((Double) -> Void)? = nil) { + self.downloadCompletion = completion + self.progress = progress + do { + let urlRequest = try configuration.createURLRequest() + downloadTask = session.downloadTask(with: urlRequest) + downloadTask?.resume() + } catch { + completion(nil, error) + self.downloadCompletion = nil + self.progress = nil + } + } + + public func cancel() { + downloadTask?.cancel() + } +} + +extension RestClient: URLSessionDownloadDelegate { + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + downloadCompletion?(nil, error) + downloadCompletion = nil + progress = nil + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + defer { + self.downloadCompletion = nil + self.progress = nil + } + do { + let documentsURL = try + FileManager.default.url(for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false) + let savedURL = documentsURL.appendingPathComponent( + location.lastPathComponent) + try FileManager.default.moveItem(at: location, to: savedURL) + downloadCompletion?(savedURL, nil) + } catch { + downloadCompletion?(nil, error) + } + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + guard downloadTask == self.downloadTask else { return } + let bytesWritten = Double(bytesWritten) + let totalBytesWritten = Double(totalBytesWritten) + progress?(bytesWritten/totalBytesWritten) } } diff --git a/Tests/RestingTests/RequestConfigurationTests.swift b/Tests/RestingTests/RequestConfigurationTests.swift index 39e32c0..558aa59 100644 --- a/Tests/RestingTests/RequestConfigurationTests.swift +++ b/Tests/RestingTests/RequestConfigurationTests.swift @@ -31,6 +31,7 @@ final class RequestConfigurationTests: XCTestCase { } } + func testCreatingURLConfigurationWithParameterTypeFailure() throws { let requestConfiguration: RequestConfiguration = .init(urlString: "http://www.example.com", method: .get, body: Data()) do { diff --git a/Tests/RestingTests/RestClientTests.swift b/Tests/RestingTests/RestClientTests.swift index 884eaff..a4c3144 100644 --- a/Tests/RestingTests/RestClientTests.swift +++ b/Tests/RestingTests/RestClientTests.swift @@ -12,6 +12,7 @@ import Combine final class RestClientTests: XCTestCase { private let configuration = URLSessionConfiguration.default private var cancellables = Set() + var xClient: RestClient? override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. @@ -60,7 +61,7 @@ final class RestClientTests: XCTestCase { XCTAssertEqual(response.title, "Title Example", "Response data don't match the example data!") } - func testDownloadAsyncAwait() async throws { + func testDownloadAsyncAwait() { let exampleString = "Text" let exampleData = exampleString.data(using: .utf8) MockedURLService.observer = { request -> (URLResponse?, Data?) in @@ -70,10 +71,94 @@ final class RestClientTests: XCTestCase { let restClient = RestClient(configuration: .init(sessionConfiguration: configuration)) let configuration = RequestConfiguration(urlString: "http://www.example.com", parameters: nil) - let responseURL: URL = try await restClient.download(with: configuration) - let responseData = try Data(contentsOf: responseURL) + let expectation = XCTestExpectation(description: "Downloading file...") - XCTAssertEqual(responseData, exampleData, "Downloaded file don't match the example file data!") + restClient.download(with: configuration) { url, error in + guard let url else { + XCTFail("Downloaded file url should not be nil!") + return + } + guard error == nil else { + XCTFail("Download shouldn't have been failed!") + return + } + do { + let responseData = try Data(contentsOf: url) + XCTAssertEqual(responseData, exampleData, "Downloaded file don't match the example file data!") + } catch { + XCTFail(error.localizedDescription) + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testDownloadWithProgressAsyncAwait() { + let exampleString = "Text" + let exampleData = exampleString.data(using: .utf8) + MockedURLService.observer = { request -> (URLResponse?, Data?) in + let response = HTTPURLResponse(url: URL(string: "http://www.example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil) + return (response, exampleData) + } + let restClient = RestClient(configuration: .init(sessionConfiguration: configuration)) + let configuration = RequestConfiguration(urlString: "http://www.example.com", parameters: nil) + + let expectation = XCTestExpectation(description: "Downloading file...") + + var currentProgress: Double = 0 + + restClient.download(with: configuration) { url, error in + guard let url else { + XCTFail("Downloaded file url should not be nil!") + return + } + guard error == nil else { + XCTFail("Download shouldn't have been failed!") + return + } + do { + let responseData = try Data(contentsOf: url) + XCTAssertEqual(responseData, exampleData, "Downloaded file don't match the example file data!") + XCTAssertEqual(currentProgress, 1, "Downloaded progress don't match!") + } catch { + XCTFail(error.localizedDescription) + } + expectation.fulfill() + } progress: { progress in + currentProgress = progress + } + + wait(for: [expectation], timeout: 1.0) + } + + func testCancellingDownloadAsyncAwait() { + let exampleString = "Text" + let exampleData = exampleString.data(using: .utf8) + MockedURLService.observer = { request -> (URLResponse?, Data?) in + let response = HTTPURLResponse(url: URL(string: "http://www.example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil) + return (response, exampleData) + } + let restClient = RestClient(configuration: .init(sessionConfiguration: configuration)) + let configuration = RequestConfiguration(urlString: "http://www.example.com", parameters: nil) + + let expectation = XCTestExpectation(description: "Downloading file...") + + restClient.download(with: configuration) { url, error in + guard let error else { + XCTFail("Download should have been failed!") + return + } + guard let error = error as? URLError, + error.code == .cancelled else { + XCTFail("Download should have been failed as cancelled!") + return + } + expectation.fulfill() + } + restClient.cancel() + + wait(for: [expectation], timeout: 1.0) } func testPublisher() throws { @@ -126,22 +211,6 @@ final class RestClientTests: XCTestCase { } } - func testAsyncAwaitWithUnknownFailure() async throws { - MockedURLService.observer = { request -> (URLResponse?, Data?) in - return (nil, nil) - } - let restClient = RestClient(configuration: .init(sessionConfiguration: configuration)) - let configuration = RequestConfiguration(urlString: "http://www.example.com", method: .get) - - do { - let _: URL = try await restClient.download(with: configuration) - XCTFail("Downloading should have been failed with unknown case!") - } catch RestingError.unknown { - } catch { - XCTFail(error.localizedDescription) - } - } - func testPublisherWithPublisherFailure() throws { MockedURLService.observer = { request -> (URLResponse?, Data?) in let response = HTTPURLResponse(url: URL(string: "http://\u{FFFD}\u{FFFE}")!, statusCode: 200, httpVersion: nil, headerFields: nil) @@ -170,4 +239,29 @@ final class RestClientTests: XCTestCase { waitForExpectations(timeout: 1) } + + func testHandlingURLConfigurationFailureWithDownload() { + MockedURLService.observer = { request -> (URLResponse?, Data?) in + let response = HTTPURLResponse(url: URL(string: "http://\u{FFFD}\u{FFFE}")!, statusCode: 403, httpVersion: nil, headerFields: nil) + return (response, nil) + } + let restClient = RestClient(configuration: .init(sessionConfiguration: configuration)) + let configuration = RequestConfiguration(urlString: "http://\u{FFFD}\u{FFFE}", method: .get) + + let expectation = XCTestExpectation(description: "Downloading file...") + + restClient.download(with: configuration) { url, error in + guard url == nil else { + XCTFail("Download file url should be nil!") + return + } + guard error != nil else { + XCTFail("Download should not be nil!") + return + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } } diff --git a/Tests/RestingTests/RestClientWithFailureTests.swift b/Tests/RestingTests/RestClientWithFailureTests.swift index 3e07bef..2d1a02c 100644 --- a/Tests/RestingTests/RestClientWithFailureTests.swift +++ b/Tests/RestingTests/RestClientWithFailureTests.swift @@ -48,7 +48,7 @@ final class RestClientWithFailureTests: XCTestCase { waitForExpectations(timeout: 1) } - func testAsyncAwaitWithFailure() async throws { + func testAsyncAwaitWithFailure() { MockedURLService.observer = { request -> (URLResponse?, Data?) in let response = HTTPURLResponse(url: URL(string: "unsupported_url")!, statusCode: 403, httpVersion: nil, headerFields: nil) return (response, nil) @@ -56,10 +56,20 @@ final class RestClientWithFailureTests: XCTestCase { let restClient = RestClient(configuration: .init(sessionConfiguration: configuration)) let configuration = RequestConfiguration(urlString: "unsupported_url", method: .get) - do { - let url: URL = try await restClient.download(with: configuration) - XCTFail("Downloading should have been failed!") - } catch { + let expectation = XCTestExpectation(description: "Downloading file...") + + restClient.download(with: configuration) { url, error in + guard url == nil else { + XCTFail("Download file url should be nil!") + return + } + guard error != nil else { + XCTFail("Download should have been failed!") + return + } + expectation.fulfill() } + + wait(for: [expectation], timeout: 1.0) } }