Xcoding with Alfian

Software Development Videos & Tutorials

Using Swift 5.5 Async Await to Fetch REST API

Alt text

As an iOS developer, most probably you already have lot of experiences to fetch REST API from multiple endpoints. In some instances, the call to the API endpoints need to be in sequential order as they need the response of previous API call to be passed in HTTP Request.

Let me give you an illustation for such scenario with these given steps:

  • Fetch IP address of the user from Ipify API
  • Passing the IP address, fetch IP Geolocation data from FreeGeoIP API containing the country code.
  • Passing the Country Code, fetch the Country Detail from RestCountries.eu API


If the final code we ended written will have 3 callbacks that are nested like the picture below, then welcome to Pyramid of Doom aka callback hell! The code is not easily readable, complicated, and harder to maintain as the codebase growth. Let's say we need to use country detail response to fetch another API, we will add the 4th callback making the nesting become even much deeper.

Alt text

The pyramid of doom callbacks problem has already been raised as one of the most requested issue that the Swift Language should eliminate. At 2017, Chris Lattner (Swift creator) had even written the Swift Concurrency Manifesto discussing his visions and goals to handle concurrency with imperative code using async await.

Finally with the accepted Swift Evolution Proposal (SE-0296), Async Await has been implemented in Swift 5.5 (Currently still in development as this article is written). The proposal provides the motivation to tackle 5 main problems:

  • Pyramid of doom.
  • Better error handling.
  • Conditional execution of asynchronous function.
  • Forgot or incorrectly call the callback.
  • Eliminating design and performance issue of synchronous API because of callbacks API awkwardness.


Async Await provides the mechanism for us to run asynchronous and concurrent functions in a sequential way. This will help us to eliminate pyramid of doom callback hell and making our code easier to read, maintain, and scale. Take a look at the picture below showing how simple and elegant async await code is compared to using callback.

Alt text

What We Will Learn and Build

In this article, we will be learning and experimenting Swift Async Await to solve these 2 tasks:


Here are 2 main topics that we will learn along the way:

  • Task Based API introduced in Structured Concurrency proposal (SE-0304) as the basic unit of concurrency in async function. We'll be learning on how to create single task and task group async functions.
  • Interfacing current synchronous code with completion handler callback to async tasks using Continuation API introduced in SE-0300. We'll be interfacing current URLSession data task callback based API to Async Await function.

Swift 5.5 is currently in development as this article is written, i am using Xcode 12.5 with Swift Nightly 5/29 Development Snapshot from swift.org. There might be an API changes that break the current code when Swift 5.5 stable is released in the future.

Getting Started

Alt text

Please download and install the Swift 5.5 Xcode toolchain from this swift.org link. Then, open Xcode and select File > Toolchains > Swift > Swift 5.5 Development Snapshot 2021-05-28.

Alt text

Next, you need to clone or download the Starter Project from my GitHub Repository. It contains the starter code as well as the completed project.

Alt text

As Async Await in Swift 5.5 development build currently doesn't have the support to run in iOS and macOS Cocoa GUI based App, the project is set as a macOS console based App. I have already added 3 additional flags in Package.swift to help us experiment with async await:

  • "-Xfrontend", "-enable-experimental-concurrency". Enable the async await feature.
  • "-Xfrontend", "-disable-availability-checking". Eliminate the build error of macOS9999 availability checking when using Task based API
  • "-Xfrontend", "-parse-as-library". Eliminate the build error when declaring async main function so we can invoke async function in main.
Alt text

Open the Starter folder and click on async-api.xcodeproj to open the project in Xcode. The starter project already provides the Models that conform to Decodable when fetching the data from remote APIs:

  • For SWAPI, we have SWAPIResponse, Film, and People structs.
  • For GeoIP, we have IpifyResponse, FreeGeoIPResponse, and RestCountriesResponse.
  • All the models provide static constant and method for the URL to fetch.
Alt text

In the FetchAPITask.swift file, i provided a global method to fetch remote API and decode the response data using URLSession data task and callback generic Result handler.

Alt text

The entry-point of the App is in static main method in struct App inside the main.swift file. It is declared using the @main keyword introduced in Swift 5.3. Currently, it fetches the APIs using callbacks. You can try to build and run the app to see the results printed in the console, make sure you have internet connection before.

Alt text

Let's move on to our first task, which is to create our own async function to fetch REST API concurrently using the all new Structured Concurrency Task based API.

