SwiftUI and REST (Episode I)

With this post, I am starting a series of three episodes about the use of REST in SwiftUI. In this episode, we will see how to implement a GET request. In the next one, we’ll explore how to make a POST request, and in the last one, we’ll examine different solutions.

Suppose we want to search for books using the Google Books API: https://www.googleapis.com/books/v1/volumes?q=intitle:your-word. The idea is to write the search text in the search bar, press enter, and then search for a book with the word typed in the title.

In the screenshot provided earlier, we see a part of the result for the search term ‘lean’. We use this to define the structure of the data that we want to retrieve and decode.

In a Swift file named ‘Book’, we create the following structure:

struct GoogleBooks: Codable {
    var kind: String
    var totalItems
    var items: Array<GoogleBook>
}

struct GoogleBook: Codable {
    var id: String
    var volumeInfo: VolumeInfo
}

struct VolumeInfo: Codable {
    var title: String
    var subtitle: String
    var authors: Array<String>
    var industryIdentifiers: Array<Identifier>
    var imageLinks: ImagesLink
}

struct ImagesLink: Codable {
    var smallThumbnail: String
}

We create structures that map only the information that we want use in our application. Each structure implement Codable, “A type that can convert itself into and out of an external representation”. It’s necessary to decode (or encode) information from a json.

What happen if some field is null? Using the structure above, we would encounter decode errors in the application. To avoid this problem, we can rewrite the structures in the following way:

struct GoogleBook: Codable {
    var id: String
    var volumeInfo: VolumeInfo?
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(String.self, forKey: .id)
        volumeInfo = try container.decodeIfPresent(VolumeInfo.self, forKey: .volumeInfo)
    }
}

struct VolumeInfo: Codable {
    var title: String
    var subtitle: String?
    var authors: Array<String>?
    var industryIdentifiers: Array<Identifier>?
    var imageLinks: ImagesLink
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
        title = try container.decode(String.self, forKey: .title)
        authors = try container.decodeIfPresent(Array<String>.self, forKey: .authors)
        industryIdentifiers = try container.decodeIfPresent(Array<Identifier>.self, forKey: .industryIdentifiers)
        imageLinks = try container.decode(ImagesLink.self, forKey: .imageLinks)
    }
}

struct ImagesLink: Codable {
    var smallThumbnail: String
}

Now, the fields that can be null are defined as Optional, and an initialization function is added.

In this function, we need to decode all the fields present in the structure that are not optional, in the following way:

id = try container.decode(String.self, forKey: .id)

This for the optionals:

authors = try container.decodeIfPresent(Array<String>.self, forKey: .authors)

In this manner, a field is decoded only if it is present.

Now, let’s jump into the UI part.

struct ContentView: View {
    @State var searchText: String = "lean"
    @State private var results: GoogleBooks = GoogleBooks()
    @State var books = [GoogleBook]()
    @State var loading = true
    let url = "https://www.googleapis.com/books/v1/volumes?q=intitle:"
    var body: some View {
        NavigationStack {
            if loading {
                ProgressView()
            } else {
                List {
                    ForEach(books, id: \.id) { item in
                        Text(item.volumeInfo!.title)
                    }
                }.searchable(text: $searchText)
                .onSubmit(of: .search) {
                    Task {
                        await loadData()
                    }
                }
                .navigationTitle("Books")
            }
        }.task {
            await loadData()
        }
        
    }
    func loadData() async {
    }

In the first part, we define the variables we will use. ‘searchText’ is initialized to ‘lean’ to load at the start. ‘GoogleBooks’ contains all the search results, ‘books’ holds the list of books, and ‘loading’ is a Boolean variable used to hide or show a ProgressView while loading the data. The last one is the URL.

In the view, we set a searchable text field. When the user taps enter, the submit action calls the async ‘loadData’ function within a Task. The same happens when the view loads for the first time. Honestly, I don’t like setting a default search, but I implemented it in this example just to show how to load data at the start.

Last but not least, and never has this phrase been truer than in this case, take a look at the ‘loadData’ function, the core of the application:

func loadData() async {
        
        guard let url = URL(string: url + searchText) else {
            print("Invalid URL")
            return
        }
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let decoder = JSONDecoder()
            do {
                // process data
                let decodedResponse = try decoder.decode(GoogleBooks.self, from: data)
                results.items = decodedResponse.items
                results.totalItems = decodedResponse.totalItems
                books = results.items
            } catch let DecodingError.dataCorrupted(context) {
                print(context)
            } catch let DecodingError.keyNotFound(key, context) {
                print("Key '\(key)' not found:", context.debugDescription)
                print("codingPath:", context.codingPath)
            } catch let DecodingError.valueNotFound(value, context) {
                print("Value '\(value)' not found:", context.debugDescription)
                print("codingPath:", context.codingPath)
            } catch let DecodingError.typeMismatch(type, context)  {
                print("Type '\(type)' mismatch:", context.debugDescription)
                print("codingPath:", context.codingPath)
            } catch {
                print("error: ", error)
            }
        } catch {
            print("Invalid data")
        }
        self.loading = false
    }

It’s an async function, due we have to run in this way to avoid that the user interface is blocked waiting the result.

The first step i check that the url is valid, after that get the data with

await URLSession.shared.data(from: url)

Therefore, create a decoder and attempt to decode the data. If there is a problem with decoding, use the catch block to map every possible error (I recommend this, as logs are fundamental in the art of programming).

At the end, ‘loading’ is set to false, so the ProgressView disappears and the list is shown.

That’s all for this episode. Check the blog for the next one.

share this post with friends

Picture of Nicola De filippo

Nicola De filippo

I'm a software engineer who adds to the passion for technologies the wisdom and the experience without losing the wonder for the world. I love to create new projects and to help people and teams to improve

2 comments

Leave a comment

Your email address will not be published. Required fields are marked *

Who I am

I'm a software engineer who adds to the passion for technologies the wisdom and the experience without losing the wonder for the world. I love to create new projects and to help people and teams to improve.

Follow Me Here

Get The Latest Updates

Periodically receive my super contents on coding and programming

join the family;)

Recent Posts