Skip to content

Commit

Permalink
added async await support
Browse files Browse the repository at this point in the history
  • Loading branch information
mbuchetics committed Apr 6, 2022
1 parent 66ddae1 commit 21e9006
Show file tree
Hide file tree
Showing 6 changed files with 339 additions and 5 deletions.
107 changes: 107 additions & 0 deletions Sources/Fetch/Extensions/Fetch+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// Fetch+Async.swift
// Fetch+Async
//
// Created by Matthias Buchetics on 01.09.21.
// Copyright © 2021 aaa - all about apps GmbH. All rights reserved.
//

#if swift(>=5.5.2)

import Foundation

@available(macOS 12, iOS 13, tvOS 15, watchOS 8, *)
public extension Resource {

enum ForwardBehaviour {
case firstValue
case waitForFinishedValue
}

func requestAsync() async throws -> NetworkResponse<T> {
var requestToken: RequestToken?

return try await withTaskCancellationHandler {
try Task.checkCancellation()

return try await withCheckedThrowingContinuation { (continuation) in
requestToken = self.request(queue: .asyncCompletionQueue) { (result) in
switch result {
case let .success(response):
continuation.resume(returning: response)
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
} onCancel: { [requestToken] in
requestToken?.cancel() // runs immediately when cancelled
}
}

func fetchAsync(cachePolicy: CachePolicy? = nil, behaviour: ForwardBehaviour = .firstValue) async throws -> (FetchResponse<T>, Bool) where T: Cacheable {
var requestToken: RequestToken?

return try await withTaskCancellationHandler {
try Task.checkCancellation()

return try await withCheckedThrowingContinuation { (continuation) in
var hasSendOneValue = false
requestToken = self.fetch(cachePolicy: cachePolicy, queue: .asyncCompletionQueue) { (result, isFinished) in
guard !hasSendOneValue else { return }

switch result {
case let .success(response):

let sendValue = {
continuation.resume(returning: (response, isFinished))
hasSendOneValue = true
}

switch (behaviour, isFinished) {
case (.firstValue, _):
sendValue()
case (.waitForFinishedValue, true):
sendValue()
default:
break
}

case let .failure(error):
continuation.resume(throwing: error)
}
}
}
} onCancel: { [requestToken] in
requestToken?.cancel() // runs immediately when cancelled
}
}

func fetchAsyncSequence(cachePolicy: CachePolicy? = nil) -> AsyncThrowingStream<FetchResponse<T>, Error> where T: Cacheable {
return AsyncThrowingStream<FetchResponse<T>, Error> { continuation in

let requestToken = self.fetch(cachePolicy: cachePolicy, queue: .main) { (result, isFinished) in
switch result {
case let .success(response):
continuation.yield(response)
if isFinished {
continuation.finish(throwing: nil)
}
case let .failure(error):
continuation.finish(throwing: error)
}
}

continuation.onTermination = { @Sendable termination in
switch termination {
case .cancelled:
requestToken.cancel()
default:
break
}
}
}
}
}

#endif
File renamed without changes.
9 changes: 6 additions & 3 deletions Sources/Fetch/Network/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
import Foundation
import Alamofire

extension DispatchQueue {
static let asyncCompletionQueue = DispatchQueue(label: "at.allaboutapps.fetch.asyncCompletionQueue", attributes: .concurrent)
static let decodingQueue = DispatchQueue(label: "at.allaboutapps.fetch.decodingQueue")
}

/// A configuration object used to setup an `APIClient`
public struct Config {

Expand Down Expand Up @@ -128,8 +133,6 @@ open class APIClient {
StubbedURL.stubProvider = _config?.stubProvider
}

let decodingQueue = DispatchQueue(label: "at.allaboutapps.fetch.decodingQueue")

/// Configures an `APIClient` with the given `config`
///
/// - Parameter config: used to setup the `APIClient`
Expand Down Expand Up @@ -195,7 +198,7 @@ open class APIClient {

dataRequest
.validate() // Validate response (status codes + content types)
.responseData(queue: self.decodingQueue, completionHandler: { (dataResponse) in
.responseData(queue: DispatchQueue.decodingQueue, completionHandler: { (dataResponse) in
// Map and decode Data to Object
let decodedResponse = dataResponse.tryMap { (data) throws -> T in
if T.self == IgnoreBody.self {
Expand Down
4 changes: 2 additions & 2 deletions Sources/Fetch/Network/Resource+Fetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ public extension Resource where T: Cacheable {
}

private func requestAndUpdateCache(cache: Cache?, compareWith cached: T? = nil, queue: DispatchQueue, completion: ((Swift.Result<FetchResponse<T>, FetchError>, Bool) -> Void)?) -> RequestToken {
return apiClient.request(self, queue: apiClient.decodingQueue) { (result) in
return apiClient.request(self, queue: DispatchQueue.decodingQueue) { (result) in
if let cache = cache, let data = try? result.get().model {
do {
try cache.set(data, for: self)
Expand All @@ -172,7 +172,7 @@ public extension Resource where T: Cacheable {
private func readCacheAsync(queue: DispatchQueue, completion: @escaping (CacheEntry<T>?) -> Void) -> RequestToken {
let token = RequestToken()

apiClient.decodingQueue.async {
DispatchQueue.decodingQueue.async {
queue.async {
if !token.isCancelled {
if let entry: CacheEntry<T> = try? self.cache?.get(for: self) {
Expand Down
136 changes: 136 additions & 0 deletions Tests/FetchTests/Async Await/AsyncCacheTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
@testable
import Fetch
import XCTest

#if swift(>=5.5.2)

@available(macOS 12, iOS 13, tvOS 15, watchOS 8, *)
class AsyncCacheTests: XCTestCase {

private(set) var client: APIClient!
private var cache: Cache!

override func setUp() {
super.setUp()
cache = createCache()
client = createAPIClient()
}

func createCache() -> Cache {
return MemoryCache(defaultExpiration: .seconds(10.0))
}

func createAPIClient() -> APIClient {
let config = Config(
baseURL: URL(string: "https://www.asdf.at")!,
cache: cache,
shouldStub: true
)

return APIClient(config: config)
}

func testCacheWithFirstValue() {
let resource = Resource<ModelA>(
apiClient: client,
method: .get,
path: "/test/detail",
cachePolicy: .cacheFirstNetworkAlways)

let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1)
client.stubProvider.register(stub: stub, for: resource)

try! resource.cache?.set(ModelA(a: "123"), for: resource)

let expectation = self.expectation(description: "")
Task {

do {
let (result, isFinished) = try await resource.fetchAsync(cachePolicy: nil)
XCTAssertEqual(isFinished, false)

switch result {
case let .cache(value, _):
XCTAssert(value == ModelA(a: "123"), "first value should be from cache")
case .network:
XCTFail("should never return network response")

}
expectation.fulfill()
} catch {
XCTFail("should suceed")
}

}
waitForExpectations(timeout: 10, handler: nil)
}

func testCacheWithFinishedValue() {
let resource = Resource<ModelA>(
apiClient: client,
method: .get,
path: "/test/detail",
cachePolicy: .cacheFirstNetworkAlways)

let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1)
client.stubProvider.register(stub: stub, for: resource)

try! resource.cache?.set(ModelA(a: "123"), for: resource)

let expectation = self.expectation(description: "")
Task {

do {
let (result, isFinished) = try await resource.fetchAsync(behaviour: .waitForFinishedValue)
XCTAssertEqual(isFinished, true)

switch result {
case .cache:
XCTFail("should wait for network")
case let .network(_, updated):
XCTAssertEqual(updated, false)

}
expectation.fulfill()
} catch {
XCTFail("should suceed")
}

}
waitForExpectations(timeout: 10, handler: nil)
}

func testCacheWithAsyncSequence() {
let resource = Resource<ModelA>(
apiClient: client,
method: .get,
path: "/test/detail",
cachePolicy: .cacheFirstNetworkAlways)

let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1)
client.stubProvider.register(stub: stub, for: resource)

try! resource.cache?.set(ModelA(a: "1234"), for: resource)

let expectation = self.expectation(description: "")
Task {

do {
var results = [ModelA]()
for try await result in resource.fetchAsyncSequence() {
results.append(result.model)
}
XCTAssert(results.count == 2, "Should send exactly 2 values")
XCTAssertEqual(results[0].a, "1234", "first result should be from cache")
XCTAssertEqual(results[1].a, "123", "first result should be from network")
expectation.fulfill()
} catch {
XCTFail("should suceed")
}

}
waitForExpectations(timeout: 10, handler: nil)
}
}

#endif
88 changes: 88 additions & 0 deletions Tests/FetchTests/Async Await/AsyncTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import XCTest
import Alamofire
import Fetch

#if swift(>=5.5.2)

@available(macOS 12, iOS 13, tvOS 15, watchOS 8, *)
class AsyncTests: XCTestCase {

override func setUp() {
APIClient.shared.setup(with: Config(
baseURL: URL(string: "https://www.asdf.at")!,
shouldStub: true
))
}

func testSuccessfulStubbingOfDecodable() {
let expectation = self.expectation(description: "Fetch model")
let resource = Resource<ModelA>(
method: .get,
path: "/test")

let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1)
APIClient.shared.stubProvider.register(stub: stub, for: resource)

Task {
do {
let result = try await resource.requestAsync()
XCTAssertEqual(result.model.a, "a")
expectation.fulfill()
} catch {
XCTFail("Request did not return value")
}
}
waitForExpectations(timeout: 5, handler: nil)
}

func testFailingRequest() {
let expectation = self.expectation(description: "Fetch model")
let resource = Resource<ModelA>(
method: .get,
path: "/test")

let stub = StubResponse(statusCode: 400, encodable: ModelA(a: "a"), delay: 0.1)
APIClient.shared.stubProvider.register(stub: stub, for: resource)

Task {
do {
let _ = try await resource.requestAsync()
XCTFail("Request should not succeed")
} catch {
expectation.fulfill()
}
}
waitForExpectations(timeout: 5, handler: nil)
}

func testRequestTokenCanCancelRequest() {
let expectation = self.expectation(description: "T")
let resource = Resource<ModelA>(
method: .get,
path: "/test")

let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.2)
APIClient.shared.stubProvider.register(stub: stub, for: resource)

let task = Task {
do {
_ = try await resource.requestAsync()
XCTFail("Request should be cancelled")
} catch is CancellationError {
print("cancelled")
} catch {
XCTFail("Request should be cancelled")
}
}
task.cancel()

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + stub.delay + 0.1) {
expectation.fulfill()
}

waitForExpectations(timeout: 10, handler: nil)
}

}

#endif

0 comments on commit 21e9006

Please sign in to comment.