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.

    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.