diff --git a/Sources/Provider/ItemProvider.swift b/Sources/Provider/ItemProvider.swift index 48c08ae..f6f2fce 100644 --- a/Sources/Provider/ItemProvider.swift +++ b/Sources/Provider/ItemProvider.swift @@ -85,7 +85,6 @@ extension ItemProvider: Provider { case .finished: break } - self?.removeCancellable(cancellable: cancellable) }, receiveValue: { (item: Item) in itemHandler(.success(item)) @@ -97,7 +96,6 @@ extension ItemProvider: Provider { } @discardableResult public func provideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = [], handlerQueue: DispatchQueue = .main, allowExpiredItems: Bool = false, itemsHandler: @escaping (Result<[Item], ProviderError>) -> Void) -> AnyCancellable? { - var cancellable: AnyCancellable? cancellable = provideItems(request: request, decoder: decoder, @@ -112,7 +110,6 @@ extension ItemProvider: Provider { case .finished: break } - self?.removeCancellable(cancellable: cancellable) }, receiveValue: { (items: [Item]) in itemsHandler(.success(items)) @@ -260,6 +257,34 @@ extension ItemProvider: Provider { .eraseToAnyPublisher() } + @available(*, deprecated, message: "This API does not work with `FetchPolicy.returnFromCacheAndNetwork` and will only return the first response that is provided. Please transition over to `AsyncStream` version of `func asyncProvide(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> AsyncStream>` instead.") + public func asyncProvide(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result { + await withCheckedContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = provide(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { [weak self] result in + continuation.resume(returning: result) + + self?.removeCancellable(cancellable: cancellable) + } + + insertCancellable(cancellable: cancellable) + } + } + + @available(*, deprecated, message: "This API does not work with `FetchPolicy.returnFromCacheAndNetwork` and will only return the first response that is provided. Please transition over to `AsyncStream` version of `func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> AsyncStream>` instead.") + public func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result<[Item], ProviderError> { + await withCheckedContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = provideItems(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { [weak self] result in + continuation.resume(returning: result) + + self?.removeCancellable(cancellable: cancellable) + } + + insertCancellable(cancellable: cancellable) + } + } + public func asyncProvide(request: any ProviderRequest, decoder: any ItemDecoder = JSONDecoder(), providerBehaviors: [any ProviderBehavior] = [], requestBehaviors: [any Networking.RequestBehavior] = [], allowExpiredItem: Bool = false) async -> AsyncStream> { return AsyncStream { [weak self] continuation in var cancellable: AnyCancellable? diff --git a/Sources/Provider/Provider.swift b/Sources/Provider/Provider.swift index f5733bf..55310f1 100644 --- a/Sources/Provider/Provider.swift +++ b/Sources/Provider/Provider.swift @@ -59,6 +59,24 @@ public protocol Provider: Sendable { /// - allowExpiredItems: Allows the publisher to publish expired items from the cache. If expired items are published, this publisher will then also publish up to date results from the network when they are available. func provideItems(request: any ProviderRequest, decoder: ItemDecoder, providerBehaviors: [ProviderBehavior], requestBehaviors: [RequestBehavior], allowExpiredItems: Bool) -> AnyPublisher<[Item], ProviderError> + /// Returns a item or a `ProviderError` after the async operation has been completed. + /// - Parameters: + /// - request: The request that provides the details needed to retrieve the items from persistence or networking. + /// - decoder: The decoder used to convert network response data into an array of the type specified by the generic placeholder. + /// - providerBehaviors: Actions to perform before the provider request is performed and / or after the provider request is completed. + /// - requestBehaviors: Actions to perform before the network request is performed and / or after the network request is completed. Only called if the items weren’t successfully retrieved from persistence. + /// - Returns: The item or error which occurred + func asyncProvide(request: any ProviderRequest, decoder: ItemDecoder, providerBehaviors: [ProviderBehavior], requestBehaviors: [RequestBehavior]) async -> Result + + /// Returns a collection of items or a `ProviderError` after the async operation has been completed. + /// - Parameters: + /// - request: The request that provides the details needed to retrieve the items from persistence or networking. + /// - decoder: The decoder used to convert network response data into an array of the type specified by the generic placeholder. + /// - providerBehaviors: Actions to perform before the provider request is performed and / or after the provider request is completed. + /// - requestBehaviors: Actions to perform before the network request is performed and / or after the network request is completed. Only called if the items weren’t successfully retrieved from persistence. + /// - Returns: The items or error which occurred. + func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder, providerBehaviors: [ProviderBehavior], requestBehaviors: [RequestBehavior]) async -> Result<[Item], ProviderError> + /// Makes a request for an item and provides the result of that request as a stream of results. /// - Parameters: /// - request: The request that provides the details needed to retrieve the items from persistence or networking. diff --git a/Tests/ItemProviderTest+Async.swift b/Tests/ItemProviderTest+Async.swift index f930176..194fa4a 100644 --- a/Tests/ItemProviderTest+Async.swift +++ b/Tests/ItemProviderTest+Async.swift @@ -41,6 +41,212 @@ final class ItemProviderTests_Async: XCTestCase { try? expiredProvider.cache?.removeAll() } + // MARK: - Async Provide Items Tests + + func testProvideItems() async { + let request = TestProviderRequest() + + stub(condition: { _ in true }) { _ in + fixture(filePath: self.itemsPath, headers: nil) + } + + let result: Result<[TestItem], ProviderError> = await provider.asyncProvideItems(request: request) + + switch result { + case let .success(items): + XCTAssertEqual(items.count, 3) + case let .failure(error): + XCTFail("There should be no error: \(error)") + } + } + + func testProvideItemsReturnsPartialResponseUponFailure() async { + let request = TestProviderRequest() + + let originalStub = stub(condition: { _ in true }) { _ in + fixture(filePath: self.itemsPath, headers: nil) + } + + let _ : Result<[TestItem], ProviderError> = await provider.asyncProvideItems(request: request) + + try? self.provider.cache?.remove(forKey: "Hello 2") + HTTPStubs.removeStub(originalStub) + + stub(condition: { _ in true}) { _ in + fixture(filePath: self.itemPath, headers: nil) + } + + let secondResult: Result<[TestItem], ProviderError> = await provider.asyncProvideItems(request: request) + + switch secondResult { + case .success: + XCTFail("Should have received a partial retrieval failure.") + case let .failure(error): + switch error { + case let .partialRetrieval(retrievedItems, persistenceErrors, error): + let expectedItemIDs = ["Hello 1", "Hello 3"] + + XCTAssertEqual(retrievedItems.map { $0.identifier }, expectedItemIDs) + XCTAssertEqual(persistenceErrors.count, 1) + XCTAssertEqual(persistenceErrors.first?.key, "Hello 2") + + guard case ProviderError.decodingError = error else { + XCTFail("Incorrect error received.") + return + } + + guard let persistenceError = persistenceErrors.first?.persistenceError, case PersistenceError.noValidDataForKey = persistenceError else { + XCTFail("Incorrect error received.") + return + } + + default: XCTFail("Should have received a partial retrieval error.") + } + } + } + + func testProvideItemsDoesNotReturnPartialResponseUponFailureForExpiredItems() async { + let request = TestProviderRequest() + + let originalStub = stub(condition: { _ in true }) { _ in + fixture(filePath: self.itemsPath, headers: nil) + } + + let _ : Result<[TestItem], ProviderError> = await expiredProvider.asyncProvideItems(request: request) + + try? self.expiredProvider.cache?.remove(forKey: "Hello 2") + HTTPStubs.removeStub(originalStub) + + stub(condition: { _ in true}) { _ in + fixture(filePath: self.itemPath, headers: nil) + } + + let expiredResult : Result<[TestItem], ProviderError> = await expiredProvider.asyncProvideItems(request: request) + + switch expiredResult { + case .success: + XCTFail("Should have received a decoding error.") + case let .failure(error): + switch error { + case .decodingError: break + default: XCTFail("Should have received a decoding error.") + } + } + } + + func testProvideItemsFailure() async { + let request = TestProviderRequest() + + stub(condition: { _ in true }) { _ in + fixture(filePath: OHPathForFile("InvalidItems.json", type(of: self))!, headers: nil) + } + + let result : Result<[TestItem], ProviderError> = await provider.asyncProvideItems(request: request) + switch result { + case .success: + XCTFail("There should be an error.") + case .failure: break + } + } + + // MARK: - Async Provide Item Tests + + func testProvideItem() async { + let request = TestProviderRequest() + + stub(condition: { _ in true }) { _ in + fixture(filePath: self.itemPath, headers: nil) + } + + let result: Result = await provider.asyncProvide(request: request) + + switch result { + case .success: break + case let .failure(error): + XCTFail("There should be no error: \(error)") + } + } + + func testProvideItemReturnsCachedResult() async { + let request = TestProviderRequest() + + let originalStub = stub(condition: { _ in true }) { _ in + fixture(filePath: self.itemPath, headers: nil) + } + + let result: Result = await provider.asyncProvide(request: request) + + switch result { + case .success: + HTTPStubs.removeStub(originalStub) + + stub(condition: { _ in true }) { _ in + fixture(filePath: OHPathForFile("InvalidItem.json", type(of: self))!, headers: nil) + } + + let result: Result = await provider.asyncProvide(request: request) + switch result { + case .success: + break + case let .failure(error): + XCTFail("There should be no error: \(error)") + } + + case let .failure(error): + XCTFail("There should be no error: \(error)") + } + } + + func testProvideItemFailure() async { + let request = TestProviderRequest() + + stub(condition: { _ in true }) { _ in + fixture(filePath: OHPathForFile("InvalidItem.json", type(of: self))!, headers: nil) + } + + let result: Result = await provider.asyncProvide(request: request) + switch result { + case .success: + XCTFail("There should be an error.") + case .failure: break + } + } + + func testAsyncProvideItemsWithCustomDecoder() async { + let request = TestProviderRequest() + + stub(condition: { _ in true }) { _ in + fixture(filePath: self.datesPath, headers: nil) + } + + // Test first to ensure failure when not providing a custom decoder. + let result1: Result<[TestDateContainer], ProviderError> = await provider.asyncProvideItems(request: request) + + switch result1 { + case .success: + XCTFail("Decoding should fail due to incorrect date format") + case let .failure(error): + switch error { + case .decodingError: + break + default: + XCTFail("An unexpected, non-decoding error occurred: \(error)") + } + } + + // Now test the same file with our custom decoder. + let customDecoder = JSONDecoder() + customDecoder.dateDecodingStrategy = .iso8601 + let result2: Result<[TestDateContainer], ProviderError> = await provider.asyncProvideItems(request: request, decoder: customDecoder) + + switch result2 { + case let .success(dateContainers): + XCTAssertEqual(dateContainers.count, 2) + case let .failure(error): + XCTFail("An unexpected error occurred: \(error)") + } + } + // MARK: - Async Provide Items Async Stream Tests func testProvideItemsCacheElseNetworkProvider() async {