Search code examples
jsonswiftswiftuiasync-awaitgoogle-books-api

Asynchronously fetching book information from ISBN using the Google Books API in SwiftUI


I am trying to fetch the details of a book from its ISBN. Here's a reproducible example of what I have so far. I want the data to be fetched when I press the button however what I have doesn't work. Ill also include a data model for the request below. Additionally I want to overlay some sort of loading animation while it fetches the data.

struct ContentView: View {
    @State var name: String = ""
    @State var author: String = ""
    @State var total: String = ""
    
    @State var code = "ISBN"
    
    private func fetchBook(id identifier: String) async throws -> GoogleBook {
        let url = URL(string: "https://www.googleapis.com/books/v1/volumes?q={\(identifier)}")
        let (data, _) = try await URLSession.shared.data(from: url!)
        return try! JSONDecoder().decode(GoogleBook.self, from: data)
    }
    
    var body: some View {
        VStack {
            Text("Name: \(name)")
            Text("Author: \(author)")
            Text("total: \(total)")

            Button(action: {
                code = "9780141375632"
                Task {
                    do {
                        let fetchedBooks = try await fetchBook(id: code)
                        let book = fetchedBooks.items[0].volumeInfo
                        name = book.title
                        author = book.authors[0]
                        total = String(book.pageCount!)
                    } catch {
                        print(error.localizedDescription)
                    }
                }
            }, label: {
                Rectangle()
                    .frame(width: 200, height: 100)
                    .foregroundColor(.blue)
        })
        }
    }
}
import Foundation

// MARK: - GoogleBook
struct GoogleBook: Decodable {
    let kind: String
    let totalItems: Int
    let items: [Item]
}

// MARK: - Item
struct Item: Decodable {
    let kind: Kind
    let id, etag: String
    let selfLink: String
    let volumeInfo: VolumeInfo
    let saleInfo: SaleInfo
    let accessInfo: AccessInfo
    let searchInfo: SearchInfo
}

// MARK: - AccessInfo
struct AccessInfo: Decodable {
    let country: Country
    let viewability: Viewability
    let embeddable, publicDomain: Bool
    let textToSpeechPermission: TextToSpeechPermission
    let epub, pdf: Epub
    let webReaderLink: String
    let accessViewStatus: AccessViewStatus
    let quoteSharingAllowed: Bool
}

enum AccessViewStatus: String, Decodable {
    case none = "NONE"
    case sample = "SAMPLE"
}

enum Country: String, Decodable {
    case countryIN = "IN"
}

// MARK: - Epub
struct Epub: Decodable {
    let isAvailable: Bool
    let acsTokenLink: String?
}

enum TextToSpeechPermission: String, Decodable {
    case allowed = "ALLOWED"
    case allowedForAccessibility = "ALLOWED_FOR_ACCESSIBILITY"
}

enum Viewability: String, Decodable {
    case noPages = "NO_PAGES"
    case partial = "PARTIAL"
}

enum Kind: String, Decodable {
    case booksVolume = "books#volume"
}

// MARK: - SaleInfo
struct SaleInfo: Decodable {
    let country: Country
    let saleability: Saleability
    let isEbook: Bool
    let listPrice, retailPrice: SaleInfoListPrice?
    let buyLink: String?
    let offers: [Offer]?
}

// MARK: - SaleInfoListPrice
struct SaleInfoListPrice: Decodable {
    let amount: Double
    let currencyCode: CurrencyCode
}

enum CurrencyCode: String, Decodable {
    case inr = "INR"
}

// MARK: - Offer
struct Offer: Decodable {
    let finskyOfferType: Int
    let listPrice, retailPrice: OfferListPrice
}

// MARK: - OfferListPrice
struct OfferListPrice: Decodable {
    let amountInMicros: Int
    let currencyCode: CurrencyCode
}

enum Saleability: String, Decodable {
    case forSale = "FOR_SALE"
    case notForSale = "NOT_FOR_SALE"
}

// MARK: - SearchInfo
struct SearchInfo: Decodable {
    let textSnippet: String
}

// MARK: - VolumeInfo
struct VolumeInfo: Decodable {
    let title: String
    let authors: [String]
    let publisher, publishedDate, volumeInfoDescription: String
    let industryIdentifiers: [IndustryIdentifier]
    let readingModes: ReadingModes
    let pageCount: Int?
    let printType: PrintType
    let categories: [String]?
    let averageRating: Double?
    let ratingsCount: Int?
    let maturityRating: MaturityRating
    let allowAnonLogging: Bool
    let contentVersion: String
    let panelizationSummary: PanelizationSummary?
    let imageLinks: ImageLinks
    let language: Language
    let previewLink: String
    let infoLink: String
    let canonicalVolumeLink: String
    let subtitle: String?
    let comicsContent: Bool?
    let seriesInfo: SeriesInfo?

    enum CodingKeys: String, CodingKey {
        case title, authors, publisher, publishedDate
        case volumeInfoDescription = "description"
        case industryIdentifiers, readingModes, pageCount, printType, categories, averageRating, ratingsCount, maturityRating, allowAnonLogging, contentVersion, panelizationSummary, imageLinks, language, previewLink, infoLink, canonicalVolumeLink, subtitle, comicsContent, seriesInfo
    }
}

// MARK: - ImageLinks
struct ImageLinks: Decodable {
    let smallThumbnail, thumbnail: String
}

// MARK: - IndustryIdentifier
struct IndustryIdentifier: Decodable {
    let type: TypeEnum
    let identifier: String
}

enum TypeEnum: String, Decodable {
    case isbn10 = "ISBN_10"
    case isbn13 = "ISBN_13"
}

enum Language: String, Decodable {
    case en = "en"
}

enum MaturityRating: String, Decodable {
    case notMature = "NOT_MATURE"
}

// MARK: - PanelizationSummary
struct PanelizationSummary: Decodable {
    let containsEpubBubbles, containsImageBubbles: Bool
    let imageBubbleVersion: String?
}

enum PrintType: String, Decodable {
    case book = "BOOK"
}

// MARK: - ReadingModes
struct ReadingModes: Decodable {
    let text, image: Bool
}

// MARK: - SeriesInfo
struct SeriesInfo: Decodable {
    let kind, shortSeriesBookTitle, bookDisplayNumber: String
    let volumeSeries: [VolumeSery]
}

// MARK: - VolumeSery
struct VolumeSery: Decodable {
    let seriesID, seriesBookType: String
    let orderNumber: Int
    let issue: [Issue]

    enum CodingKeys: String, CodingKey {
        case seriesID = "seriesId"
        case seriesBookType, orderNumber, issue
    }
}

// MARK: - Issue
struct Issue: Decodable {
    let issueDisplayNumber: String
}

Solution

  • There are a few issues.

    • Never try! in a method which throws, hand the error over.
    • Never print error.localizedDescription in a decoding context, print always just the error instance.
    • Never force unwrap URLs composed with String Interpolation, throw an error on failure.

    The main issue is that you have to encode the URL by adding percent encoding


    private func fetchBook(id identifier: String) async throws -> GoogleBook {
        guard let encodedString = "https://www.googleapis.com/books/v1/volumes?q={\(identifier)}"
                                  .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
              let url = URL(string: encodedString) else { throw URLError(.badURL)}
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(GoogleBook.self, from: data)
    }
    

    If you'll face any DecodingError, the error message will tell you exactly why and where it occurred

    To show a progress view add a view model with a @Published property representing a state, an enum with associated values for example this generic enum

    enum LoadingState<Value> {
        case loading(Double)
        case loaded(Value)
    }
    

    The associated Double value can pass the progress percentage.