Search code examples
iosswiftasynchronouscompletionhandler

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.


Solution

  • 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) })