Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions Sources/Provider/ItemProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ extension ItemProvider: Provider {
case .finished:
break
}

self?.removeCancellable(cancellable: cancellable)
}, receiveValue: { (item: Item) in
itemHandler(.success(item))
Expand All @@ -97,7 +96,6 @@ extension ItemProvider: Provider {
}

@discardableResult public func provideItems<Item: Providable>(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,
Expand All @@ -112,7 +110,6 @@ extension ItemProvider: Provider {
case .finished:
break
}

self?.removeCancellable(cancellable: cancellable)
}, receiveValue: { (items: [Item]) in
itemsHandler(.success(items))
Expand Down Expand Up @@ -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<Item: Providable>(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> AsyncStream<Result<Item, ProviderError>>` instead.")
public func asyncProvide<Item: Providable>(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result<Item, ProviderError> {
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<Item: Providable>(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> AsyncStream<Result<[Item], ProviderError>>` instead.")
public func asyncProvideItems<Item: Providable>(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<Item: Providable>(request: any ProviderRequest, decoder: any ItemDecoder = JSONDecoder(), providerBehaviors: [any ProviderBehavior] = [], requestBehaviors: [any Networking.RequestBehavior] = [], allowExpiredItem: Bool = false) async -> AsyncStream<Result<Item, ProviderError>> {
return AsyncStream { [weak self] continuation in
var cancellable: AnyCancellable?
Expand Down
18 changes: 18 additions & 0 deletions Sources/Provider/Provider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item: Providable>(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<Item: Providable>(request: any ProviderRequest, decoder: ItemDecoder, providerBehaviors: [ProviderBehavior], requestBehaviors: [RequestBehavior]) async -> Result<Item, ProviderError>

/// 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<Item: Providable>(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.
Expand Down
206 changes: 206 additions & 0 deletions Tests/ItemProviderTest+Async.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestItem, ProviderError> = 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<TestItem, ProviderError> = 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<TestItem, ProviderError> = 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<TestItem, ProviderError> = 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 {
Expand Down