Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Translating async method into Combine

Tags:

swift

combine

I'm trying to wrap my head around Combine.

Here's a method I want to translate into Combine, so that it would return AnyPublisher.

func getToken(completion: @escaping (Result<String, Error>) -> Void) {
    dispatchQueue.async {
        do {
            if let localEncryptedToken = try self.readTokenFromKeychain() {
                let decryptedToken = try self.tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
                DispatchQueue.main.async {
                    completion(.success(decryptedToken))
                }
            } else {
                self.fetchToken(completion: completion)
            }
        } catch {
            DispatchQueue.main.async {
                completion(.failure(error))
            }
        }
    }
}

The whole thing executes on a separate dispatch queue because reading from Keychain and decryption can be slow.

My first attempt to embrace Combine

func getToken() -> AnyPublisher<String, Error> {
    do {
        if let localEncryptedToken = try readTokenFromKeychain() {
            let decryptedToken = try tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
            return Result.success(decryptedToken).publisher.eraseToAnyPublisher()
        } else {
            return fetchToken() // also rewritten to return AnyPublisher<String, Error>
        }
    } catch {
        return Result.failure(error).publisher.eraseToAnyPublisher()
    }
}

But how would I move reading from Keychain and decryption onto separate queue? It probably should look something like

func getToken() -> AnyPublisher<String, Error> {
    return Future<String, Error> { promise in
        self.dispatchQueue.async {
            do {
                if let localEncryptedToken = try self.readTokenFromKeychain() {
                    let decryptedToken = try self.tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
                    promise(.success(decryptedToken))
                } else {
                    // should I fetchToken().sink here?
                }
            } catch {
                promise(.failure(error))
            }
        }    
    }.eraseToAnyPublisher()
}

How would I return a publisher from my private method call? (see comment in code)

Are there any prettier solutions?

like image 835
swasta Avatar asked Aug 07 '19 17:08

swasta


People also ask

Does async await replace combine?

Before moving forward, it's important to highlight that Async/Await is not meant to replace Combine. Thus, code that operate on streams should remain using the latter. This article will be focused on code that doesn't necessarily need such capabilities.

Is combine asynchronous?

Combine is Apple's framework to work with asynchronous events in a unified and reactive way that ensures your app is always up to date based on the latest state of its data.

What is sink in combine?

What's a sink? While a complete explanation of Combine, publishers, subscribers, and sinks is beyond the scope of this article, for our purposes here it's probably enough to know that in Combine a sink is the code receives data and completion events or errors from a publisher and deals with them.

What is PassthroughSubject?

A PassthroughSubject broadcasts elements to downstream subscribers and provides a convenient way to adapt existing imperative code to Combine. As the name suggests, this type of subject only passes through values meaning that it does not capture any state and will drop values if there aren't any subscribers set.


Video Answer


1 Answers

Assuming you’ve refactored readTokenFromKeyChain, decrypt, and fetchToken to return AnyPublisher<String, Error> themselves, you can then do:

func getToken() -> AnyPublisher<String, Error> {
    readTokenFromKeyChain()
        .flatMap { self.tokenCryptoHelper.decrypt(encryptedToken: $0) }
        .catch { _ in self.fetchToken() }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

That will read the keychain, if it succeeded, decrypt it, and if it didn’t succeed, it will call fetchToken. And having done all of that, it will make sure the final result is delivered on the main queue.


I think that’s the right general pattern. Now, let's talk about that dispatchQueue: Frankly, I’m not sure I’m seeing anything here that warrants running on a background thread, but let’s imagine you wanted to kick this off in a background queue, then, you readTokenFromKeyChain might dispatch that to a background queue:

func readTokenFromKeyChain() -> AnyPublisher<String, Error> {
    dispatchQueue.publisher { promise in
        let query: [CFString: Any] = [
            kSecReturnData: true,
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: "token",
            kSecAttrService: Bundle.main.bundleIdentifier!]

        var extractedData: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &extractedData)

        if
            status == errSecSuccess,
            let retrievedData = extractedData as? Data,
            let string = String(data: retrievedData, encoding: .utf8)
        {
            promise(.success(string))
        } else {
            promise(.failure(TokenError.failure))
        }
    }
}

By the way, that’s using a simple little method, publisher that I added to DispatchQueue:

extension DispatchQueue {
    /// Dispatch block asynchronously
    /// - Parameter block: Block

    func publisher<Output, Failure: Error>(_ block: @escaping (Future<Output, Failure>.Promise) -> Void) -> AnyPublisher<Output, Failure> {
        Future<Output, Failure> { promise in
            self.async { block(promise) }
        }.eraseToAnyPublisher()
    }
}

For the sake of completeness, this is a sample fetchToken implementation:

func fetchToken() -> AnyPublisher<String, Error> {
    let request = ...

    return URLSession.shared
        .dataTaskPublisher(for: request)
        .map { $0.data }
        .decode(type: ResponseObject.self, decoder: JSONDecoder())
        .map { $0.payload.token }
        .eraseToAnyPublisher()
}
like image 89
Rob Avatar answered Oct 23 '22 08:10

Rob