-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
66ddae1
commit 21e9006
Showing
6 changed files
with
339 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |