From 2a35a6047b3f824fef63592a53abd38b3796ddb3 Mon Sep 17 00:00:00 2001 From: Michael Amundsen Date: Tue, 1 Jul 2025 14:30:20 -0400 Subject: [PATCH 1/8] Revert "removed error prone code" This reverts commit 579a3fa7be1d803926028da97f00061d44ed5325. Signed-off-by: Michael Amundsen # Conflicts: # Tests/ItemProviderTest+Async.swift --- Sources/Provider/ItemProvider.swift | 24 ++++++++++++++++++++++++ Sources/Provider/Provider.swift | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/Sources/Provider/ItemProvider.swift b/Sources/Provider/ItemProvider.swift index ea3be9b..3a59893 100644 --- a/Sources/Provider/ItemProvider.swift +++ b/Sources/Provider/ItemProvider.swift @@ -259,6 +259,22 @@ extension ItemProvider: Provider { .eraseToAnyPublisher() } + public func asyncProvide(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result { + await withCheckedContinuation { continuation in + provide(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { result in + continuation.resume(returning: result) + } + } + } + + public func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result<[Item], ProviderError> { + await withCheckedContinuation { continuation in + provideItems(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { result in + continuation.resume(returning: result) + } + } + } + public func asyncProvide(request: any ProviderRequest, decoder: any ItemDecoder = JSONDecoder(), providerBehaviors: [any ProviderBehavior] = [], requestBehaviors: [any Networking.RequestBehavior] = []) async -> AsyncStream> where Item : Identifiable, Item : Decodable, Item : Encodable { return AsyncStream { [weak self] continuation in self?.provide(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors, allowExpiredItem: false) { (result: Result) in @@ -321,6 +337,14 @@ extension ItemProvider { return ItemProvider(networkRequestPerformer: NetworkController(), cache: persister, fetchPolicy: fetchPolicy, defaultProviderBehaviors: []) } + + public static func configuredStreamingProvider(withRootPersistenceURL persistenceURL: URL = FileManager.default.applicationSupportDirectoryURL, memoryCacheCapacity: CacheCapacity = .limited(numberOfItems: 100), fetchPolicy: ItemProvider.FetchPolicy = .returnFromCacheAndNetwork) -> ItemProvider { + let memoryCache = MemoryCache(capacity: memoryCacheCapacity) + let diskCache = DiskCache(rootDirectoryURL: persistenceURL) + let persister = Persister(memoryCache: memoryCache, diskCache: diskCache) + + return ItemProvider(networkRequestPerformer: NetworkController(), cache: persister, fetchPolicy: fetchPolicy, defaultProviderBehaviors: []) + } } extension FileManager { diff --git a/Sources/Provider/Provider.swift b/Sources/Provider/Provider.swift index adee090..169dade 100644 --- a/Sources/Provider/Provider.swift +++ b/Sources/Provider/Provider.swift @@ -61,6 +61,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> + /// Returns a stream of a single item. /// - Parameters: /// - request: The request that provides the details needed to retrieve the items from persistence or networking. From 63844c8263a519823d213b2c94089ce76d423334 Mon Sep 17 00:00:00 2001 From: Michael Amundsen Date: Tue, 1 Jul 2025 14:46:07 -0400 Subject: [PATCH 2/8] Adding deprecation messages Signed-off-by: Michael Amundsen --- Sources/Provider/ItemProvider.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Provider/ItemProvider.swift b/Sources/Provider/ItemProvider.swift index 3a59893..08f42dc 100644 --- a/Sources/Provider/ItemProvider.swift +++ b/Sources/Provider/ItemProvider.swift @@ -259,6 +259,7 @@ extension ItemProvider: Provider { .eraseToAnyPublisher() } + @available(*, deprecated, message: "This API does not work with `FetchPolicy.returnFromCacheAndNetwork` and will crash. 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 provide(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { result in @@ -267,6 +268,8 @@ extension ItemProvider: Provider { } } + @available(*, deprecated, message: "This API does not work with `FetchPolicy.returnFromCacheAndNetwork` and will crash. Please transition over to `AsyncStream` version of `func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> AsyncStream>` instead.") + @available(*, deprecated, message: "Parse your data by hand instead") public func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result<[Item], ProviderError> { await withCheckedContinuation { continuation in provideItems(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { result in From 32ca30ea203a43e03a5b21029c36d7de37cdaf94 Mon Sep 17 00:00:00 2001 From: Michael Amundsen Date: Tue, 1 Jul 2025 14:46:59 -0400 Subject: [PATCH 3/8] Fixing message Signed-off-by: Michael Amundsen --- Sources/Provider/ItemProvider.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Provider/ItemProvider.swift b/Sources/Provider/ItemProvider.swift index 08f42dc..6c760f0 100644 --- a/Sources/Provider/ItemProvider.swift +++ b/Sources/Provider/ItemProvider.swift @@ -269,7 +269,6 @@ extension ItemProvider: Provider { } @available(*, deprecated, message: "This API does not work with `FetchPolicy.returnFromCacheAndNetwork` and will crash. Please transition over to `AsyncStream` version of `func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> AsyncStream>` instead.") - @available(*, deprecated, message: "Parse your data by hand instead") public func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result<[Item], ProviderError> { await withCheckedContinuation { continuation in provideItems(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { result in From ea75b5ced0e906362ed7170c1450cef41cd2b74b Mon Sep 17 00:00:00 2001 From: Michael Amundsen Date: Tue, 1 Jul 2025 14:50:50 -0400 Subject: [PATCH 4/8] Restoring tests for deprecated APIs Signed-off-by: Michael Amundsen --- Tests/ItemProviderTest+Async.swift | 204 +++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/Tests/ItemProviderTest+Async.swift b/Tests/ItemProviderTest+Async.swift index 36f2128..0279dec 100644 --- a/Tests/ItemProviderTest+Async.swift +++ b/Tests/ItemProviderTest+Async.swift @@ -39,6 +39,210 @@ final class ItemProviderTests_Async: XCTestCase { try? expiredProvider.cache?.removeAll() } + 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 testProvideItemsStream() async { From 5fbb08555d2ad12ab9942aa777824eae2965164f Mon Sep 17 00:00:00 2001 From: Michael Amundsen Date: Tue, 1 Jul 2025 14:52:34 -0400 Subject: [PATCH 5/8] removing code Signed-off-by: Michael Amundsen --- Sources/Provider/ItemProvider.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Sources/Provider/ItemProvider.swift b/Sources/Provider/ItemProvider.swift index 6c760f0..39cbea5 100644 --- a/Sources/Provider/ItemProvider.swift +++ b/Sources/Provider/ItemProvider.swift @@ -339,14 +339,6 @@ extension ItemProvider { return ItemProvider(networkRequestPerformer: NetworkController(), cache: persister, fetchPolicy: fetchPolicy, defaultProviderBehaviors: []) } - - public static func configuredStreamingProvider(withRootPersistenceURL persistenceURL: URL = FileManager.default.applicationSupportDirectoryURL, memoryCacheCapacity: CacheCapacity = .limited(numberOfItems: 100), fetchPolicy: ItemProvider.FetchPolicy = .returnFromCacheAndNetwork) -> ItemProvider { - let memoryCache = MemoryCache(capacity: memoryCacheCapacity) - let diskCache = DiskCache(rootDirectoryURL: persistenceURL) - let persister = Persister(memoryCache: memoryCache, diskCache: diskCache) - - return ItemProvider(networkRequestPerformer: NetworkController(), cache: persister, fetchPolicy: fetchPolicy, defaultProviderBehaviors: []) - } } extension FileManager { From 06cdcb9949041823520bcb3c4116f5d37b41f864 Mon Sep 17 00:00:00 2001 From: Michael Amundsen Date: Tue, 1 Jul 2025 15:30:44 -0400 Subject: [PATCH 6/8] Enforcing single call Signed-off-by: Michael Amundsen --- Sources/Provider/ItemProvider.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/Provider/ItemProvider.swift b/Sources/Provider/ItemProvider.swift index 39cbea5..1df1a5b 100644 --- a/Sources/Provider/ItemProvider.swift +++ b/Sources/Provider/ItemProvider.swift @@ -262,18 +262,28 @@ extension ItemProvider: Provider { @available(*, deprecated, message: "This API does not work with `FetchPolicy.returnFromCacheAndNetwork` and will crash. 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 - provide(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { result 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 crash. 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 - provideItems(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { result 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) } } From 4cecb5743602c2caf8bd579c58cd5373f2fce972 Mon Sep 17 00:00:00 2001 From: Michael Amundsen Date: Tue, 1 Jul 2025 15:31:44 -0400 Subject: [PATCH 7/8] Updating message Signed-off-by: Michael Amundsen --- Sources/Provider/ItemProvider.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Provider/ItemProvider.swift b/Sources/Provider/ItemProvider.swift index 1df1a5b..c739700 100644 --- a/Sources/Provider/ItemProvider.swift +++ b/Sources/Provider/ItemProvider.swift @@ -259,7 +259,7 @@ extension ItemProvider: Provider { .eraseToAnyPublisher() } - @available(*, deprecated, message: "This API does not work with `FetchPolicy.returnFromCacheAndNetwork` and will crash. Please transition over to `AsyncStream` version of `func asyncProvide(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> AsyncStream>` instead.") + @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? @@ -273,7 +273,7 @@ extension ItemProvider: Provider { } } - @available(*, deprecated, message: "This API does not work with `FetchPolicy.returnFromCacheAndNetwork` and will crash. Please transition over to `AsyncStream` version of `func asyncProvideItems(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> AsyncStream>` instead.") + @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? From 59a1cd0890d057ceb75f933062af09677fbec807 Mon Sep 17 00:00:00 2001 From: Michael Amundsen Date: Tue, 1 Jul 2025 15:36:49 -0400 Subject: [PATCH 8/8] Restoring title Signed-off-by: Michael Amundsen --- Tests/ItemProviderTest+Async.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/ItemProviderTest+Async.swift b/Tests/ItemProviderTest+Async.swift index 0279dec..3fff3ac 100644 --- a/Tests/ItemProviderTest+Async.swift +++ b/Tests/ItemProviderTest+Async.swift @@ -39,6 +39,8 @@ final class ItemProviderTests_Async: XCTestCase { try? expiredProvider.cache?.removeAll() } + // MARK: - Async Provide Items Tests + func testProvideItems() async { let request = TestProviderRequest()