Create Fetch REST API Async Function using Task API

The async await proposal (SE-0296) itself doesn't introduce the mechanism to handle concurrency. There is another proposal called structured concurrency (SE-0304) which enables the concurrent execution of asynchronous code with Task based API model for better efficiency and predictability.

Based on the Task public interface, we can initialize a single task passing the optional TaskPriority param and mandatory operation param containing the closure with the return type. One of the initializer also support throwing error. For TaskPriority, we can pass several options such as none, background, default, high, low, userInitiated, utility. If nil is passed, system will use the current priority.

extension Task where Failure == Never {
    public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)
    public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async throws -> Success)
}

Open FetchAPITask.swift file and type/copy the following code from the snippet.

// 1
func fetchAPI<D: Decodable>(url: URL) async throws -> D {
    // 2
    let task = Task { () -> D in
        // 3
        let data = try Data(contentsOf: url)
        let decodedData = try JSONDecoder().decode(D.self, from: data)
        return decodedData
    }
    // 4
    return try await task.value
}
  1. We declare the fetchAPI function using a generic D type that conforms to Decodable, it accepts a single URL parameter and returns the generic D type. Notice the async and throws keywords after the parameter. It is an async function that can throws.
    1. We initialize a Task without passing the TaskPriority and use trailing closure for the operation parameter. It has no parameter and return the generic D type.
    2. The code inside the task closure runs in async context and another thread assigned by the system. Here, we fetch the data from the API using the Data(contentsOf:) throwing initializer which accepts an URL. This is a blocking synchronous API used to fetch data and is bad for performance, but as we are running on another thread, it should be ok for now (later, we will learn on how to interface using URLSession DataTask). Then, we decode the response data using JSONDecoder to the generic D Type and return it.
    3. Finally, we need to invoke task value property to wait for the task to complete and returning (or throwing) its result. This property is async , so we need to prefix the call using await keyword. One thing to remember is we can only use await an async function in an async context. So, we cannot simply use this if our function is not an async function. For your info, there is another result property which returns the Result type instead of the value.


Next, let's go back to the main.swift file and replace the current static main code method with the following snippet.

// 1
static func main() async {
    // 2
    do {
        // 3
        let ipifyResponse: IpifyResponse = try await fetchAPI(url: IpifyResponse.url)
        print("Resp: \(ipifyResponse)")

        // 4
        let freeGeoIpResponse: FreeGeoIPResponse = try await fetchAPI(url: FreeGeoIPResponse.url(ipAddress: ipifyResponse.ip))
        print("Resp: \(freeGeoIpResponse)")

        // 5
        let restCountriesResponse: RestCountriesResponse = try await fetchAPI(url: RestCountriesResponse.url(countryCode:  freeGeoIpResponse.countryCode))
        print("Resp: \(restCountriesResponse)")
    } catch {
          // 6
        print(error.localizedDescription)
    }
}
  1. To make app entry-point runs in async context, we add the async keyword to the static main method. Without this, we won't be able to invoke and await async functions.
  2. As our fetchAPI async functions can throw, we need to use the standard do catch block.
  3. In the do block, first, we fetch the current IP Address of the user from Ipify API. We pass the url address from the IpifyResponse struct static constant. We declare the ipifyResponse constant with the type of IpifyResponse so the fetchAPI method able to infer the D Generic placeholder as IpifyResponse struct, we need to use try await as fetchAPI is an async function.
  4. Next, we fetch the Geolocation data from FreeGeoIP API passing the IP address from the previous response. We declare FreeGeoIPResponse struct as the type that will be decoded by the fetchAPI.
  5. Next, we fetch the Country data from RestCountry.eu API passing the country code from the FreeGeoIP response. RestCountriesResponse struct is the type of decodable data.
  6. Finally, inside the catch block, we just print the error localizedDescription to the console. This will be invoked in case one of the API call fails.


That's it! try to run and build with active internet connection to see the responses printed in the console. We have successfully implement the Async function to fetch data from multiple REST API sequentially. You should be proud of the code that we write for this as it is very clean and readable.

Call Async Function in Synchronous Function using Detach

I have said before that we can't call async function in a synchronous function. Actually, there is another approach to do this using the detach API.

  static func main() {
    detach {
        do {
            let ipifyResponse: IpifyResponse = try await fetchAPI(url: IpifyResponse.url)
            //...
        } catch {
            print(error.localizedDescription)
        }
    }
    RunLoop.main.run(until: Date.distantFuture)
}

