Skip to content

Commit 21e9006

Browse files
committed
added async await support
1 parent 66ddae1 commit 21e9006

File tree

6 files changed

+339
-5
lines changed

6 files changed

+339
-5
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//
2+
// Fetch+Async.swift
3+
// Fetch+Async
4+
//
5+
// Created by Matthias Buchetics on 01.09.21.
6+
// Copyright © 2021 aaa - all about apps GmbH. All rights reserved.
7+
//
8+
9+
#if swift(>=5.5.2)
10+
11+
import Foundation
12+
13+
@available(macOS 12, iOS 13, tvOS 15, watchOS 8, *)
14+
public extension Resource {
15+
16+
enum ForwardBehaviour {
17+
case firstValue
18+
case waitForFinishedValue
19+
}
20+
21+
func requestAsync() async throws -> NetworkResponse<T> {
22+
var requestToken: RequestToken?
23+
24+
return try await withTaskCancellationHandler {
25+
try Task.checkCancellation()
26+
27+
return try await withCheckedThrowingContinuation { (continuation) in
28+
requestToken = self.request(queue: .asyncCompletionQueue) { (result) in
29+
switch result {
30+
case let .success(response):
31+
continuation.resume(returning: response)
32+
case let .failure(error):
33+
continuation.resume(throwing: error)
34+
}
35+
}
36+
}
37+
} onCancel: { [requestToken] in
38+
requestToken?.cancel() // runs immediately when cancelled
39+
}
40+
}
41+
42+
func fetchAsync(cachePolicy: CachePolicy? = nil, behaviour: ForwardBehaviour = .firstValue) async throws -> (FetchResponse<T>, Bool) where T: Cacheable {
43+
var requestToken: RequestToken?
44+
45+
return try await withTaskCancellationHandler {
46+
try Task.checkCancellation()
47+
48+
return try await withCheckedThrowingContinuation { (continuation) in
49+
var hasSendOneValue = false
50+
requestToken = self.fetch(cachePolicy: cachePolicy, queue: .asyncCompletionQueue) { (result, isFinished) in
51+
guard !hasSendOneValue else { return }
52+
53+
switch result {
54+
case let .success(response):
55+
56+
let sendValue = {
57+
continuation.resume(returning: (response, isFinished))
58+
hasSendOneValue = true
59+
}
60+
61+
switch (behaviour, isFinished) {
62+
case (.firstValue, _):
63+
sendValue()
64+
case (.waitForFinishedValue, true):
65+
sendValue()
66+
default:
67+
break
68+
}
69+
70+
case let .failure(error):
71+
continuation.resume(throwing: error)
72+
}
73+
}
74+
}
75+
} onCancel: { [requestToken] in
76+
requestToken?.cancel() // runs immediately when cancelled
77+
}
78+
}
79+
80+
func fetchAsyncSequence(cachePolicy: CachePolicy? = nil) -> AsyncThrowingStream<FetchResponse<T>, Error> where T: Cacheable {
81+
return AsyncThrowingStream<FetchResponse<T>, Error> { continuation in
82+
83+
let requestToken = self.fetch(cachePolicy: cachePolicy, queue: .main) { (result, isFinished) in
84+
switch result {
85+
case let .success(response):
86+
continuation.yield(response)
87+
if isFinished {
88+
continuation.finish(throwing: nil)
89+
}
90+
case let .failure(error):
91+
continuation.finish(throwing: error)
92+
}
93+
}
94+
95+
continuation.onTermination = { @Sendable termination in
96+
switch termination {
97+
case .cancelled:
98+
requestToken.cancel()
99+
default:
100+
break
101+
}
102+
}
103+
}
104+
}
105+
}
106+
107+
#endif

Sources/Fetch/Network/APIClient.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
import Foundation
1010
import Alamofire
1111

