Skip to content

Commit

Permalink
Merge pull request #17 from Lickability/update/async-await
Browse files Browse the repository at this point in the history
Updates Public APIs to use async/await
  • Loading branch information
Cordavi authored Jan 17, 2024
2 parents 6b38c07 + 5ac4059 commit 095ab9e
Show file tree
Hide file tree
Showing 9 changed files with 379 additions and 68 deletions.
22 changes: 21 additions & 1 deletion Provider.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
archiveVersion = 1;
classes = {
};
objectVersion = 52;
objectVersion = 54;
objects = {

/* Begin PBXBuildFile section */
2E19FDC62B5719B20026925A /* ItemProviderTest+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19FDC52B5719B20026925A /* ItemProviderTest+Async.swift */; };
2E19FDC82B5720580026925A /* FileManager+CachesDirectoryURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19FDC72B5720580026925A /* FileManager+CachesDirectoryURL.swift */; };
2E19FDCA2B57208F0026925A /* TestProviderRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19FDC92B57208F0026925A /* TestProviderRequest.swift */; };
2E19FDCC2B57209C0026925A /* TestPostProviderRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19FDCB2B57209C0026925A /* TestPostProviderRequest.swift */; };
2E19FDCE2B5720E70026925A /* TestItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19FDCD2B5720E70026925A /* TestItem.swift */; };
4C04A60624E6EEBA00D73E0E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C04A60524E6EEBA00D73E0E /* AppDelegate.swift */; };
4C04A60824E6EEBA00D73E0E /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C04A60724E6EEBA00D73E0E /* SceneDelegate.swift */; };
4C04A60A24E6EEBA00D73E0E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C04A60924E6EEBA00D73E0E /* ContentView.swift */; };
Expand Down Expand Up @@ -43,6 +48,11 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
2E19FDC52B5719B20026925A /* ItemProviderTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemProviderTest+Async.swift"; sourceTree = "<group>"; };
2E19FDC72B5720580026925A /* FileManager+CachesDirectoryURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+CachesDirectoryURL.swift"; sourceTree = "<group>"; };
2E19FDC92B57208F0026925A /* TestProviderRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestProviderRequest.swift; sourceTree = "<group>"; };
2E19FDCB2B57209C0026925A /* TestPostProviderRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestPostProviderRequest.swift; sourceTree = "<group>"; };
2E19FDCD2B5720E70026925A /* TestItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestItem.swift; sourceTree = "<group>"; };
4C04A60224E6EEBA00D73E0E /* Provider.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Provider.app; sourceTree = BUILT_PRODUCTS_DIR; };
4C04A60524E6EEBA00D73E0E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
4C04A60724E6EEBA00D73E0E /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -122,6 +132,11 @@
children = (
F27A5796250BF32D00FBAD8F /* Test JSON */,
4C04A61C24E6EEBC00D73E0E /* ItemProviderTests.swift */,
2E19FDC52B5719B20026925A /* ItemProviderTest+Async.swift */,
2E19FDC72B5720580026925A /* FileManager+CachesDirectoryURL.swift */,
2E19FDC92B57208F0026925A /* TestProviderRequest.swift */,
2E19FDCB2B57209C0026925A /* TestPostProviderRequest.swift */,
2E19FDCD2B5720E70026925A /* TestItem.swift */,
4C04A61E24E6EEBC00D73E0E /* Info.plist */,
);
path = Tests;
Expand Down Expand Up @@ -328,7 +343,12 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2E19FDCA2B57208F0026925A /* TestProviderRequest.swift in Sources */,
2E19FDC62B5719B20026925A /* ItemProviderTest+Async.swift in Sources */,
2E19FDCE2B5720E70026925A /* TestItem.swift in Sources */,
2E19FDCC2B57209C0026925A /* TestPostProviderRequest.swift in Sources */,
4C04A61D24E6EEBC00D73E0E /* ItemProviderTests.swift in Sources */,
2E19FDC82B5720580026925A /* FileManager+CachesDirectoryURL.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
18 changes: 18 additions & 0 deletions Sources/Provider/ItemProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ extension ItemProvider: Provider {

// MARK: - Provider

@discardableResult
public func provide<Item: Providable>(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = [], handlerQueue: DispatchQueue = .main, allowExpiredItem: Bool = false, itemHandler: @escaping (Result<Item, ProviderError>) -> Void) -> AnyCancellable? {

var cancellable: AnyCancellable?
Expand All @@ -80,6 +81,7 @@ extension ItemProvider: Provider {
return cancellable
}

@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?
Expand Down Expand Up @@ -246,6 +248,22 @@ extension ItemProvider: Provider {
.eraseToAnyPublisher()
}

public func asyncProvide<Item: Providable>(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result<Item, ProviderError> {
await withCheckedContinuation { continuation in
provide(request: request, decoder: decoder, providerBehaviors: providerBehaviors, requestBehaviors: requestBehaviors) { result in
continuation.resume(returning: result)
}
}
}

public func asyncProvideItems<Item: Providable>(request: any ProviderRequest, decoder: ItemDecoder = JSONDecoder(), providerBehaviors: [ProviderBehavior] = [], requestBehaviors: [RequestBehavior] = []) async -> Result<[Item], ProviderError> {
await withCheckedContinuation { continuation in
provideItems(request: request, providerBehaviors: providerBehaviors) { result in
continuation.resume(returning: result)
}
}
}

private func itemsCachePublisher<Item: Providable>(for request: any ProviderRequest) -> Result<CacheItemsResponse<Item>?, ProviderError>.Publisher {
let cachePublisher: Result<CacheItemsResponse<Item>?, ProviderError>.Publisher

Expand Down
18 changes: 18 additions & 0 deletions Sources/Provider/Provider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,22 @@ public protocol Provider {
/// - 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.
/// - 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>
}
17 changes: 17 additions & 0 deletions Tests/FileManager+CachesDirectoryURL.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// FileManager+CachesDirectoryURL.swift
// ProviderTests
//
// Created by Ashli Rankin on 1/16/24.
// Copyright © 2024 Lickability. All rights reserved.
//

import Foundation

extension FileManager {

/// The caches directory `URL`.
var cachesDirectoryURL: URL! { //swiftlint:disable:this implicitly_unwrapped_optional
return urls(for: .cachesDirectory, in: .userDomainMask).first
}
}
211 changes: 211 additions & 0 deletions Tests/ItemProviderTest+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//
// ItemProviderTests+Async.swift
// ProviderTests
//
// Created by Ashli Rankin on 1/16/24.
// Copyright © 2024 Lickability. All rights reserved.
//

import Combine
import XCTest
import OHHTTPStubs
import OHHTTPStubsSwift

import Networking
import Persister

@testable import Provider

final class ItemProviderTests_Async: XCTestCase {

private let provider = ItemProvider.configuredProvider(withRootPersistenceURL: FileManager.default.cachesDirectoryURL, memoryCacheCapacity: .unlimited)
private let expiredProvider: ItemProvider = {
let networkController = NetworkController(urlSession: .shared, defaultRequestBehaviors: [])
let cache = Persister(memoryCache: MemoryCache(capacity: .unlimited, expirationPolicy: .afterInterval(-1)), diskCache: DiskCache(rootDirectoryURL: FileManager.default.cachesDirectoryURL, expirationPolicy: .afterInterval(-1)))

return ItemProvider(networkRequestPerformer: networkController, cache: cache)
}()

private var cancellables = Set<AnyCancellable>()
private lazy var itemPath = OHPathForFile("Item.json", type(of: self))!
private lazy var itemsPath = OHPathForFile("Items.json", type(of: self))!

override func tearDown() {
HTTPStubs.removeAllStubs()
try? provider.cache?.removeAll()
try? expiredProvider.cache?.removeAll()

super.tearDown()
}

// 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
}
}
}
Loading

0 comments on commit 095ab9e

Please sign in to comment.