Basically this task will run independently of the context which it is created. You might be wondering about this code RunLoop.main.run(until: Date.distantFuture). As this is a console based app with synchronous main function, the process will get terminated immediately as the function ends, using this, we can keep the process running until a specified date so the detach task can be executed.

Concurrently Fetch Multiple REST APIs in Parallel

Let's move on to the the second task, which is to fetch multiple APIs in parallel using async await. You might be wondering how can we achieve this as we don't want to sequentially fetch the APIs, it will be very slow and doesn't maximize the potential of our hardware such as M1 based SoC with 8 Cores (4x High Performance + 4x Efficiency)

The Task API from the Structured Concurrency proposal got you covered as it provides the GroupTask API to execute group of async functions in parallel.

Using the GroupTask we can spawn new async child tasks within the async scope/closure. We need to complete all the child tasks inside the scope before it exits.

Navigate to the FetchAPITask.swift file and type/copy the following code snippet.

// 1
func fetchAPIGroup<D: Decodable>(urls: [URL]) async throws -> [D] {
    // 2
    try await withThrowingTaskGroup(of: D.self) { (group)  in
        // 3
        for url in urls {
            group.async {
                let data = try Data(contentsOf: url)
                let decodedData = try JSONDecoder().decode(D.self, from: data)
                return decodedData
            }
        }
        
        // 4
        var results = [D]()
        for try await result in group {
            results.append(result)
        }
        return results
    }
}
  1. We declare the fetchAPIGroup function that use generic D type that conforms to Decodable. The parameter accepts an array of URL, it means we can have dynamic number of URLs. The return type is an array of D generic type. We declare the async and throws keywords after the parameter to make this an async function that can throw.
  2. We use the withThrowingTaskGroup API passing the return type of D and the closure with group as the single parameter without return type. We need to invoke this code using try await as it is an async function.
  3. In the closure, we use for-loop in the url array. In each loop, we use the group async method to spawn a new async child task. In the closure, we just fetch the data using Data(contentsOf:) initializer, decode the data as D type, and return. You might be thinking why don't we call fetchAPI instead, please don't do this as the app will crash. I am not really sure why, please tell me the reason if you know the reason.
  4. We declare a property containing array of D type. Here, we use the AsyncSequence to iterate and use try await at the group child tasks, the child task that finish first is appended to results array. Finally, we return the array after all the child tasks has been completed. TaskGroup itself implements AsyncIterator so it has the next() method for iteration, you can take a look at the Async Sequence proposal (SE-0298) to learn more about the detail.


One thing to consider when using withThrowingTaskGroup is if one of the child task threw an error before the closure completes, then the remaining child tasks will be cancelled and error will be thrown. If we want one of the child fails without making the remaining tasks cancelled, we can use withTaskGroup instead and return optional value instead of throwing. Next, navigate back to the main.swift file and replace the static main method with the following snippet.

// 1
static func main() async {
   do {
       // 2
       let revengeOfSith: SWAPIResponse<Film> = try await fetchAPI(url: Film.url(id: "6"))
       print("Resp: \(revengeOfSith.response)")
       
       // 3
       let urlsToFetch = Array(revengeOfSith.response.characterURLs.prefix(upTo: 3))
       let revengeOfSithCharacters: [SWAPIResponse<People>] = try await fetchAPIGroup(urls: urlsToFetch)
       print("Resp: \(revengeOfSithCharacters)")
   } catch {
       // 4
       print(error.localizedDescription)
   }
}
  1. The main static method is declared with async method to enable async context.
  2. In the do block, first we fetch the "Revenge of the Sith" with using the fetchAPI async function, the URL is retrieved from the Film struct static url method passing id of 6. (6 is the film id of Revenge of the Sith in SWAPI). The Film response provided by SWAPI contains an array of the characters URLs.
  3. We'll fetch the first 3 characters in the array using the fetchAPIGroup async function passing the film's characterURLs property, we slice the array using prefix method to get the first 3 elements. The return type of this is Array of SWAPIResponse<People>.
  4. In the catch block, we just print the error localizedDescription property.


That's it, make sure to run and build with internet connection to see the responses printed in the console. We have successfully implemented async function to group child tasks so it can be executed in parallel concurrently.