12+
extension DispatchQueue {
13+
static let asyncCompletionQueue = DispatchQueue(label: "at.allaboutapps.fetch.asyncCompletionQueue", attributes: .concurrent)
14+
static let decodingQueue = DispatchQueue(label: "at.allaboutapps.fetch.decodingQueue")
15+
}
16+
1217
/// A configuration object used to setup an `APIClient`
1318
public struct Config {
1419

@@ -128,8 +133,6 @@ open class APIClient {
128133
StubbedURL.stubProvider = _config?.stubProvider
129134
}
130135

131-
let decodingQueue = DispatchQueue(label: "at.allaboutapps.fetch.decodingQueue")
132-
133136
/// Configures an `APIClient` with the given `config`
134137
///
135138
/// - Parameter config: used to setup the `APIClient`
@@ -195,7 +198,7 @@ open class APIClient {
195198

196199
dataRequest
197200
.validate() // Validate response (status codes + content types)
198-
.responseData(queue: self.decodingQueue, completionHandler: { (dataResponse) in
201+
.responseData(queue: DispatchQueue.decodingQueue, completionHandler: { (dataResponse) in
199202
// Map and decode Data to Object
200203
let decodedResponse = dataResponse.tryMap { (data) throws -> T in
201204
if T.self == IgnoreBody.self {

Sources/Fetch/Network/Resource+Fetch.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ public extension Resource where T: Cacheable {
147147
}
148148

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

175-
apiClient.decodingQueue.async {
175+
DispatchQueue.decodingQueue.async {
176176
queue.async {
177177
if !token.isCancelled {
178178
if let entry: CacheEntry<T> = try? self.cache?.get(for: self) {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
@testable
2+
import Fetch
3+
import XCTest
4+
5+
#if swift(>=5.5.2)
6+
7+
@available(macOS 12, iOS 13, tvOS 15, watchOS 8, *)
8+
class AsyncCacheTests: XCTestCase {
9+
10+
private(set) var client: APIClient!
11+
private var cache: Cache!
12+
13+
override func setUp() {
14+
super.setUp()
15+
cache = createCache()
16+
client = createAPIClient()
17+
}
18+
19+
func createCache() -> Cache {
20+
return MemoryCache(defaultExpiration: .seconds(10.0))
21+
}
22+
23+
func createAPIClient() -> APIClient {
24+
let config = Config(
25+
baseURL: URL(string: "https://www.asdf.at")!,
26+
cache: cache,
27+
shouldStub: true
28+
)
29+
30+
return APIClient(config: config)
31+
}
32+
33+
func testCacheWithFirstValue() {
34+
let resource = Resource<ModelA>(
35+
apiClient: client,
36+
method: .get,
37+
path: "/test/detail",
38+
cachePolicy: .cacheFirstNetworkAlways)
39+
40+
let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1)
41+
client.stubProvider.register(stub: stub, for: resource)
42+
43+
try! resource.cache?.set(ModelA(a: "123"), for: resource)
44+
45+
let expectation = self.expectation(description: "")
46+
Task {
47+
48+
do {
49+
let (result, isFinished) = try await resource.fetchAsync(cachePolicy: nil)
50+
XCTAssertEqual(isFinished, false)
51+
52+
switch result {
53+
case let .cache(value, _):
54+
XCTAssert(value == ModelA(a: "123"), "first value should be from cache")
55+
case .network:
56+
XCTFail("should never return network response")
57+
58+
}
59+
expectation.fulfill()
60+
} catch {
61+
XCTFail("should suceed")
62+
}
63+
64+
}
65+
waitForExpectations(timeout: 10, handler: nil)
66+
}
67+
68+
func testCacheWithFinishedValue() {
69+
let resource = Resource<ModelA>(
70+
apiClient: client,
71+
method: .get,
72+
path: "/test/detail",
73+
cachePolicy: .cacheFirstNetworkAlways)
74+
75+
let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1)
76+
client.stubProvider.register(stub: stub, for: resource)
77+
78+
try! resource.cache?.set(ModelA(a: "123"), for: resource)
79+
80+
let expectation = self.expectation(description: "")
81+
Task {
82+
83+
do {
84+
let (result, isFinished) = try await resource.fetchAsync(behaviour: .waitForFinishedValue)
85+
XCTAssertEqual(isFinished, true)
86+
87+
switch result {
88+
case .cache:
89+
XCTFail("should wait for network")
90+
case let .network(_, updated):
91+
XCTAssertEqual(updated, false)
92+
93+
}
94+
expectation.fulfill()
95+
} catch {
96+
XCTFail("should suceed")
97+
}
98+
99+
}
100+
waitForExpectations(timeout: 10, handler: nil)
101+
}
102+
103+
func testCacheWithAsyncSequence() {
104+
let resource = Resource<ModelA>(
105+
apiClient: client,
106+
method: .get,
107+
path: "/test/detail",
108+
cachePolicy: .cacheFirstNetworkAlways)
109+
110+
let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1)
111+
client.stubProvider.register(stub: stub, for: resource)
112+
113+
try! resource.cache?.set(ModelA(a: "1234"), for: resource)
114+
115+
let expectation = self.expectation(description: "")
116+
Task {
117+
118+
do {
119+
var results = [ModelA]()
120+
for try await result in resource.fetchAsyncSequence() {
121+
results.append(result.model)
122+
}
123+
XCTAssert(results.count == 2, "Should send exactly 2 values")
124+
XCTAssertEqual(results[0].a, "1234", "first result should be from cache")
125+
XCTAssertEqual(results[1].a, "123", "first result should be from network")
126+
expectation.fulfill()
127+
} catch {
128+
XCTFail("should suceed")
129+
}
130+
131+
}
132+
waitForExpectations(timeout: 10, handler: nil)
133+
}
134+
}
135+
136+
#endif
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import XCTest
2+
import Alamofire
3+
import Fetch
4+
5+
#if swift(>=5.5.2)
6+
7+
@available(macOS 12, iOS 13, tvOS 15, watchOS 8, *)
8+
class AsyncTests: XCTestCase {
9+
10+
override func setUp() {
11+
APIClient.shared.setup(with: Config(
12+
baseURL: URL(string: "https://www.asdf.at")!,
13+
shouldStub: true
14+
))
15+
}
16+
17+
func testSuccessfulStubbingOfDecodable() {
18+
let expectation = self.expectation(description: "Fetch model")
19+
let resource = Resource<ModelA>(
20+
method: .get,
21+
path: "/test")
22+
23+
let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1)
24+
APIClient.shared.stubProvider.register(stub: stub, for: resource)
25+
26+
Task {
27+
do {
28+
let result = try await resource.requestAsync()
29+
XCTAssertEqual(result.model.a, "a")
30+
expectation.fulfill()
31+
} catch {
32+
XCTFail("Request did not return value")
33+
}
34+
}
35+
waitForExpectations(timeout: 5, handler: nil)
36+
}
37+
38+
func testFailingRequest() {
39+
let expectation = self.expectation(description: "Fetch model")
40+
let resource = Resource<ModelA>(
41+
method: .get,
42+
path: "/test")
43+
44+
let stub = StubResponse(statusCode: 400, encodable: ModelA(a: "a"), delay: 0.1)
45+
APIClient.shared.stubProvider.register(stub: stub, for: resource)
46+
47+
Task {
48+
do {
49+
let _ = try await resource.requestAsync()
50+
XCTFail("Request should not succeed")
51+
} catch {
52+
expectation.fulfill()
53+
}
54+
}
55+
waitForExpectations(timeout: 5, handler: nil)
56+
}
57+
58+
func testRequestTokenCanCancelRequest() {
59+
let expectation = self.expectation(description: "T")
60+
let resource = Resource<ModelA>(
61+
method: .get,
62+
path: "/test")
63+
64+
let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.2)
65+
APIClient.shared.stubProvider.register(stub: stub, for: resource)
66+
67+
let task = Task {
68+
do {
69+
_ = try await resource.requestAsync()
70+
XCTFail("Request should be cancelled")
71+
} catch is CancellationError {
72+
print("cancelled")
73+
} catch {
74+
XCTFail("Request should be cancelled")
75+
}
76+
}
77+
task.cancel()
78+
79+
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + stub.delay + 0.1) {
80+
expectation.fulfill()
81+
}
82+
83+
waitForExpectations(timeout: 10, handler: nil)
84+
}
85+
86+
}
87+
88+
#endif

0 commit comments

Comments
 (0)