8

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?

swasta
  • 846
  • 8
  • 25

2 Answers2

4

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()
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • glad it looks kind of similar to what I came up with:) As for running on a background thread, is that ok if I .subscribe(on: dispatchQueue) in my whole pipeline (as in my answer), instead of wrapping some parts of the pipeline in dispatch blocks? Also, I think, running something on a background thread seems like an implementation detail, so should be declared inside of the method, whereas receiving something on a particular queue is probably a responsibility of a client, so `.receive(on: DispatchQueue.main)` should be done by the client. What do you think? – swasta Aug 12 '19 at 07:37
1

I think I could find a solution


private func readTokenFromKeychain() -> AnyPublisher<String?, Error> {
    ...
}

func getToken() -> AnyPublisher<String, Error> {
    return readTokenFromKeychain()
        .flatMap { localEncryptedToken -> AnyPublisher<String, Error> in
            if let localEncryptedToken = localEncryptedToken {
                return Result.success(localEncryptedToken).publisher.eraseToAnyPublisher()
            } else {
                return self.fetchToken()
            }
        }
        .flatMap {
            return self.tokenCryptoHelper.decrypt(encryptedToken: $0)
        }
        .subscribe(on: dispatchQueue)
        .eraseToAnyPublisher()
}

But I had to make functions I call within getToken() return publishers too to Combine them well. There probably should be error handling somewhere but this is the next thing for me to learn.

swasta
  • 846
  • 8
  • 25