From ed80e315e8a410d6e04976e60254ab7273d3766f Mon Sep 17 00:00:00 2001 From: Ivan Vavilov Date: Mon, 15 Sep 2025 15:29:40 -0700 Subject: [PATCH 1/7] Remove the upload function --- Sources/Apexy/Client.swift | 10 ---- Sources/ApexyAlamofire/AlamofireClient.swift | 50 ------------------- .../ApexyURLSession/URLSessionClient.swift | 39 --------------- 3 files changed, 99 deletions(-) diff --git a/Sources/Apexy/Client.swift b/Sources/Apexy/Client.swift index 92f0408..fb13049 100644 --- a/Sources/Apexy/Client.swift +++ b/Sources/Apexy/Client.swift @@ -13,14 +13,4 @@ public protocol Client: AnyObject { completionHandler: @escaping (APIResult) -> Void ) -> Progress where T: Endpoint - /// Upload data to specified endpoint. - /// - /// - Parameters: - /// - endpoint: The remote endpoint and data to upload. - /// - completionHandler: The completion closure to be executed when request is completed. - /// - Returns: The progress of uploading data to the server. - func upload( - _ endpoint: T, - completionHandler: @escaping (APIResult) -> Void - ) -> Progress where T: UploadEndpoint } diff --git a/Sources/ApexyAlamofire/AlamofireClient.swift b/Sources/ApexyAlamofire/AlamofireClient.swift index b98ee12..fe389d2 100755 --- a/Sources/ApexyAlamofire/AlamofireClient.swift +++ b/Sources/ApexyAlamofire/AlamofireClient.swift @@ -131,56 +131,6 @@ open class AlamofireClient: Client, CombineClient { progress.cancellationHandler = { [weak request] in request?.cancel() } return progress } - - /// Upload data to specified endpoint. - /// - /// - Parameters: - /// - endpoint: The remote endpoint and data to upload. - /// - completionHandler: The completion closure to be executed when request is completed. - /// - Returns: The progress of uploading data to the server. - open func upload( - _ endpoint: T, - completionHandler: @escaping (APIResult) -> Void - ) -> Progress where T: UploadEndpoint { - - let urlRequest: URLRequest - let body: UploadEndpointBody - do { - (urlRequest, body) = try endpoint.makeRequest() - } catch { - completionHandler(.failure(error)) - return Progress() - } - - let request: UploadRequest - switch body { - case .data(let data): - request = sessionManager.upload(data, with: urlRequest) - case .file(let url): - request = sessionManager.upload(url, with: urlRequest) - case .stream(let stream): - request = sessionManager.upload(stream, with: urlRequest) - } - - request.responseData( - queue: responseQueue, - completionHandler: { (response: DataResponse) in - - let result = APIResult(catching: { () throws -> T.Content in - let data = try response.result.get() - return try endpoint.content(from: response.response, with: data) - }) - - self.completionQueue.async { - self.responseObserver?(response.request, response.response, response.data, result.error) - completionHandler(result) - } - }) - - let progress = request.uploadProgress - progress.cancellationHandler = { [weak request] in request?.cancel() } - return progress - } } diff --git a/Sources/ApexyURLSession/URLSessionClient.swift b/Sources/ApexyURLSession/URLSessionClient.swift index 71516ee..e7a660d 100644 --- a/Sources/ApexyURLSession/URLSessionClient.swift +++ b/Sources/ApexyURLSession/URLSessionClient.swift @@ -90,45 +90,6 @@ open class URLSessionClient: Client, CombineClient { return task.progress } - - open func upload(_ endpoint: T, completionHandler: @escaping (APIResult) -> Void) -> Progress where T : UploadEndpoint { - var request: (URLRequest, UploadEndpointBody) - do { - request = try endpoint.makeRequest() - request.0 = try requestAdapter.adapt(request.0) - } catch { - completionHandler(.failure(error)) - return Progress() - } - - let handler: (Data?, URLResponse?, Error?) -> Void = { (data, response, error) in - let result = APIResult(catching: { () throws -> T.Content in - let data = data ?? Data() - if let error = error { - throw error - } - return try endpoint.content(from: response, with: data) - }) - self.completionQueue.async { - self.responseObserver?(request.0, response as? HTTPURLResponse, data, error) - completionHandler(result) - } - } - - let task: URLSessionUploadTask - switch request { - case (let request, .data(let data)): - task = session.uploadTask(with: request, from: data, completionHandler: handler) - case (let request, .file(let url)): - task = session.uploadTask(with: request, fromFile: url, completionHandler: handler) - case (_, .stream): - completionHandler(.failure(URLSessionClientError.uploadStreamUnimplemented)) - return Progress() - } - task.resume() - - return task.progress - } } enum URLSessionClientError: LocalizedError { From f9067cdf08b042f55b0c7b9a61f2c0260a13c7a5 Mon Sep 17 00:00:00 2001 From: Ivan Vavilov Date: Mon, 15 Sep 2025 15:53:59 -0700 Subject: [PATCH 2/7] Move Concurrency Client to Client --- .../Business Logic/Service/BookService.swift | 4 +- .../Business Logic/Service/FileService.swift | 4 +- .../Sources/Business Logic/ServiceLayer.swift | 2 +- Sources/Apexy/Client.swift | 19 ++-- Sources/Apexy/Clients/ConcurrencyClient.swift | 24 ----- .../AlamofireClient+Concurrency.swift | 82 ---------------- Sources/ApexyAlamofire/AlamofireClient.swift | 96 ++++++++++++------- .../URLSessionClient+Concurrency.swift | 90 ----------------- .../ApexyURLSession/URLSessionClient.swift | 94 +++++++++++++----- 9 files changed, 147 insertions(+), 268 deletions(-) delete mode 100644 Sources/Apexy/Clients/ConcurrencyClient.swift delete mode 100644 Sources/ApexyAlamofire/AlamofireClient+Concurrency.swift delete mode 100644 Sources/ApexyURLSession/URLSessionClient+Concurrency.swift diff --git a/Example/Example/Sources/Business Logic/Service/BookService.swift b/Example/Example/Sources/Business Logic/Service/BookService.swift index d652088..3170827 100644 --- a/Example/Example/Sources/Business Logic/Service/BookService.swift +++ b/Example/Example/Sources/Business Logic/Service/BookService.swift @@ -18,9 +18,9 @@ protocol BookService { final class BookServiceImpl: BookService { - let apiClient: ConcurrencyClient + let apiClient: Client - init(apiClient: ConcurrencyClient) { + init(apiClient: Client) { self.apiClient = apiClient } diff --git a/Example/Example/Sources/Business Logic/Service/FileService.swift b/Example/Example/Sources/Business Logic/Service/FileService.swift index d72f60a..c30f6e9 100644 --- a/Example/Example/Sources/Business Logic/Service/FileService.swift +++ b/Example/Example/Sources/Business Logic/Service/FileService.swift @@ -17,9 +17,9 @@ protocol FileService { final class FileServiceImpl: FileService { - let apiClient: ConcurrencyClient + let apiClient: Client - init(apiClient: ConcurrencyClient) { + init(apiClient: Client) { self.apiClient = apiClient } diff --git a/Example/Example/Sources/Business Logic/ServiceLayer.swift b/Example/Example/Sources/Business Logic/ServiceLayer.swift index e224fca..647f60c 100644 --- a/Example/Example/Sources/Business Logic/ServiceLayer.swift +++ b/Example/Example/Sources/Business Logic/ServiceLayer.swift @@ -15,7 +15,7 @@ final class ServiceLayer { static let shared = ServiceLayer() - private(set) lazy var apiClient: ConcurrencyClient = AlamofireClient( + private(set) lazy var apiClient: Client = AlamofireClient( baseURL: URL(string: "https://library.mock-object.redmadserver.com/api/v1/")!, configuration: .ephemeral, responseObserver: { [weak self] request, response, data, error in diff --git a/Sources/Apexy/Client.swift b/Sources/Apexy/Client.swift index fb13049..508f8a5 100644 --- a/Sources/Apexy/Client.swift +++ b/Sources/Apexy/Client.swift @@ -1,16 +1,17 @@ import Foundation +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) public protocol Client: AnyObject { - /// Send request to specified endpoint. - /// /// - Parameters: - /// - endpoint: endpoint of remote content. - /// - completionHandler: The completion closure to be executed when request is completed. - /// - Returns: The progress of fetching the response data from the server for the request. - func request( - _ endpoint: T, - completionHandler: @escaping (APIResult) -> Void - ) -> Progress where T: Endpoint + /// - endpoint: endpoint of remote content. + /// - Returns: response data from the server for the request. + func request(_ endpoint: T) async throws -> T.Content where T: Endpoint + + /// Upload data to specified endpoint. + /// - Parameters: + /// - endpoint: endpoint of remote content. + /// - Returns: response data from the server for the upload. + func upload(_ endpoint: T) async throws -> T.Content where T: UploadEndpoint } diff --git a/Sources/Apexy/Clients/ConcurrencyClient.swift b/Sources/Apexy/Clients/ConcurrencyClient.swift deleted file mode 100644 index 83d673f..0000000 --- a/Sources/Apexy/Clients/ConcurrencyClient.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ConcurrencyClient.swift -// -// -// Created by Aleksei Tiurnin on 16.08.2022. -// - -import Foundation - -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -public protocol ConcurrencyClient: AnyObject { - /// Send request to specified endpoint. - /// - Parameters: - /// - endpoint: endpoint of remote content. - /// - Returns: response data from the server for the request. - func request(_ endpoint: T) async throws -> T.Content where T: Endpoint - - /// Upload data to specified endpoint. - /// - Parameters: - /// - endpoint: endpoint of remote content. - /// - Returns: response data from the server for the upload. - func upload(_ endpoint: T) async throws -> T.Content where T: UploadEndpoint - -} diff --git a/Sources/ApexyAlamofire/AlamofireClient+Concurrency.swift b/Sources/ApexyAlamofire/AlamofireClient+Concurrency.swift deleted file mode 100644 index 73b36be..0000000 --- a/Sources/ApexyAlamofire/AlamofireClient+Concurrency.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// AlamofireClient+Concurrency.swift -// -// -// Created by Aleksei Tiurnin on 15.08.2022. -// - -import Alamofire -import Apexy -import Foundation - -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -extension AlamofireClient: ConcurrencyClient { - - func observeResponse( - dataResponse: DataResponse, - error: Error?) { - self.responseObserver?( - dataResponse.request, - dataResponse.response, - dataResponse.data, - error) - } - - open func request(_ endpoint: T) async throws -> T.Content where T : Endpoint { - - let anyRequest = AnyRequest(create: endpoint.makeRequest) - let request = sessionManager.request(anyRequest) - .validate { request, response, data in - Result(catching: { try endpoint.validate(request, response: response, data: data) }) - } - - let dataResponse = await request.serializingData().response - let result = APIResult(catching: { () throws -> T.Content in - do { - let data = try dataResponse.result.get() - return try endpoint.content(from: dataResponse.response, with: data) - } catch { - throw error.unwrapAlamofireValidationError() - } - }) - - Task.detached { [weak self, dataResponse, result] in - self?.observeResponse(dataResponse: dataResponse, error: result.error) - } - - return try result.get() - } - - open func upload(_ endpoint: T) async throws -> T.Content where T : UploadEndpoint { - - let urlRequest: URLRequest - let body: UploadEndpointBody - (urlRequest, body) = try endpoint.makeRequest() - - let request: UploadRequest - switch body { - case .data(let data): - request = sessionManager.upload(data, with: urlRequest) - case .file(let url): - request = sessionManager.upload(url, with: urlRequest) - case .stream(let stream): - request = sessionManager.upload(stream, with: urlRequest) - } - - let dataResponse = await request.serializingData().response - let result = APIResult(catching: { () throws -> T.Content in - do { - let data = try dataResponse.result.get() - return try endpoint.content(from: dataResponse.response, with: data) - } catch { - throw error.unwrapAlamofireValidationError() - } - }) - - Task.detached { [weak self, dataResponse, result] in - self?.observeResponse(dataResponse: dataResponse, error: result.error) - } - - return try result.get() - } -} diff --git a/Sources/ApexyAlamofire/AlamofireClient.swift b/Sources/ApexyAlamofire/AlamofireClient.swift index fe389d2..31b32f3 100755 --- a/Sources/ApexyAlamofire/AlamofireClient.swift +++ b/Sources/ApexyAlamofire/AlamofireClient.swift @@ -10,6 +10,7 @@ import Apexy import Foundation /// API Client. +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) open class AlamofireClient: Client, CombineClient { /// Session network manager. @@ -93,43 +94,72 @@ open class AlamofireClient: Client, CombineClient { eventMonitors: eventMonitors) } - /// Send request to specified endpoint. - /// - /// - Parameters: - /// - endpoint: endpoint of remote content. - /// - completionHandler: The completion closure to be executed when request is completed. - /// - Returns: The progress of fetching the response data from the server for the request. - open func request( - _ endpoint: T, - completionHandler: @escaping (APIResult) -> Void - ) -> Progress where T: Endpoint { - + func observeResponse( + dataResponse: DataResponse, + error: Error?) { + self.responseObserver?( + dataResponse.request, + dataResponse.response, + dataResponse.data, + error) + } + + open func request(_ endpoint: T) async throws -> T.Content where T : Endpoint { + let anyRequest = AnyRequest(create: endpoint.makeRequest) let request = sessionManager.request(anyRequest) .validate { request, response, data in Result(catching: { try endpoint.validate(request, response: response, data: data) }) - }.responseData( - queue: responseQueue, - completionHandler: { (response: DataResponse) in - - let result = APIResult(catching: { () throws -> T.Content in - do { - let data = try response.result.get() - return try endpoint.content(from: response.response, with: data) - } catch { - throw error.unwrapAlamofireValidationError() - } - }) - - self.completionQueue.async { - self.responseObserver?(response.request, response.response, response.data, result.error) - completionHandler(result) - } - }) - - let progress = request.downloadProgress - progress.cancellationHandler = { [weak request] in request?.cancel() } - return progress + } + + let dataResponse = await request.serializingData().response + let result = APIResult(catching: { () throws -> T.Content in + do { + let data = try dataResponse.result.get() + return try endpoint.content(from: dataResponse.response, with: data) + } catch { + throw error.unwrapAlamofireValidationError() + } + }) + + Task.detached { [weak self, dataResponse, result] in + self?.observeResponse(dataResponse: dataResponse, error: result.error) + } + + return try result.get() + } + + open func upload(_ endpoint: T) async throws -> T.Content where T : UploadEndpoint { + + let urlRequest: URLRequest + let body: UploadEndpointBody + (urlRequest, body) = try endpoint.makeRequest() + + let request: UploadRequest + switch body { + case .data(let data): + request = sessionManager.upload(data, with: urlRequest) + case .file(let url): + request = sessionManager.upload(url, with: urlRequest) + case .stream(let stream): + request = sessionManager.upload(stream, with: urlRequest) + } + + let dataResponse = await request.serializingData().response + let result = APIResult(catching: { () throws -> T.Content in + do { + let data = try dataResponse.result.get() + return try endpoint.content(from: dataResponse.response, with: data) + } catch { + throw error.unwrapAlamofireValidationError() + } + }) + + Task.detached { [weak self, dataResponse, result] in + self?.observeResponse(dataResponse: dataResponse, error: result.error) + } + + return try result.get() } } diff --git a/Sources/ApexyURLSession/URLSessionClient+Concurrency.swift b/Sources/ApexyURLSession/URLSessionClient+Concurrency.swift deleted file mode 100644 index 547ad45..0000000 --- a/Sources/ApexyURLSession/URLSessionClient+Concurrency.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// URLSessionClient+Concurrency.swift -// -// -// Created by Aleksei Tiurnin on 15.08.2022. -// - -import Apexy -import Foundation - -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -extension URLSessionClient: ConcurrencyClient { - - func observeResponse( - request: URLRequest?, - responseResult: Result<(data: Data, response: URLResponse), Error>) { - let tuple = try? responseResult.get() - self.responseObserver?( - request, - tuple?.response as? HTTPURLResponse, - tuple?.data, - responseResult.error) - } - - open func request(_ endpoint: T) async throws -> T.Content where T : Endpoint { - - var request = try endpoint.makeRequest() - request = try requestAdapter.adapt(request) - var responseResult: Result<(data: Data, response: URLResponse), Error> - - do { - let response: (data: Data, response: URLResponse) = try await session.data(for: request) - - if let httpResponse = response.response as? HTTPURLResponse { - try endpoint.validate(request, response: httpResponse, data: response.data) - } - - responseResult = .success(response) - } catch let someError { - responseResult = .failure(someError) - } - - Task.detached { [weak self, request, responseResult] in - self?.observeResponse(request: request, responseResult: responseResult) - } - - return try responseResult.flatMap { tuple in - do { - return .success(try endpoint.content(from: tuple.response, with: tuple.data)) - } catch { - return .failure(error) - } - }.get() - } - - open func upload(_ endpoint: T) async throws -> T.Content where T : UploadEndpoint { - - var request: (request: URLRequest, body: UploadEndpointBody) = try endpoint.makeRequest() - request.request = try requestAdapter.adapt(request.request) - var responseResult: Result<(data: Data, response: URLResponse), Error> - - do { - let response: (data: Data, response: URLResponse) - switch request { - case (_, .data(let data)): - response = try await session.upload(for: request.request, from: data) - case (_, .file(let url)): - response = try await session.upload(for: request.request, fromFile: url) - case (_, .stream): - throw URLSessionClientError.uploadStreamUnimplemented - } - - responseResult = .success(response) - } catch let someError { - responseResult = .failure(someError) - } - - Task.detached { [weak self, request, responseResult] in - self?.observeResponse(request: request.request, responseResult: responseResult) - } - - return try responseResult.flatMap { tuple in - do { - return .success(try endpoint.content(from: tuple.response, with: tuple.data)) - } catch { - return .failure(error) - } - }.get() - } -} diff --git a/Sources/ApexyURLSession/URLSessionClient.swift b/Sources/ApexyURLSession/URLSessionClient.swift index e7a660d..3168a1b 100644 --- a/Sources/ApexyURLSession/URLSessionClient.swift +++ b/Sources/ApexyURLSession/URLSessionClient.swift @@ -1,6 +1,7 @@ import Apexy import Foundation +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) open class URLSessionClient: Client, CombineClient { let session: URLSession @@ -57,38 +58,81 @@ open class URLSessionClient: Client, CombineClient { self.responseObserver = responseObserver } - open func request( - _ endpoint: T, - completionHandler: @escaping (APIResult) -> Void) -> Progress where T : Endpoint { + func observeResponse( + request: URLRequest?, + responseResult: Result<(data: Data, response: URLResponse), Error>) { + let tuple = try? responseResult.get() + self.responseObserver?( + request, + tuple?.response as? HTTPURLResponse, + tuple?.data, + responseResult.error) + } + + open func request(_ endpoint: T) async throws -> T.Content where T : Endpoint { + + var request = try endpoint.makeRequest() + request = try requestAdapter.adapt(request) + var responseResult: Result<(data: Data, response: URLResponse), Error> - var request: URLRequest do { - request = try endpoint.makeRequest() - request = try requestAdapter.adapt(request) - } catch { - completionHandler(.failure(error)) - return Progress() + let response: (data: Data, response: URLResponse) = try await session.data(for: request) + + if let httpResponse = response.response as? HTTPURLResponse { + try endpoint.validate(request, response: httpResponse, data: response.data) + } + + responseResult = .success(response) + } catch let someError { + responseResult = .failure(someError) } + + Task.detached { [weak self, request, responseResult] in + self?.observeResponse(request: request, responseResult: responseResult) + } + + return try responseResult.flatMap { tuple in + do { + return .success(try endpoint.content(from: tuple.response, with: tuple.data)) + } catch { + return .failure(error) + } + }.get() + } + + open func upload(_ endpoint: T) async throws -> T.Content where T : UploadEndpoint { - let task = session.dataTask(with: request) { (data, response, error) in - let result = APIResult(catching: { () throws -> T.Content in - if let httpResponse = response as? HTTPURLResponse { - try endpoint.validate(request, response: httpResponse, data: data) - } - let data = data ?? Data() - if let error = error { - throw error - } - return try endpoint.content(from: response, with: data) - }) - self.completionQueue.async { - self.responseObserver?(request, response as? HTTPURLResponse, data, error) - completionHandler(result) + var request: (request: URLRequest, body: UploadEndpointBody) = try endpoint.makeRequest() + request.request = try requestAdapter.adapt(request.request) + var responseResult: Result<(data: Data, response: URLResponse), Error> + + do { + let response: (data: Data, response: URLResponse) + switch request { + case (_, .data(let data)): + response = try await session.upload(for: request.request, from: data) + case (_, .file(let url)): + response = try await session.upload(for: request.request, fromFile: url) + case (_, .stream): + throw URLSessionClientError.uploadStreamUnimplemented } + + responseResult = .success(response) + } catch let someError { + responseResult = .failure(someError) } - task.resume() - return task.progress + Task.detached { [weak self, request, responseResult] in + self?.observeResponse(request: request.request, responseResult: responseResult) + } + + return try responseResult.flatMap { tuple in + do { + return .success(try endpoint.content(from: tuple.response, with: tuple.data)) + } catch { + return .failure(error) + } + }.get() } } From 608ba811fd14e9de421af2e0176d4a6bb387f30e Mon Sep 17 00:00:00 2001 From: Ivan Vavilov Date: Mon, 15 Sep 2025 16:21:32 -0700 Subject: [PATCH 3/7] Update readme --- README.md | 29 +++++++++++++---------------- README.ru.md | 29 +++++++++++++---------------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 85cd057..f98ce48 100644 --- a/README.md +++ b/README.md @@ -93,20 +93,20 @@ public struct BookEndpoint: Endpoint { } } -let client = Client ... +let client: Client = ... let endpoint = BookEndpoint(id: "1") -client.request(endpoint) { (result: Result) - print(result) -} +let book = try await client.request(endpoint) +print(book) ``` ## Client -`Client` - an object with only one method for executing `Endpoint`. -- It's easy to mock, because it has only one method. +`Client` - an object with methods for executing `Endpoint` using modern async/await syntax. +- It's easy to mock, because it has a simple interface. - It's easy to send several `Endpoint`. - Easily wraps into decorators or adapters. For example, you can wrap in `Combine` and you don't have to make wrappers for each request. +- Supports both `request` and `upload` operations with async/await. The separation into `Client` and `Endpoint` allows you to separate the asynchronous code in `Client` from the synchronous code in `Endpoint`. Thus, the side effects are isolated in `Client`, and the pure functions in the non-mutable `Endpoint`. @@ -114,15 +114,7 @@ The separation into `Client` and `Endpoint` allows you to separate the asynchron `CombineClient` - protocol that wrap up network request in `Combine`. -### ConcurrencyClient - -`ConcurrencyClient` - protocol that wrap up network request in `Async/Await`. - -* By default, new methods implemented as extensions of `Client`'s methods. -* `ApexyAlamofire` use built in implementation of `Async/Await` in `Alamofire` -* For `URLSession` new `Async/Await` methods was implemented using `URLSession`'s `AsyncAwait` extended implementation for iOS 14 and below. (look into `URLSession+Concurrency.swift` for more details) - -`Client`, `CombineClient` and `ConcurrenyClient` are separated protocols. You can specify method that you are using by using specific protocol. +`Client` and `CombineClient` are separated protocols. You can specify method that you are using by using specific protocol. ## Getting Started @@ -222,7 +214,7 @@ public struct DeleteBookEndpoint: VoidEndpoint, URLRequestBuildable { ### Sending a large amount of data to the server -You can use `UploadEndpoint` to send files or large amounts of data. In the `makeRequest()` method you need to return `URLRequest` and the data you are uploading, it can be a file `.file(URL)`, a data `.data(Data)` or a stream `.stream(InputStream)`. To execute the request, call the `Client.upload(endpoint: completionHandler:)` method. Use `Progress` object to track the progress of the data upload or cancel the request. +You can use `UploadEndpoint` to send files or large amounts of data. In the `makeRequest()` method you need to return `URLRequest` and the data you are uploading, it can be a file `.file(URL)`, a data `.data(Data)` or a stream `.stream(InputStream)`. To execute the request, call the `Client.upload(endpoint:)` method using async/await. ```swift public struct FileUploadEndpoint: UploadEndpoint { @@ -247,6 +239,11 @@ public struct FileUploadEndpoint: UploadEndpoint { return (request, .file(fileUrl)) } } + +// Usage with async/await +let client: Client = ... +let endpoint = FileUploadEndpoint(fileUrl: fileURL) +try await client.upload(endpoint) ``` ## Network Layer Organization diff --git a/README.ru.md b/README.ru.md index 8a21958..3f2068d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -94,20 +94,20 @@ public struct BookEndpoint: Endpoint { } } -let client = Client ... +let client: Client = ... let endpoint = BookEndpoint(id: "1") -client.request(endpoint) { (result: Result) - print(result) -} +let book = try await client.request(endpoint) +print(book) ``` ## Client -`Client` - объект с одним методом способный выполнить `Endpoint`. -- Легко мокается, так как у него один метод. +`Client` - объект с методами для выполнения `Endpoint` используя современный синтаксис async/await. +- Легко мокается, так как у него простой интерфейс. - Легко отправить через него несколько разных `Endpoint`. - Легко оборачивается в декораторы или адаптеры. Например можно обернуть в `Combine` и вам не придется делать обертки для каждого запроса. +- Поддерживает как `request`, так и `upload` операции с async/await. Разделение на `Client` и `Endpoint` позволяет разделить асинхронный код в `Client` от синхронного кода в `Endpoint`. Таким образом сайд эффекты изолированы в одном месте `Client`, а чистые функции в немутабельных `Endpoint`. @@ -115,15 +115,7 @@ client.request(endpoint) { (result: Result) `CombineClient` - отдельный протокол, который содержит релизацию сетевый вызовов через Combine. -### ConcurrencyClient - -`ConcurrencyClient` - отдельный протокол, который содержит релизацию сетевый вызовов через Async/Await. - -* По умолчанию, новые методы релизованы как надстройки над существующими методами с замыканиями. -* Для `ApexyAlamofire` методы уже реализованы через методы `Alamofire`. -* Для `URLSession` добавлены через реализацию системных методов через Async/Await для версий ниже iOS 15. - -`Client`, `CombineClient` и `ConcurrenyClient` - независимые протоколы. В зависимости от удобного для вас способа работы асинхронностью, вы можете выбрать конкретный протокол. +`Client` и `CombineClient` - независимые протоколы. В зависимости от удобного для вас способа работы асинхронностью, вы можете выбрать конкретный протокол. ## Getting Started @@ -223,7 +215,7 @@ public struct DeleteBookEndpoint: VoidEndpoint, URLRequestBuildable { ### Отправка данных на сервер -Для отправки файлов или больших объемов данных вы можете использовать `UploadEndpoint`. В методе `makeRequest()` необходимо вернуть `URLRequest` и загружаемые данные, это может быть файл `.file(URL)`, данные `.data(Data)` или поток `.stream(InputStream)`. Для выполнения запроса вызовите метод `Client.upload(endpoint:, completionHandler:)`. С помощью объекта `Progress` вы сможете отслеживать прогресс загрузки данных либо отменить запрос. +Для отправки файлов или больших объемов данных вы можете использовать `UploadEndpoint`. В методе `makeRequest()` необходимо вернуть `URLRequest` и загружаемые данные, это может быть файл `.file(URL)`, данные `.data(Data)` или поток `.stream(InputStream)`. Для выполнения запроса вызовите метод `Client.upload(endpoint:)` используя async/await. ```swift public struct FileUploadEndpoint: UploadEndpoint { @@ -248,6 +240,11 @@ public struct FileUploadEndpoint: UploadEndpoint { return (request, .file(fileUrl)) } } + +// Использование с async/await +let client: Client = ... +let endpoint = FileUploadEndpoint(fileUrl: fileURL) +try await client.upload(endpoint) ``` ## Организация сетевого слоя From aa1889e2afcabeb67e3eb2b209c620ea668e1591 Mon Sep 17 00:00:00 2001 From: Ivan Vavilov Date: Tue, 16 Sep 2025 09:49:40 -0700 Subject: [PATCH 4/7] Update combine client --- Sources/Apexy/Clients/CombineClient.swift | 42 +++++++++++++++-------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/Sources/Apexy/Clients/CombineClient.swift b/Sources/Apexy/Clients/CombineClient.swift index da06ca3..88069ab 100644 --- a/Sources/Apexy/Clients/CombineClient.swift +++ b/Sources/Apexy/Clients/CombineClient.swift @@ -9,28 +9,40 @@ public protocol CombineClient: AnyObject { /// - endpoint: endpoint of remote content. /// - Returns: Publisher which you can subscribe to func request(_ endpoint: T) -> AnyPublisher where T: Endpoint + + /// Upload data to specified endpoint. + /// - Parameters: + /// - endpoint: endpoint of remote content. + /// - Returns: Publisher which you can subscribe to + func upload(_ endpoint: T) -> AnyPublisher where T: UploadEndpoint } @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) public extension Client where Self: CombineClient { func request(_ endpoint: T) -> AnyPublisher where T: Endpoint { - Deferred> { - let subject = PassthroughSubject() - - let progress = self.request(endpoint) { (result: Result) in - switch result { - case .success(let content): - subject.send(content) - subject.send(completion: .finished) - case .failure(let error): - subject.send(completion: .failure(error)) + Future { promise in + Task { + do { + let content = try await self.request(endpoint) + promise(.success(content)) + } catch { + promise(.failure(error)) + } + } + } + .eraseToAnyPublisher() + } + + func upload(_ endpoint: T) -> AnyPublisher where T: UploadEndpoint { + Future { promise in + Task { + do { + let content = try await self.upload(endpoint) + promise(.success(content)) + } catch { + promise(.failure(error)) } } - - return subject.handleEvents(receiveCancel: { - progress.cancel() - subject.send(completion: .finished) - }).eraseToAnyPublisher() } .eraseToAnyPublisher() } From ce19173b25b80db3fc884976f878219e011dda6c Mon Sep 17 00:00:00 2001 From: Ivan Vavilov Date: Mon, 13 Oct 2025 10:43:07 -0700 Subject: [PATCH 5/7] Update loader --- .../xcshareddata/swiftpm/Package.resolved | 9 ----- .../Loaders/OrganizationLoader.swift | 8 ++-- .../Loaders/RepositoriesLoader.swift | 8 ++-- Documentation/loader.md | 5 ++- Documentation/loader_ru.md | 5 ++- Sources/ApexyLoader/WebLoader.swift | 40 +++++++++++-------- 6 files changed, 37 insertions(+), 38 deletions(-) diff --git a/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bd539f9..873d451 100644 --- a/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,15 +9,6 @@ "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", "version": "5.4.1" } - }, - { - "package": "RxSwift", - "repositoryURL": "https://github.com/ReactiveX/RxSwift.git", - "state": { - "branch": null, - "revision": "b4307ba0b6425c0ba4178e138799946c3da594f8", - "version": "6.5.0" - } } ] }, diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganizationLoader.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganizationLoader.swift index 0c25226..15fec37 100644 --- a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganizationLoader.swift +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganizationLoader.swift @@ -12,14 +12,12 @@ protocol OrganizationLoading: ContentLoading { var state: LoadingState { get } } +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) final class OrganizationLoader: WebLoader, OrganizationLoading { func load() { guard startLoading() else { return } - request(OrganizationEndpoint()) { result in - // imitation of waiting for the request for 3 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - self.finishLoading(result) - } + Task { + await request(OrganizationEndpoint()) } } } diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift index 3fe519b..b0c6c09 100644 --- a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift @@ -12,14 +12,12 @@ protocol RepoLoading: ContentLoading { var state: LoadingState<[Repository]> { get } } +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) final class RepositoriesLoader: WebLoader<[Repository]>, RepoLoading { func load() { guard startLoading() else { return } - request(RepositoriesEndpoint()) { result in - // imitation of waiting for the request for 5 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - self.finishLoading(result) - } + Task { + await request(RepositoriesEndpoint()) } } } diff --git a/Documentation/loader.md b/Documentation/loader.md index e0a5347..cf8eba7 100644 --- a/Documentation/loader.md +++ b/Documentation/loader.md @@ -22,10 +22,13 @@ protocol UserProfileLoading: ContentLoading { var state: LoadingState { get } } +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) final class UserProfileLoader: WebLoader, UserProfileLoading { func load() { guard startLoading() else { return } - request(UserProfileEndpoint()) + Task { + await request(UserProfileEndpoint()) + } } } ``` diff --git a/Documentation/loader_ru.md b/Documentation/loader_ru.md index 2bbb7bc..b5164af 100644 --- a/Documentation/loader_ru.md +++ b/Documentation/loader_ru.md @@ -21,10 +21,13 @@ protocol UserProfileLoading: ContentLoading { var state: LoadingState { get } } +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) final class UserProfileLoader: WebLoader, UserProfileLoading { func load() { guard startLoading() else { return } - request(UserProfileEndpoint()) + Task { + await request(UserProfileEndpoint()) + } } } ``` diff --git a/Sources/ApexyLoader/WebLoader.swift b/Sources/ApexyLoader/WebLoader.swift index 87073a0..87102a2 100644 --- a/Sources/ApexyLoader/WebLoader.swift +++ b/Sources/ApexyLoader/WebLoader.swift @@ -2,9 +2,9 @@ import Apexy import Foundation /// Loads content by network. +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) open class WebLoader: ContentLoader { private let apiClient: Client - public private(set) var progress: Progress? /// Creates an instance of `WebLoader` to load content by network using specified `Client`. /// - Parameter apiClient: An instance of the `Client` protocol. Use `AlamofireClient` or `URLSessionClient`. @@ -12,18 +12,17 @@ open class WebLoader: ContentLoader { self.apiClient = apiClient } - deinit { - progress?.cancel() - } - /// Sends requests to the network. /// /// - Warning: You must call `startLoading` before calling this method! /// - Parameter endpoint: An object representing request. - public func request(_ endpoint: T) where T: Endpoint, T.Content == Content { - progress = apiClient.request(endpoint) { [weak self] result in - self?.progress = nil - self?.finishLoading(result) + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func request(_ endpoint: T) async where T: Endpoint, T.Content == Content { + do { + let content = try await apiClient.request(endpoint) + finishLoading(.success(content)) + } catch { + finishLoading(.failure(error)) } } @@ -32,10 +31,13 @@ open class WebLoader: ContentLoader { /// - Parameters: /// - endpoint: An object representing request. /// - transform: A closure that transforms successfull result. - public func request(_ endpoint: T, transform: @escaping (T.Content) -> Content) where T: Endpoint { - progress = apiClient.request(endpoint) { [weak self] result in - self?.progress = nil - self?.finishLoading(result.map(transform)) + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func request(_ endpoint: T, transform: @escaping (T.Content) -> Content) async where T: Endpoint { + do { + let content = try await apiClient.request(endpoint) + finishLoading(.success(transform(content))) + } catch { + finishLoading(.failure(error)) } } @@ -43,10 +45,14 @@ open class WebLoader: ContentLoader { /// - Parameters: /// - endpoint: An object representing request. /// - completion: A completion handler. - public func request(_ endpoint: T, completion: @escaping (Result) -> Void) where T: Endpoint { - progress = apiClient.request(endpoint) { [weak self] result in - self?.progress = nil - completion(result) + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func request(_ endpoint: T, completion: @escaping (Result) -> Void) async where T: Endpoint { + do { + let content = try await apiClient.request(endpoint) + completion(.success(content)) + } catch { + completion(.failure(error)) } } + } From d429afed0fad89cbf71aa5c1337036c11a3d6d07 Mon Sep 17 00:00:00 2001 From: Ivan Vavilov Date: Mon, 13 Oct 2025 10:58:48 -0700 Subject: [PATCH 6/7] Update tests --- .../AlamofireClientTests.swift | 38 ++-- .../ApexyLoaderTests/ContentLoaderTests.swift | 57 ++++++ Tests/ApexyLoaderTests/TestHelpers.swift | 45 +++++ Tests/ApexyLoaderTests/WebLoaderTests.swift | 184 ++++++++++++++++++ .../URLSessionClientTests.swift | 61 +++--- 5 files changed, 325 insertions(+), 60 deletions(-) create mode 100644 Tests/ApexyLoaderTests/TestHelpers.swift create mode 100644 Tests/ApexyLoaderTests/WebLoaderTests.swift diff --git a/Tests/ApexyAlamofireTests/AlamofireClientTests.swift b/Tests/ApexyAlamofireTests/AlamofireClientTests.swift index aa654c0..3772d05 100644 --- a/Tests/ApexyAlamofireTests/AlamofireClientTests.swift +++ b/Tests/ApexyAlamofireTests/AlamofireClientTests.swift @@ -22,7 +22,8 @@ final class AlamofireClientTests: XCTestCase { client = AlamofireClient(baseURL: url, configuration: config) } - func testClientRequest() { + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func testClientRequest() async throws { let endpoint = EmptyEndpoint() let data = "Test".data(using: .utf8)! MockURLProtocol.requestHandler = { request in @@ -30,20 +31,16 @@ final class AlamofireClientTests: XCTestCase { return (response, data) } - let exp = expectation(description: "wait for response") - _ = client.request(endpoint) { result in - switch result { - case .success(let content): - XCTAssertEqual(content, data) - case .failure: - XCTFail("Expected result: .success, actual result: .failure") - } - exp.fulfill() + do { + let content = try await client.request(endpoint) + XCTAssertEqual(content, data) + } catch { + XCTFail("Expected result: .success, actual result: .failure") } - wait(for: [exp], timeout: 1) } - func testClientUpload() { + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func testClientUpload() async throws { let data = "apple".data(using: .utf8)! let endpoint = SimpleUploadEndpoint(data: data) MockURLProtocol.requestHandler = { request in @@ -51,17 +48,12 @@ final class AlamofireClientTests: XCTestCase { return (response, data) } - let exp = expectation(description: "wait for response") - _ = client.upload(endpoint, completionHandler: { result in - switch result { - case .success(let content): - XCTAssertEqual(content, data) - case .failure: - XCTFail("Expected result: .success, actual result: .failure") - } - exp.fulfill() - }) - wait(for: [exp], timeout: 1) + do { + let content = try await client.upload(endpoint) + XCTAssertEqual(content, data) + } catch { + XCTFail("Expected result: .success, actual result: .failure") + } } } diff --git a/Tests/ApexyLoaderTests/ContentLoaderTests.swift b/Tests/ApexyLoaderTests/ContentLoaderTests.swift index 7be865a..e375d9c 100644 --- a/Tests/ApexyLoaderTests/ContentLoaderTests.swift +++ b/Tests/ApexyLoaderTests/ContentLoaderTests.swift @@ -164,4 +164,61 @@ final class ContentLoaderTests: XCTestCase { [.initial, .success(content: 1)], "The state didn't changed and the handler didn't triggered") } + + // MARK: - Async/Await Tests + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func testAsyncStateChanges() async { + // Test that async state changes trigger observations + let expectation = XCTestExpectation(description: "State change observed") + + // Set up observation + let observation = contentLoader.observe { + expectation.fulfill() + } + + // Change state asynchronously + Task { + contentLoader.state = .success(content: 42) + } + + // Wait for observation to be triggered + await fulfillment(of: [expectation], timeout: 1.0) + + // Verify state + switch contentLoader.state { + case .success(let content): + XCTAssertEqual(content, 42) + default: + XCTFail("Expected success state") + } + + // Clean up + _ = observation + } + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func testAsyncStateChangesCombine() async { + // Test that async state changes trigger Combine publishers + let expectation = XCTestExpectation(description: "State change published") + + // Set up publisher + let cancellable = contentLoader.statePublisher + .sink { state in + if case .success(let content) = state, content == 42 { + expectation.fulfill() + } + } + + // Change state asynchronously + Task { + contentLoader.state = .success(content: 42) + } + + // Wait for publisher to emit + await fulfillment(of: [expectation], timeout: 1.0) + + // Clean up + cancellable.cancel() + } } diff --git a/Tests/ApexyLoaderTests/TestHelpers.swift b/Tests/ApexyLoaderTests/TestHelpers.swift new file mode 100644 index 0000000..be43976 --- /dev/null +++ b/Tests/ApexyLoaderTests/TestHelpers.swift @@ -0,0 +1,45 @@ +import Apexy +import Foundation + +// MARK: - Mock Client + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +final class MockClient: Client { + var mockResult: Result = .success("Mock Content") + + func request(_ endpoint: T) async throws -> T.Content where T: Endpoint { + switch mockResult { + case .success(let content): + return content as! T.Content + case .failure(let error): + throw error + } + } + + func upload(_ endpoint: T) async throws -> T.Content where T: UploadEndpoint { + switch mockResult { + case .success(let content): + return content as! T.Content + case .failure(let error): + throw error + } + } +} + +// MARK: - Mock Endpoint + +struct MockEndpoint: Endpoint { + typealias Content = String + + func makeRequest() throws -> URLRequest { + return URLRequest(url: URL(string: "https://example.com")!) + } + + func content(from response: URLResponse?, with body: Data) throws -> Content { + return String(data: body, encoding: .utf8) ?? "" + } + + func validate(_ request: URLRequest?, response: HTTPURLResponse, data: Data?) throws { + // No validation needed for tests + } +} diff --git a/Tests/ApexyLoaderTests/WebLoaderTests.swift b/Tests/ApexyLoaderTests/WebLoaderTests.swift new file mode 100644 index 0000000..31548b6 --- /dev/null +++ b/Tests/ApexyLoaderTests/WebLoaderTests.swift @@ -0,0 +1,184 @@ +@testable import ApexyLoader +import Apexy +import XCTest + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +final class WebLoaderTests: XCTestCase { + + private var mockClient: MockClient! + private var webLoader: WebLoader! + + override func setUp() { + super.setUp() + mockClient = MockClient() + webLoader = WebLoader(apiClient: mockClient) + } + + override func tearDown() { + webLoader = nil + mockClient = nil + super.tearDown() + } + + // MARK: - Async Request Tests + + func testAsyncRequestSuccess() async { + // Given + let expectedContent = "Test Content" + mockClient.mockResult = .success(expectedContent) + let endpoint = MockEndpoint() + + // When + guard webLoader.startLoading() else { + XCTFail("Should be able to start loading") + return + } + + await webLoader.request(endpoint) + + // Then + switch webLoader.state { + case .success(let content): + XCTAssertEqual(content, expectedContent) + default: + XCTFail("Expected success state, got \(webLoader.state)") + } + } + + func testAsyncRequestFailure() async { + // Given + let expectedError = URLError(.networkConnectionLost) + mockClient.mockResult = .failure(expectedError) + let endpoint = MockEndpoint() + + // When + guard webLoader.startLoading() else { + XCTFail("Should be able to start loading") + return + } + + await webLoader.request(endpoint) + + // Then + switch webLoader.state { + case .failure(let error, let cache): + XCTAssertEqual(error as? URLError, expectedError) + XCTAssertNil(cache) + default: + XCTFail("Expected failure state, got \(webLoader.state)") + } + } + + func testAsyncRequestWithTransformation() async { + // Given + let rawContent = "Raw Content" + let transformedContent = "Transformed: Raw Content" + mockClient.mockResult = .success(rawContent) + let endpoint = MockEndpoint() + + // When + guard webLoader.startLoading() else { + XCTFail("Should be able to start loading") + return + } + + await webLoader.request(endpoint) { raw in + return "Transformed: \(raw)" + } + + // Then + switch webLoader.state { + case .success(let content): + XCTAssertEqual(content, transformedContent) + default: + XCTFail("Expected success state, got \(webLoader.state)") + } + } + + func testAsyncRequestWithCompletion() async { + // Given + let expectedContent = "Test Content" + mockClient.mockResult = .success(expectedContent) + let endpoint = MockEndpoint() + var completionCalled = false + var receivedResult: Result? + + // When + guard webLoader.startLoading() else { + XCTFail("Should be able to start loading") + return + } + + await webLoader.request(endpoint) { result in + completionCalled = true + receivedResult = result + } + + // Then + XCTAssertTrue(completionCalled) + switch receivedResult { + case .success(let content): + XCTAssertEqual(content, expectedContent) + case .failure: + XCTFail("Expected success result") + case .none: + XCTFail("Expected result to be set") + } + } + + func testAsyncRequestWithoutStartLoading() async { + // Given + let endpoint = MockEndpoint() + + // When + await webLoader.request(endpoint) + + // Then + // The request method should still work even without startLoading + // but it will call finishLoading which changes the state + switch webLoader.state { + case .success(let content): + XCTAssertEqual(content, "Mock Content") + default: + XCTFail("Expected success state, got \(webLoader.state)") + } + } + + func testAsyncLoadMethod() async { + // Given + let expectedContent = "Test Content" + mockClient.mockResult = .success(expectedContent) + let endpoint = MockEndpoint() + + // Create a custom loader that has a custom load method + let customLoader = CustomWebLoader(apiClient: mockClient, endpoint: endpoint) + + // When + await customLoader.customLoad() + + // Then + switch customLoader.state { + case .success(let content): + XCTAssertEqual(content, expectedContent) + default: + XCTFail("Expected success state, got \(customLoader.state)") + } + } + +} + + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +private final class CustomWebLoader: WebLoader { + private let testEndpoint: MockEndpoint + + init(apiClient: Client, endpoint: MockEndpoint) { + self.testEndpoint = endpoint + super.init(apiClient: apiClient) + } + + func customLoad() async { + guard startLoading() else { return } + await request(testEndpoint) + } +} diff --git a/Tests/ApexyURLSessionTests/URLSessionClientTests.swift b/Tests/ApexyURLSessionTests/URLSessionClientTests.swift index 33624be..57a747b 100644 --- a/Tests/ApexyURLSessionTests/URLSessionClientTests.swift +++ b/Tests/ApexyURLSessionTests/URLSessionClientTests.swift @@ -24,7 +24,8 @@ final class URLSessionClientTests: XCTestCase { client = URLSessionClient(baseURL: url, configuration: config) } - func testClientRequest() { + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func testClientRequest() async throws { let endpoint = EmptyEndpoint() let data = "Test".data(using: .utf8)! MockURLProtocol.requestHandler = { request in @@ -32,20 +33,16 @@ final class URLSessionClientTests: XCTestCase { return (response, data) } - let exp = expectation(description: "wait for response") - _ = client.request(endpoint) { result in - switch result { - case .success(let content): - XCTAssertEqual(content, data) - case .failure: - XCTFail("Expected result: .success, actual result: .failure") - } - exp.fulfill() + do { + let content = try await client.request(endpoint) + XCTAssertEqual(content, data) + } catch { + XCTFail("Expected result: .success, actual result: .failure") } - wait(for: [exp], timeout: 1) } - func testEndpointValidate() { + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func testEndpointValidate() async { var endpoint = EmptyEndpoint() endpoint.validateError = EndpointValidationError.validationFailed @@ -55,23 +52,18 @@ final class URLSessionClientTests: XCTestCase { return (response, data) } - let exp = expectation(description: "wait for response") - - _ = client.request(endpoint) { result in - switch result { - case .success: - XCTFail("Expected result: .failure, actual result: .success") - case .failure(let error as EndpointValidationError): - XCTAssertEqual(error, endpoint.validateError) - case .failure(let error): - XCTFail("Expected result: .failure(EndpointValidationError), actual result: .failure(\(error))") - } - exp.fulfill() + do { + _ = try await client.request(endpoint) + XCTFail("Expected result: .failure, actual result: .success") + } catch let error as EndpointValidationError { + XCTAssertEqual(error, endpoint.validateError) + } catch { + XCTFail("Expected result: .failure(EndpointValidationError), actual result: .failure(\(error))") } - wait(for: [exp], timeout: 1) } - func testClientUpload() { + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func testClientUpload() async throws { let data = "apple".data(using: .utf8)! let endpoint = SimpleUploadEndpoint(data: data) MockURLProtocol.requestHandler = { request in @@ -79,17 +71,12 @@ final class URLSessionClientTests: XCTestCase { return (response, data) } - let exp = expectation(description: "wait for response") - _ = client.upload(endpoint, completionHandler: { result in - switch result { - case .success(let content): - XCTAssertEqual(content, data) - case .failure: - XCTFail("Expected result: .success, actual result: .failure") - } - exp.fulfill() - }) - wait(for: [exp], timeout: 1) + do { + let content = try await client.upload(endpoint) + XCTAssertEqual(content, data) + } catch { + XCTFail("Expected result: .success, actual result: .failure") + } } @available(iOS 13.0, *) From 7b608bc3f7c72e19f2eb0d617664a266a12fa0ab Mon Sep 17 00:00:00 2001 From: Ivan Vavilov Date: Mon, 13 Oct 2025 11:08:24 -0700 Subject: [PATCH 7/7] Fix the test --- .../AlamofireClientCombineTests.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Tests/ApexyAlamofireTests/AlamofireClientCombineTests.swift b/Tests/ApexyAlamofireTests/AlamofireClientCombineTests.swift index 9cd68c4..e49550c 100644 --- a/Tests/ApexyAlamofireTests/AlamofireClientCombineTests.swift +++ b/Tests/ApexyAlamofireTests/AlamofireClientCombineTests.swift @@ -28,11 +28,10 @@ final class AlamofireClientCombineTests: XCTestCase { let exp = expectation(description: "wait for response") exp.expectedFulfillmentCount = 2 - let request = client.request(endpoint) - // First subscription + // First request var firstRequestContent: Data? - request + client.request(endpoint) .sink( receiveCompletion: { _ in }, receiveValue: { content in @@ -42,9 +41,9 @@ final class AlamofireClientCombineTests: XCTestCase { ) .store(in: &cancellables) - // Second subscription + // Second request var secondRequestContent: Data? - request + client.request(endpoint) .sink( receiveCompletion: { _ in }, receiveValue: { content in @@ -54,8 +53,8 @@ final class AlamofireClientCombineTests: XCTestCase { ) .store(in: &cancellables) - // Third subscription which will be cancelled at once - request + // Third request which will be cancelled at once + client.request(endpoint) .sink( receiveCompletion: { _ in }, receiveValue: { _ in }