PolyBooks/sdp_polyBooks

View on GitHub
app/src/main/java/com/github/polybooks/database/OLBookDatabase.kt

Summary

Maintainability
C
1 day
Test Coverage
A
94%
package com.github.polybooks.database

import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import com.github.polybooks.core.Book
import com.github.polybooks.core.ISBN
import com.github.polybooks.utils.*
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import java.io.FileNotFoundException
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.CompletableFuture
import java.lang.UnsupportedOperationException


// TODO add to/create listOf as we discover new fields
private val TITLE_FIELD_NAMES = listOf("title", "full_title")
private const val AUTHORS_FIELD_NAME = "authors"
private const val FORMAT_FIELD_NAME = "physical_format"
private val ISBN_FIELD_NAMES = listOf("isbn_13", "isbn_10")
private const val PUBLISHER_FIELD_NAME = "publishers"
private const val PUBLISH_DATE_FIELD_NAME = "publish_date"
private const val AUTHOR_NAME_FIELD_NAME = "name"
private const val LANGUAGE_FIELD_NAME = "languages"
private const val LANGUAGE_NAME_FIELD_NAME = "name"
private const val EDITION_FIELD_NAME = "edition_name"

private const val DATE_FORMAT = "MMM dd, yyyy"
private const val DATE_FORMAT2 = "yyyy"

private const val OL_BASE_ADDR = """https://openlibrary.org"""

/**
 * An implementation of a book database based on the Open Library online database
 * */
object OLBookDatabase: BookProvider {

    override fun getBook(isbn: String): CompletableFuture<Book?> {
        val regularised = regulariseISBN(isbn) ?: throw IllegalArgumentException("ISBN cannot be regularised")
        val url = isbn2URL(regularised)

        return url2json(url)
            .thenApply { parseBook(it) }
            .thenCompose { updateBookWithAuthorName(it) }
            .thenCompose { updateBookWithLanguageName(it) }
            .exceptionally { exception ->
                val unwrapped = unwrapException(exception)
                if (unwrapped is FileNotFoundException) {
                    return@exceptionally null
                } else throw unwrapped
            }
    }

    override fun getBooks(isbns: Collection<ISBN>, ordering: BookOrdering): CompletableFuture<List<Book>> {
        val regularised = isbns.map { regulariseISBN(it) ?: throw IllegalArgumentException("ISBN cannot be regularised") }
        val futures = regularised.toSet().map { getBook(it) }
        return listOfFuture2FutureOfList(futures).thenApply {
            val books = it.filterNotNull()
            return@thenApply order(books, ordering)
        }
    }

    override fun addBook(book: Book): CompletableFuture<Unit> {
        // Can't add books to OpenLibrary
        return CompletableFuture.completedFuture(Unit)
    }

    //makes an URL to the OpenLibrary page out of an isbn
    private fun isbn2URL(isbn: String): String {
        return "$OL_BASE_ADDR/isbn/$isbn.json"
    }

    private val errorMessage = "Cannot parse OpenLibrary book because : "

    //takes a book that has the authors in the form /authors/<authorID>
    //and fetches the actual name of the author
    @RequiresApi(Build.VERSION_CODES.N)
    private fun updateBookWithAuthorName(book: Book): CompletableFuture<Book> {
        if (book.authors == null) return CompletableFuture.completedFuture(book)
        //This is a list of futures that are concurrently fetching the name of the authors
        val newAuthorsFutures = book.authors.map { authorID ->
            val authorsUrl = "$OL_BASE_ADDR$authorID.json"
            url2json(authorsUrl).thenApply { parseAuthor(it) }
        }

        //Combine those futures into one future of a list of names
        val combined = listOfFuture2FutureOfList(newAuthorsFutures)
        //update the book with the names of the authors
        return combined.thenApply { newAuthors -> book.copy(authors = newAuthors) }
    }

    //takes a book that has the language in the form /languages/<languageID>
    //and fetches the actual name of the language
    private fun updateBookWithLanguageName(book: Book): CompletableFuture<Book> {
        if (book.language == null) return CompletableFuture.completedFuture(book)
        val languageID = book.language
        val languageURL = "$OL_BASE_ADDR$languageID.json"
        val newLangFuture = url2json(languageURL).thenApply { parseLanguage(it) }
        return newLangFuture.thenApply { newLanguage -> book.copy(language = newLanguage) }
    }

    /**
     * Function for internal use in OLBookDatabase. Takes the json of a book, and makes a Book from it.
     * */
    @RequiresApi(Build.VERSION_CODES.N)
    private fun parseBook(jsonBook: JsonElement): Book {
        val jsonBookObject = asJsonObject(jsonBook)
        val title = getJsonFields(jsonBookObject, TITLE_FIELD_NAMES)
            .map { parseTitle(it) }
            .orElseThrow(cantParseException(TITLE_FIELD_NAMES[0]))!!
        val isbn13 = getJsonFields(jsonBookObject, ISBN_FIELD_NAMES)
            .map { parseISBN13(it) }
            .orElseThrow(cantParseException(ISBN_FIELD_NAMES[0]))!!
        val authors = getJsonField(jsonBookObject, AUTHORS_FIELD_NAME)
            .map { parseAuthors(it) }
            .orElse(null)
        val format = getJsonField(jsonBookObject, FORMAT_FIELD_NAME)
            .map { parseFormat(it) }
            .orElse(null)
        val publisher = getJsonField(jsonBookObject, PUBLISHER_FIELD_NAME)
            .map { parsePublisher(it) }
            .orElse(null)
        val publishDate = getJsonField(jsonBookObject, PUBLISH_DATE_FIELD_NAME)
            .map { parsePublishDate(it) }
            .orElse(null)
        val language = getJsonField(jsonBookObject, LANGUAGE_FIELD_NAME)
            .map { parseLanguages(it) }
            .orElse(null)
        val edition = getJsonField(jsonBookObject, EDITION_FIELD_NAME)
            .map { parseEdition(it) }
            .orElse(null)

        return Book(
            isbn13, authors, title, edition, language,
            publisher, publishDate, format
        )

    }