One more Thing - Interfacing Current Synchronous Code with Callback to Async Function (URlSession DataTask)

Before we conclude this article, there is one more thing that we need to learn, the Continuation API. This API is super important as we can use it to convert our current synchronous code with callback to async function. As an example for this article, we'll convert the URLSession DataTask as an async function.

Create a new Swift file named URLSession+Async.swift, we'll create the async method as an extension URLSession. Type/copy the following code snippet into the file.

import Foundation

extension URLSession {
    
    // 1
    func data(with url: URL) async throws -> Data {
        // 2
        try await withCheckedThrowingContinuation { continuation in
            // 3
            dataTask(with: url) { data, _, error in
                // 4
                if let error = error {
                    continuation.resume(throwing: error)
                } else if let data = data {
                    continuation.resume(returning: data)
                } else {
                    continuation.resume(throwing: NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bad Response"]))
                }
            }
            .resume()
        }
    }
    
}
  1. Declare the data method inside URLSession extension. It provides a single parameter that accepts URL. The return type is Data. The async and throw is declared to make this a throwing async method.
  2. Invoke try await on withCheckedThrowingContinuation function. Here we need to pass the closure with a single parameter continuation. Basically, we need to invoke the resume method passing the returning data or the throwing error. Make sure to invoke the resume method exactly once to avoid undefined behavior. withCheckedThrowingContinuation will crash if we invoke it twice, but it has runtime performance cost. Swift also provides withUnsafeThrowingContinuation to avoid this. In this case, the system won't crash if we invoke resume twice and can lead to undefined behavior!
  3. Invoke the URLSession dataTask passing the url and completion closure. Make sure to invoke resume on the data task.
  4. In the closure, we check if the error exists, we invoke the continuation resume passing the throwing error. If data exists, we invoke the continuation resume passing the returning data. Else, we invoke the continuation resume passing our own constructed throwing error. For simplicity of this article, i don't check the HTTP Response status code, but you must do this in production.


That's it! now we can fetch our REST API using URLSession and remove the Data(contentsOf:) blocking API. Let's navigate back to FetchAPITask.swift file and type/copy the following snippet.

func fetchAndDecode<D: Decodable>(url: URL) async throws -> D {
    let data = try await URLSession.shared.data(with: url)
    let decodedData = try JSONDecoder().decode(D.self, from: data)
    return decodedData
}

The fetchAndDecode async function basically fetches the data using shared URLSession data(with:) async method we created previously. Then, it decode data using the generic placeholder and return the decoded model.

Now, let's replace the fetchAPI and fetchGroupAPI to use this new function to fetch and decode data.

func fetchAPI<D: Decodable>(url: URL) async throws -> D {
    let task = Task { () -> D in
        try await fetchAndDecode(url: url)
    }
    //...
}

func fetchAPIGroup<D: Decodable>(urls: [URL]) async throws -> [D] {
    try await withThrowingTaskGroup(of: D.self) { (group)  in
        for url in urls {
            group.async { try await fetchAndDecode(url: url) }
        }
        //...
    }
}

That's all! now try to build and run to make sure everything work just like before. You can check the completed project from the repository link above.

What's Next

As async await itself is a pretty big API with so many features, i won't be able to cover everything in single article. So please read the proposals themselves to learn the things i haven't covered in this article such as:

  • Task Cancellation.
  • Task Yield.
  • Async Let binding.


I also recommend you to read Hacking with Swift by Paul Hudson - What's new in Swift 5.5. That one article, is also one of my source and inspiration to write this article

Also, so far, i haven't discussed on how to handle race condition and data isolation between different threads. There is another proposal named Actors (SE-0306) that solves this problem in case you are interested to learn more. I believe it is a very important concept to understand and implement so we can produce much stable code in production without race condition bugs.

Conclusion

Congratulations on making it so far reading this long article. Before i conclude the article, i want to give my own points on the async await API:

  • Async Await helps us to manage code complexity and complication when using many functions with callback, as well as providing simpler control flow and error handling.
  • Very useful for UI and Server Domain where everything should be asynchronous and non-blocking
  • Combine is an alternative approach for managing asynchronous flow using streams in a reactive way using Publisher and Subscribers. It is a closed source framework and not built into Swift, and can only be used in Apple Platforms.


It is such an amazing time to be a part of Swift developers community and build wonderful things to solve complex challenging problems using technology. So, let's keep on becoming a lifetime learner and coder!