Harry Blue
Harry Blue

Reputation: 4502

How can I chain completion handlers if one method also has a return value?

I have 2 methods I need to call, the second method must be executed using the result of the first method and the second method also returns a value.

I have put together a simple playground that demonstrates a simple version of the flow

import UIKit

protocol TokenLoader {
    func load(_ key: String, completion: @escaping (String?) -> Void)
}

protocol Client {
    func dispatch(_ request: URLRequest, completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) -> URLSessionTask
}


class AuthTokenLoader: TokenLoader {
    func load(_ key: String, completion: @escaping (String?) -> Void) {
        print("was called")
        completion("some.access.token")
    }
}

class Networking: Client {
    
    private let loader: TokenLoader
    
    init(loader: TokenLoader) {
        self.loader = loader
    }
    
    func dispatch(_ request: URLRequest, completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) -> URLSessionTask {
        
        let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
            if let error = error {
                completion(.failure(error))
            } else if let data = data, let response = response as? HTTPURLResponse {
                completion(.success((data, response)))
            }
        })
        
        task.resume()
        
        return task
    }
}

let loader = AuthTokenLoader()
let client = Networking(loader: loader)

let request = URL(string: "https://jsonplaceholder.typicode.com/todos/1")!
client.dispatch(.init(url: request), completion: { print($0) })

I need to use the token returned by AuthTokenLoader as a header on the request sent by dispatch method in my Networking class.

Networking also returns a task so this request can be cancelled.

As I cannot return from inside the completion block of the AuthTokenLoader load completion, I unsure how to achieve this.

Upvotes: 0

Views: 535

Answers (2)

jrturton
jrturton

Reputation: 119242

Turns out it was harder to do in Combine than I thought. Like most people, I'm still quite new to this. Would happily accept edits from people who know better :)

The general principle is that, instead of taking a completion block, your functions should return a Publisher, that you can then choose to do things with, and chain together.

So your token loader can look like this:

protocol TokenLoader {
    func load(_ key: String) -> AnyPublisher<String, Error>
}

Instead of taking a completion block, you now return a publisher which will send you a string on success, or an error if something goes wrong.

And your implementation, well I wasn't sure what you were planning on doing in there but here's an example of sorts:

class AuthTokenLoader: TokenLoader {
    func load(_ key: String) -> AnyPublisher<String, Error> {
        print("was called")
        // Do async stuff to create your token, ending in a result
        let result = Result<String, Error>.success("some.access.token")
        return result.publisher.eraseToAnyPublisher()
    }
}

Your client can look like this:

protocol Client {
    func dispatch(_ request: URLRequest) -> AnyPublisher<Data, Error>
}

Now this is the complicated bit. What you want to do is take the publisher from your token loader, and when it gives a result, make your URL request, and then make another publisher from that URL request. URLSession can give you a publisher for a data task, and there is a flatMap operator which is supposed to allow you to turn the results of one publisher into a new publisher, but you get stuck in the weeds of the type system so the code is uglier than it ought to be:

func dispatch(_ request: URLRequest) -> AnyPublisher<Data, Error> {
        
        return loader.load("someKey")
            .flatMap {
                token -> AnyPublisher<Data, Error> in
                var finalRequest = request
                finalRequest.setValue(token, forHTTPHeaderField: "x-token")
                return URLSession.shared.dataTaskPublisher(for: finalRequest)
                    .map { $0.data }
                    .mapError { $0 as Error }
                    .eraseToAnyPublisher()
        }.eraseToAnyPublisher()
    }

You'd use this code like so:

let request = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/todos/1")!)
let sub = client.dispatch(request)
    .sink(receiveCompletion: {
        switch $0 {
        case .finished: print("Done")
        case .failure(let error): print("Error :\(error)")
        }
    }, receiveValue:  { data in
        print("Data: \(data)")
    })

sub is an AnyCancellable, so if it is deallocated or you call cancel on it, this passes back up the chain and it will cancel the URL task for you.

If you want to do things with the data then there are operators for mapping or decoding or whatever which makes the whole thing very nice to work with.

Upvotes: 1

nodediggity
nodediggity

Reputation: 2478

You can create a wrapper for your task and return that instead.

protocol Task {
    func cancel()
}

class URLSessionTaskWrapper: Task {
    private var completion: ((Result<(Data, HTTPURLResponse), Error>) -> Void)?
    
    var wrapped: URLSessionTask?
    
    init(_ completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) {
        self.completion = completion
    }
    
    func complete(with result: Result<(Data, HTTPURLResponse), Error>) {
        completion?(result)
    }
    
    func cancel() {
        preventFurtherCompletions()
        wrapped?.cancel()
    }
    
    private func preventFurtherCompletions() {
        completion = nil
    }
}

Your entire playground would become

protocol TokenLoader {
    func load(_ key: String, completion: @escaping (String?) -> Void)
}

protocol Client {
    func dispatch(_ request: URLRequest, completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) -> Task
}


class AuthTokenLoader: TokenLoader {
    func load(_ key: String, completion: @escaping (String?) -> Void) {
        print("was called")
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            completion("some.access.token")
        }
    }
}


protocol Task {
    func cancel()
}

class URLSessionTaskWrapper: Task {
    private var completion: ((Result<(Data, HTTPURLResponse), Error>) -> Void)?
    
    var wrapped: URLSessionTask?
    
    init(_ completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) {
        self.completion = completion
    }
    
    func complete(with result: Result<(Data, HTTPURLResponse), Error>) {
        completion?(result)
    }
    
    func cancel() {
        preventFurtherCompletions()
        wrapped?.cancel()
    }
    
    private func preventFurtherCompletions() {
        completion = nil
    }
}

class Networking: Client {
    
    private let loader: TokenLoader
    
    init(loader: TokenLoader) {
        self.loader = loader
    }
    
    func dispatch(_ request: URLRequest, completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) -> Task {
        let task = URLSessionTaskWrapper(completion)

        loader.load("token") { token in
            
            task.wrapped = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
                if let error = error {
                    task.complete(with: .failure(error))
                } else if let data = data, let response = response as? HTTPURLResponse {
                    task.complete(with: .success((data, response)))
                }
            })
             
            task.wrapped?.resume()
        }
    
        return task
    }
}

let loader = AuthTokenLoader()
let client = Networking(loader: loader)

let request = URL(string: "https://jsonplaceholder.typicode.com/todos/1")!
client.dispatch(.init(url: request), completion: { print($0) })

Upvotes: 1

Related Questions