    //parses the json of an author
    @RequiresApi(Build.VERSION_CODES.N)
    private fun parseAuthor(jsonAuthor: JsonElement): String {
        val nameField = getJsonField(asJsonObject(jsonAuthor), AUTHOR_NAME_FIELD_NAME)
        return nameField.map { asString(it) }
            .orElseThrow(cantParseException(AUTHOR_NAME_FIELD_NAME))
    }

    //parses the json of a language
    private fun parseLanguage(jsonLanguage: JsonElement): String {
        val nameField = getJsonField(asJsonObject(jsonLanguage), LANGUAGE_NAME_FIELD_NAME)
        return nameField.map { asString(it) }.orElseThrow(
            cantParseException(
                LANGUAGE_NAME_FIELD_NAME
            )
        )
    }

    private fun parseTitle(jsonTitle: JsonElement): String = asString(jsonTitle)

    private fun parseEdition(jsonEdition: JsonElement): String = asString(jsonEdition)

    private fun parseISBN13(jsonISBN13: JsonElement): String {
        val first: JsonElement? = asJsonArray(jsonISBN13).firstOrNull()
        if (first == null) {
            throw cantParseException(ISBN_FIELD_NAMES[0])()
        } else return asString(first)
    }

    //parses the list of authors from the book json
    @RequiresApi(Build.VERSION_CODES.N)
    private fun parseAuthors(jsonAuthors: JsonElement): List<String> {
        return asJsonArray(jsonAuthors)
            .iterator().asSequence()
            .map {
                val authorOption = getJsonField(asJsonObject(it), "key")
                val authorJson =
                    authorOption.orElseThrow(cantParseException("$AUTHORS_FIELD_NAME[n].key"))
                asString(authorJson)
            }
            .toList()
    }

    //parses the list of languages from the book json
    private fun parseLanguages(jsonLanguages: JsonElement): String? {
        return asJsonArray(jsonLanguages)
            .firstOrNull()
            ?.let {
                val languageOption = getJsonField(asJsonObject(it), "key")
                val languageJson =
                    languageOption.orElseThrow(cantParseException("$LANGUAGE_FIELD_NAME[0].key"))
                asString(languageJson)
            }
    }

    private fun parseFormat(jsonFormat: JsonElement): String = asString(jsonFormat)

    private fun parsePublisher(jsonPublisher: JsonElement): String? {
        val first: JsonElement? = asJsonArray(jsonPublisher).firstOrNull()
        return first?.let { asString(it) }
    }

    @SuppressLint("SimpleDateFormat")
    private fun parsePublishDate(jsonPublishDate: JsonElement): Date {
        val dateString = asString(jsonPublishDate)
        val dateFormat1 = SimpleDateFormat(DATE_FORMAT)
        val dateFormat2 = SimpleDateFormat(DATE_FORMAT2)
        dateFormat1.isLenient = false
        dateFormat2.isLenient = false
        return try {
            dateFormat1.parse(dateString)!!
        } catch (e: java.text.ParseException) {
            dateFormat2.parse(dateString)!!
        }
    }

    private fun asJsonObject(jsonElement: JsonElement): JsonObject {
        if (!jsonElement.isJsonObject) {
            throw DatabaseException(errorMessage + "Json is not a JsonObject")
        }
        return jsonElement.asJsonObject!!
    }

    private fun asJsonArray(jsonElement: JsonElement): JsonArray {
        if (!jsonElement.isJsonArray) {
            throw DatabaseException(errorMessage + "Json is not a JsonArray")
        }
        return jsonElement.asJsonArray!!
    }

    private fun asString(jsonElement: JsonElement): String {
        if (!jsonElement.isJsonPrimitive) {
            throw DatabaseException(errorMessage + "Json is not a JsonPrimitive")
        }
        val primitive = jsonElement.asJsonPrimitive!!
        if (!primitive.isString) {
            throw DatabaseException(errorMessage + "Json is not a String")
        }
        return primitive.asString!!
    }

    //try to access a field of a json object and return an optional instead of a nullable
    @RequiresApi(Build.VERSION_CODES.N)
    private fun getJsonField(jsonObject: JsonObject, fieldName: String): Optional<JsonElement> {
        return Optional.ofNullable(jsonObject.get(fieldName))
    }

    //try to access a field of a json object and return an optional instead of a nullable
    @RequiresApi(Build.VERSION_CODES.N)
    private fun getJsonFields(
        jsonObject: JsonObject,
        fieldNames: List<String>
    ): Optional<JsonElement> {
        for (field in fieldNames) {
            if (jsonObject.get(field) != null) {
                return Optional.ofNullable(jsonObject.get(field))
            }
        }
        return Optional.empty()
    }

    private fun cantParseException(fieldName: String): () -> Exception {
        return { DatabaseException("$errorMessage: Json has no field $fieldName.") }
    }
}