From 1011dea091e0d1e7f0d40c318c397799e7ec7503 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 29 Oct 2025 12:07:01 +0100 Subject: [PATCH 1/7] Introduce new HTTPClient async method and deprecate the closure-based one --- .../EssentialFeed/Shared API/HTTPClient.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift index a3921b60..69a65a4a 100644 --- a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift @@ -14,5 +14,23 @@ public protocol HTTPClient { /// The completion handler can be invoked in any thread. /// Clients are responsible to dispatch to appropriate threads, if needed. @discardableResult + @available(*, deprecated, message: "Use async alternative") func get(from url: URL, completion: @Sendable @escaping (Result) -> Void) -> HTTPClientTask + + func get(from url: URL) async throws -> (Data, HTTPURLResponse) +} + +public extension HTTPClient { + func get(from url: URL) async throws -> (Data, HTTPURLResponse) { + nonisolated(unsafe) var task: HTTPClientTask? + return try await withTaskCancellationHandler { + return try await withCheckedThrowingContinuation { continuation in + task = get(from: url) { result in + continuation.resume(with: result) + } + } + } onCancel: { + task?.cancel() + } + } } From f0b6b9133bd08c7f2f17827e5b09a9095b8819c2 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 29 Oct 2025 12:14:41 +0100 Subject: [PATCH 2/7] Migrate `URLSessionHTTPClientTests` to new async API --- .../URLSessionHTTPClientTests.swift | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift b/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift index 2e4262f5..358874b7 100644 --- a/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift @@ -14,7 +14,7 @@ class URLSessionHTTPClientTests: XCTestCase { URLProtocolStub.removeStub() } - func test_getFromURL_performsGETRequestWithURL() { + func test_getFromURL_performsGETRequestWithURL() async throws { let url = anyURL() let exp = expectation(description: "Wait for request") @@ -24,13 +24,13 @@ class URLSessionHTTPClientTests: XCTestCase { exp.fulfill() } - makeSUT().get(from: url) { _ in } + _ = try await makeSUT().get(from: url) - wait(for: [exp], timeout: 1.0) + await fulfillment(of: [exp], timeout: 1.0) } func test_cancelGetFromURLTask_cancelsURLRequest() async { - var task: HTTPClientTask? + var task: Task<(Data, HTTPURLResponse), Error>? URLProtocolStub.onStartLoading { task?.cancel() } let receivedError = await resultErrorFor(taskHandler: { task = $0 }) as NSError? @@ -93,39 +93,35 @@ class URLSessionHTTPClientTests: XCTestCase { } private func resultValuesFor(_ values: (data: Data?, response: URLResponse?, error: Error?), file: StaticString = #filePath, line: UInt = #line) async -> (data: Data, response: HTTPURLResponse)? { - let result = await resultFor(values, file: file, line: line) - - switch result { - case let .success(values): - return values - default: - XCTFail("Expected success, got \(result) instead", file: file, line: line) + do { + let result = try await resultFor(values, file: file, line: line) + return result + } catch { + XCTFail("Expected success, got \(error) instead", file: file, line: line) return nil } } - private func resultErrorFor(_ values: (data: Data?, response: URLResponse?, error: Error?)? = nil, taskHandler: (HTTPClientTask) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) async -> Error? { - let result = await resultFor(values, taskHandler: taskHandler, file: file, line: line) - - switch result { - case let .failure(error): - return error - default: + private func resultErrorFor(_ values: (data: Data?, response: URLResponse?, error: Error?)? = nil, taskHandler: (Task<(Data, HTTPURLResponse), Error>) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) async -> Error? { + do { + let result = try await resultFor(values, taskHandler: taskHandler, file: file, line: line) XCTFail("Expected failure, got \(result) instead", file: file, line: line) return nil + } catch { + return error } } - private func resultFor(_ values: (data: Data?, response: URLResponse?, error: Error?)?, taskHandler: (HTTPClientTask) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) async -> HTTPClient.Result { + private func resultFor(_ values: (data: Data?, response: URLResponse?, error: Error?)?, taskHandler: (Task<(Data, HTTPURLResponse), Error>) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) async throws -> (Data, HTTPURLResponse) { values.map { URLProtocolStub.stub(data: $0, response: $1, error: $2) } let sut = makeSUT(file: file, line: line) - return await withCheckedContinuation { continuation in - taskHandler(sut.get(from: anyURL()) { result in - continuation.resume(returning: result) - }) + let task = Task { + return try await sut.get(from: anyURL()) } + taskHandler(task) + return try await task.value } private func anyHTTPURLResponse() -> HTTPURLResponse { From cae2c6d8725a0143c5e30113f09c1f8dfa3b4a7c Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 29 Oct 2025 12:16:30 +0100 Subject: [PATCH 3/7] Migrate `EssentialFeedAPIEndToEndTests` to new async API --- .../EssentialFeedAPIEndToEndTests.swift | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index 047079fb..8e8ed117 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -47,16 +47,11 @@ class EssentialFeedAPIEndToEndTests: XCTestCase { private func getFeedResult(file: StaticString = #filePath, line: UInt = #line) async -> Swift.Result<[FeedImage], Error>? { let client = ephemeralClient() - return await withCheckedContinuation { continuation in - client.get(from: feedTestServerURL) { result in - continuation.resume(returning: result.flatMap { (data, response) in - do { - return .success(try FeedItemsMapper.map(data, from: response)) - } catch { - return .failure(error) - } - }) - } + do { + let (data, response) = try await client.get(from: feedTestServerURL) + return .success(try FeedItemsMapper.map(data, from: response)) + } catch { + return .failure(error) } } @@ -64,16 +59,11 @@ class EssentialFeedAPIEndToEndTests: XCTestCase { let client = ephemeralClient() let url = feedTestServerURL.appendingPathComponent("73A7F70C-75DA-4C2E-B5A3-EED40DC53AA6/image") - return await withCheckedContinuation { continuation in - client.get(from: url) { result in - continuation.resume(returning: result.flatMap { (data, response) in - do { - return .success(try FeedImageDataMapper.map(data, from: response)) - } catch { - return .failure(error) - } - }) - } + do { + let (data, response) = try await client.get(from: url) + return .success(try FeedImageDataMapper.map(data, from: response)) + } catch { + return .failure(error) } } From 76a3b547e8ac7253ff1c68ff9b65e723c479456d Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 29 Oct 2025 12:29:04 +0100 Subject: [PATCH 4/7] Migrate `EssentialApp` to new HTTPClient async API --- EssentialApp/EssentialApp/CombineHelpers.swift | 14 ++++++++++---- .../EssentialFeed/Shared API/HTTPClient.swift | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index c2464120..8cb3ca2a 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -32,18 +32,24 @@ public extension Paginated { } } +@MainActor public extension HTTPClient { typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error> func getPublisher(url: URL) -> Publisher { - var task: HTTPClientTask? + var task: Task? return Deferred { Future { completion in nonisolated(unsafe) let uncheckedCompletion = completion - task = self.get(from: url, completion: { - uncheckedCompletion($0) - }) + task = Task.immediate { + do { + let result = try await self.get(from: url) + uncheckedCompletion(.success(result)) + } catch { + uncheckedCompletion(.failure(error)) + } + } } } .handleEvents(receiveCancel: { task?.cancel() }) diff --git a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift index 69a65a4a..275ce638 100644 --- a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift @@ -20,6 +20,7 @@ public protocol HTTPClient { func get(from url: URL) async throws -> (Data, HTTPURLResponse) } +@MainActor public extension HTTPClient { func get(from url: URL) async throws -> (Data, HTTPURLResponse) { nonisolated(unsafe) var task: HTTPClientTask? From b6bd07e041dc3117fa4c4958bc1e4eaf9dac8734 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 29 Oct 2025 12:38:28 +0100 Subject: [PATCH 5/7] Implement `URLSessionHTTPClient` async API --- .../Shared API Infra/URLSessionHTTPClient.swift | 8 ++++++++ .../Shared API Infra/URLSessionHTTPClientTests.swift | 2 -- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift index 4a88ecc0..ca436648 100644 --- a/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift @@ -21,6 +21,14 @@ public final class URLSessionHTTPClient: HTTPClient { } } + public func get(from url: URL) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(from: url) + guard let response = response as? HTTPURLResponse else { + throw UnexpectedValuesRepresentation() + } + return (data, response) + } + public func get(from url: URL, completion: @Sendable @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { let task = session.dataTask(with: url) { data, response, error in completion(Result { diff --git a/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift b/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift index 358874b7..ef193e81 100644 --- a/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift @@ -47,9 +47,7 @@ class URLSessionHTTPClientTests: XCTestCase { } func test_getFromURL_failsOnAllInvalidRepresentationCases() async { - assertNotNil(await resultErrorFor((data: nil, response: nil, error: nil))) assertNotNil(await resultErrorFor((data: nil, response: nonHTTPURLResponse(), error: nil))) - assertNotNil(await resultErrorFor((data: anyData(), response: nil, error: nil))) assertNotNil(await resultErrorFor((data: anyData(), response: nil, error: anyNSError()))) assertNotNil(await resultErrorFor((data: nil, response: nonHTTPURLResponse(), error: anyNSError()))) assertNotNil(await resultErrorFor((data: nil, response: anyHTTPURLResponse(), error: anyNSError()))) From 1f4f5cc554074d1892b05ca01683b6a1500a1ce7 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 29 Oct 2025 12:39:57 +0100 Subject: [PATCH 6/7] Implement `HTTPClientStub` async API --- EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift b/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift index 4ec63176..3748f4fa 100644 --- a/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift +++ b/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift @@ -16,6 +16,10 @@ class HTTPClientStub: HTTPClient { self.stub = stub } + func get(from url: URL) async throws -> (Data, HTTPURLResponse) { + try stub(url).get() + } + func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { completion(stub(url)) return Task() From fdb1a2cb9f04810a8d290ea2cb641372ad6aa251 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 29 Oct 2025 12:43:47 +0100 Subject: [PATCH 7/7] Remove deprecated `HTTPClient` closured-based API in favor of the new async API --- .../Helpers/HTTPClientStub.swift | 13 ++------- .../URLSessionHTTPClient.swift | 26 +---------------- .../EssentialFeed/Shared API/HTTPClient.swift | 28 ------------------- 3 files changed, 3 insertions(+), 64 deletions(-) diff --git a/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift b/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift index 3748f4fa..ce60cea9 100644 --- a/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift +++ b/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift @@ -6,24 +6,15 @@ import Foundation import EssentialFeed class HTTPClientStub: HTTPClient { - private class Task: HTTPClientTask { - func cancel() {} - } - - private let stub: (URL) -> HTTPClient.Result + private let stub: (URL) -> Result<(Data, HTTPURLResponse), Error> - init(stub: @escaping (URL) -> HTTPClient.Result) { + init(stub: @escaping (URL) -> Result<(Data, HTTPURLResponse), Error>) { self.stub = stub } func get(from url: URL) async throws -> (Data, HTTPURLResponse) { try stub(url).get() } - - func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { - completion(stub(url)) - return Task() - } } extension HTTPClientStub { diff --git a/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift index ca436648..f00cf9eb 100644 --- a/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift @@ -12,36 +12,12 @@ public final class URLSessionHTTPClient: HTTPClient { } private struct UnexpectedValuesRepresentation: Error {} - - private struct URLSessionTaskWrapper: HTTPClientTask { - let wrapped: URLSessionTask - func cancel() { - wrapped.cancel() - } - } - public func get(from url: URL) async throws -> (Data, HTTPURLResponse) { let (data, response) = try await session.data(from: url) guard let response = response as? HTTPURLResponse else { throw UnexpectedValuesRepresentation() } return (data, response) - } - - public func get(from url: URL, completion: @Sendable @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { - let task = session.dataTask(with: url) { data, response, error in - completion(Result { - if let error = error { - throw error - } else if let data = data, let response = response as? HTTPURLResponse { - return (data, response) - } else { - throw UnexpectedValuesRepresentation() - } - }) - } - task.resume() - return URLSessionTaskWrapper(wrapped: task) - } + } } diff --git a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift index 275ce638..faa3d571 100644 --- a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift @@ -4,34 +4,6 @@ import Foundation -public protocol HTTPClientTask { - func cancel() -} - public protocol HTTPClient { - typealias Result = Swift.Result<(Data, HTTPURLResponse), Error> - - /// The completion handler can be invoked in any thread. - /// Clients are responsible to dispatch to appropriate threads, if needed. - @discardableResult - @available(*, deprecated, message: "Use async alternative") - func get(from url: URL, completion: @Sendable @escaping (Result) -> Void) -> HTTPClientTask - func get(from url: URL) async throws -> (Data, HTTPURLResponse) } - -@MainActor -public extension HTTPClient { - func get(from url: URL) async throws -> (Data, HTTPURLResponse) { - nonisolated(unsafe) var task: HTTPClientTask? - return try await withTaskCancellationHandler { - return try await withCheckedThrowingContinuation { continuation in - task = get(from: url) { result in - continuation.resume(with: result) - } - } - } onCancel: { - task?.cancel() - } - } -}