Refactor A Private Callback Based API To Use Async/Await

Last time we talked about how to refactor a callback based network call into an async/await based call. In that case, we had access to the underlying networking code & updated the entire chain of method calls to use async/await. But what if we’re using a private API that only has callback based methods? It may sound tricky but Apple has provided us with some easy ways to do this.

In my previous post, I used the Star Wars API to retrieve a few characters from the movies. In this example, we’ll pretend that we’ve pulled in a PrivateStarWarsAPI framework which has callback based methods only. We’ll add our own AsyncAPI layer to transform a callback based method into an async method. Let’s jump into the code.

Below is the loadPeople() callback based method in the PrivateStarWarsAPI. Remember that “we can’t change this method” in our example:


// Framework method that we can call, but we can’t change its logic
func loadPeople(completion: @escaping (PersonAPIResult) -> ()) {
    // Lots of networking code here, removed for brevity
}

If we wanted to use this method directly, our code would look something like this:


PrivateStarWarsAPI().loadPeople { result in
    switch result {
    case let .success(people):
        // store people & reload UI
    case let .failure(error):
        // display the error
    }
}

That’s not too bad, but we can use async/await to simplify the code further. Let’s create a wrapper class that transforms the callback based logic into async logic:


class AsyncAPI {
    
    func loadPeopleAsync() async throws -> [StarWarsPerson] {
        try await withCheckedThrowingContinuation { continuation in
            PrivateStarWarsAPI().loadPeople { result in
                continuation.resume(with: result)
            }
        }
    }

}

Thankfully our method only needs 3 lines of logic to convert a callback based method into an async one. Notice the call named withCheckedThrowingContinuation - this is the transition point from callback logic to async logic. This is a top level method available in the Foundation framework and it tells the system to offload async work and resume when necessary. I used the throwing version, but we could also use withCheckedContinuation if our callback will never return an error (if that’s the case, we would also remove the throws and try keywords). 

Let’s talk a little more about continuations. “Checked” means Swift will throw a fatal error if you accidentally call continuation.resume() more than once and it will point you to your mistake (resume() must be called exactly once). If you fail to call continuation.resume(), you’ll see a console message that looks like this:

SWIFT TASK CONTINUATION MISUSE: loadPeopleAsync() leaked its continuation!

It’s usually best to use checked continuations so that you can receive feedback on continuation errors before you send your app into the wild. If you’re certain your logic is bulletproof and you need a tiny improvement in performance, there are also unchecked versions of these methods, named withUnsafeContinuation and withUnsafeThrowingContinuation. If you use these unchecked versions, your app will probably still crash when calling resume() multiple times & you won’t get notified when you never call resume() and leak device resources.

Ok, back to our API - here’s what our code looks like when we use our new async API wrapper:


let people = try await AsyncAPI().loadPeopleAsync()
// reload UI

It’s pretty awesome how simple the code is when using async/await. In a single line we wait for the API result as well as handle any errors that may have been returned. All we had to do was write a simple wrapper method to bridge the callback & async worlds.

I hope you enjoyed this post & learned something new! I know I did while I tinkered with continuations this week. Here’s a link to my sample project in GitHub that contains the code in this post. See you next time!