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/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift b/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift index 4ec63176..ce60cea9 100644 --- a/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift +++ b/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift @@ -6,19 +6,14 @@ 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, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { - completion(stub(url)) - return Task() + func get(from url: URL) async throws -> (Data, HTTPURLResponse) { + try stub(url).get() } } diff --git a/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift index 4a88ecc0..f00cf9eb 100644 --- a/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift @@ -12,28 +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() } - } - - 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) - } + return (data, response) + } } diff --git a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift index a3921b60..faa3d571 100644 --- a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift @@ -4,15 +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 - func get(from url: URL, completion: @Sendable @escaping (Result) -> Void) -> HTTPClientTask + func get(from url: URL) async throws -> (Data, HTTPURLResponse) } 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) } } diff --git a/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift b/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift index 2e4262f5..ef193e81 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? @@ -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()))) @@ -93,39 +91,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 {