From de30b3f638e38657337ce0a2f530f464397855da Mon Sep 17 00:00:00 2001 From: "Erik C. Thauvin" Date: Sat, 8 Oct 2022 22:36:01 -0700 Subject: [PATCH] Moved Kotlin-only functions to top-level --- README.md | 8 +- detekt-baseline.xml | 32 +- pom.xml | 2 +- .../net/thauvin/erik/jokeapi/JokeApi.kt | 491 +++++++----------- .../net/thauvin/erik/jokeapi/JokeConfig.kt | 9 +- .../erik/jokeapi/exceptions/JokeException.kt | 2 +- .../kotlin/net/thauvin/erik/jokeapi/util.kt | 172 ++++++ .../thauvin/erik/jokeapi/ExceptionsTest.kt | 34 +- .../net/thauvin/erik/jokeapi/GetJokeTest.kt | 1 - .../net/thauvin/erik/jokeapi/GetJokesTest.kt | 7 +- .../thauvin/erik/jokeapi/GetRawJokesTest.kt | 10 +- .../thauvin/erik/jokeapi/JokeConfigTest.kt | 50 ++ .../net/thauvin/erik/jokeapi/UtilTest.kt | 73 +++ 13 files changed, 525 insertions(+), 366 deletions(-) create mode 100644 src/main/kotlin/net/thauvin/erik/jokeapi/util.kt create mode 100644 src/test/kotlin/net/thauvin/erik/jokeapi/UtilTest.kt diff --git a/README.md b/README.md index 50626c2..baab6e8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A simple Kotlin/Java library to retrieve jokes from [Sv443's JokeAPI](https://v2 ## Examples (TL;DR) ```kotlin -import net.thauvin.erik.jokeapi.JokeApi.Companion.getJoke +import net.thauvin.erik.jokeapi.getJoke val joke = getJoke() val safe = getJoke(safe = true) @@ -124,9 +124,7 @@ var config = new JokeConfig.Builder() .safe(true) .build(); var joke = JokeApi.getJoke(config); -for (var j : joke.getJoke()) { - System.out.println(j); -} +joke.getJoke().forEach(System.out::println); ``` ## Extending @@ -136,7 +134,7 @@ A generic `apiCall()` function is available to access other [JokeAPI endpoints]( For example to retrieve the French [language code](https://v2.jokeapi.dev/#langcode-endpoint): ```kotlin -val lang = apiCall( +val lang = JokeApi.apiCall( endPoint = "langcode", path = "french", params = mapOf(Parameter.FORMAT to Format.YAML.value) diff --git a/detekt-baseline.xml b/detekt-baseline.xml index 8f4c3a4..7a212fe 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -2,24 +2,22 @@ - ComplexMethod:JokeApi.kt$JokeApi.Companion$@JvmStatic @Throws(HttpErrorException::class, IOException::class, IllegalArgumentException::class) fun getRawJokes( categories: Set<Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set<Flag> = emptySet(), type: Type = Type.ALL, format: Format = Format.JSON, search: String = "", idRange: IdRange = IdRange(), amount: Int = 1, safe: Boolean = false, ): String - LongParameterList:JokeApi.kt$JokeApi.Companion$( amount: Int, categories: Set<Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set<Flag> = emptySet(), type: Type = Type.ALL, search: String = "", idRange: IdRange = IdRange(), safe: Boolean = false, splitNewLine: Boolean = false ) - LongParameterList:JokeApi.kt$JokeApi.Companion$( categories: Set<Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set<Flag> = emptySet(), type: Type = Type.ALL, format: Format = Format.JSON, search: String = "", idRange: IdRange = IdRange(), amount: Int = 1, safe: Boolean = false, ) - LongParameterList:JokeApi.kt$JokeApi.Companion$( categories: Set<Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set<Flag> = emptySet(), type: Type = Type.ALL, search: String = "", idRange: IdRange = IdRange(), safe: Boolean = false, splitNewLine: Boolean = false ) - LongParameterList:JokeConfig.kt$JokeConfig$( val categories: Set<Category>, val language: Language, val flags: Set<Flag>, val type: Type, val format: Format, val search: String, val idRange: IdRange, val amount: Int = 1, val safe: Boolean, val splitNewLine: Boolean, ) + ComplexMethod:JokeApi.kt$fun getRawJokes( categories: Set<Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set<Flag> = emptySet(), type: Type = Type.ALL, format: Format = Format.JSON, search: String = "", idRange: IdRange = IdRange(), amount: Int = 1, safe: Boolean = false, auth: String = "" ): String + LongParameterList:JokeApi.kt$( amount: Int, categories: Set<Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set<Flag> = emptySet(), type: Type = Type.ALL, search: String = "", idRange: IdRange = IdRange(), safe: Boolean = false, splitNewLine: Boolean = false, auth: String = "" ) + LongParameterList:JokeApi.kt$( categories: Set<Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set<Flag> = emptySet(), type: Type = Type.ALL, format: Format = Format.JSON, search: String = "", idRange: IdRange = IdRange(), amount: Int = 1, safe: Boolean = false, auth: String = "" ) + LongParameterList:JokeApi.kt$( categories: Set<Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set<Flag> = emptySet(), type: Type = Type.ALL, search: String = "", idRange: IdRange = IdRange(), safe: Boolean = false, splitNewLine: Boolean = false, auth: String = "" ) + LongParameterList:JokeConfig.kt$JokeConfig$( val categories: Set<Category>, val language: Language, val flags: Set<Flag>, val type: Type, val format: Format, val search: String, val idRange: IdRange, val amount: Int, val safe: Boolean, val splitNewLine: Boolean, val auth: String ) LongParameterList:JokeException.kt$JokeException$( val internalError: Boolean, val code: Int, message: String, val causedBy: List<String>, val additionalInfo: String, val timestamp: Long, cause: Throwable? = null ) - MagicNumber:JokeApi.kt$JokeApi.Companion$200 - MagicNumber:JokeApi.kt$JokeApi.Companion$399 - MagicNumber:JokeApi.kt$JokeApi.Companion$400 - MagicNumber:JokeApi.kt$JokeApi.Companion$403 - MagicNumber:JokeApi.kt$JokeApi.Companion$404 - MagicNumber:JokeApi.kt$JokeApi.Companion$413 - MagicNumber:JokeApi.kt$JokeApi.Companion$414 - MagicNumber:JokeApi.kt$JokeApi.Companion$429 - MagicNumber:JokeApi.kt$JokeApi.Companion$500 - MagicNumber:JokeApi.kt$JokeApi.Companion$523 - TooManyFunctions:JokeApi.kt$JokeApi$Companion + MagicNumber:util.kt$200 + MagicNumber:util.kt$399 + MagicNumber:util.kt$400 + MagicNumber:util.kt$403 + MagicNumber:util.kt$404 + MagicNumber:util.kt$413 + MagicNumber:util.kt$414 + MagicNumber:util.kt$429 + MagicNumber:util.kt$500 + MagicNumber:util.kt$523 TooManyFunctions:JokeConfig.kt$JokeConfig$Builder - UtilityClassWithPublicConstructor:JokeApi.kt$JokeApi diff --git a/pom.xml b/pom.xml index 2a6b152..76f3c72 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ jokeapi 0.9-SNAPSHOT jokeapi - Kotlin/Java Wrapper for Sv443's JokeApi + Kotlin/Java Wrapper for Sv443's JokeAPI https://github.com/ethauvin/jokeapi diff --git a/src/main/kotlin/net/thauvin/erik/jokeapi/JokeApi.kt b/src/main/kotlin/net/thauvin/erik/jokeapi/JokeApi.kt index be136c0..bd850fd 100644 --- a/src/main/kotlin/net/thauvin/erik/jokeapi/JokeApi.kt +++ b/src/main/kotlin/net/thauvin/erik/jokeapi/JokeApi.kt @@ -43,22 +43,17 @@ import net.thauvin.erik.jokeapi.models.Language import net.thauvin.erik.jokeapi.models.Parameter import net.thauvin.erik.jokeapi.models.Type import org.json.JSONObject -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL import java.net.URLEncoder import java.nio.charset.StandardCharsets -import java.util.logging.Level import java.util.logging.Logger import java.util.stream.Collectors /** * Implements the [Sv443's JokeAPI](https://jokeapi.dev/). */ -class JokeApi { +class JokeApi private constructor() { companion object { private const val API_URL = "https://v2.jokeapi.dev/" - private const val JOKE_ENDPOINT = "joke" @JvmStatic val logger: Logger by lazy { Logger.getLogger(JokeApi::class.java.simpleName) } @@ -70,8 +65,13 @@ class JokeApi { */ @JvmStatic @JvmOverloads - @Throws(HttpErrorException::class, IOException::class) - fun apiCall(endPoint: String, path: String = "", params: Map = emptyMap()): String { + @Throws(HttpErrorException::class) + fun apiCall( + endPoint: String, + path: String = "", + params: Map = emptyMap(), + auth: String = "" + ): String { val urlBuilder = StringBuilder("$API_URL$endPoint") if (path.isNotEmpty()) { @@ -98,99 +98,16 @@ class JokeApi { } } } - return fetchUrl(urlBuilder.toString()) - } - - /** - * Returns one or more jokes. - * - * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. - * @see [getJoke] - */ - @JvmStatic - @Throws(HttpErrorException::class, IOException::class, IllegalArgumentException::class) - fun getRawJokes( - categories: Set = setOf(Category.ANY), - language: Language = Language.ENGLISH, - flags: Set = emptySet(), - type: Type = Type.ALL, - format: Format = Format.JSON, - search: String = "", - idRange: IdRange = IdRange(), - amount: Int = 1, - safe: Boolean = false, - ): String { - val params = mutableMapOf() - - // Categories - val path = if (!categories.contains(Category.ANY)) { - categories.stream().map(Category::value).collect(Collectors.joining(",")) - } else { - Category.ANY.value - } - - // Language - if (language != Language.ENGLISH) { - params[Parameter.LANG] = language.value - } - - // Flags - if (flags.isNotEmpty()) { - if (flags.contains(Flag.ALL)) { - params[Parameter.FLAGS] = Flag.ALL.value - } else { - params[Parameter.FLAGS] = flags.stream().map(Flag::value).collect(Collectors.joining(",")) - } - } - - // Type - if (type != Type.ALL) { - params[Parameter.TYPE] = type.value - } - - // Format - if (format != Format.JSON) { - params[Parameter.FORMAT] = format.value - } - - // Contains - if (search.isNotBlank()) { - params[Parameter.CONTAINS] = search - } - - // Range - if (idRange.start >= 0) { - if (idRange.end == -1 || idRange.start == idRange.end) { - params[Parameter.RANGE] = idRange.start.toString() - } else if (idRange.end > idRange.start) { - params[Parameter.RANGE] = "${idRange.start}-${idRange.end}" - } else { - throw IllegalArgumentException("Invalid ID Range: ${idRange.start}, ${idRange.end}") - } - } - - // Amount - if (amount > 1) { - params[Parameter.AMOUNT] = amount.toString() - } else if (amount <= 0) { - throw IllegalArgumentException("Invalid Amount: $amount") - } - - // Safe - if (safe) { - params[Parameter.SAFE] = "" - } - - return apiCall(JOKE_ENDPOINT, path, params) + return fetchUrl(urlBuilder.toString(), auth) } /** * Returns one or more jokes using a [configuration][JokeConfig]. * - * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. + * See the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. */ @JvmStatic - @Throws(HttpErrorException::class, IOException::class, IllegalArgumentException::class) + @Throws(HttpErrorException::class) fun getRawJokes(config: JokeConfig): String { return getRawJokes( categories = config.categories, @@ -201,181 +118,20 @@ class JokeApi { search = config.search, idRange = config.idRange, amount = config.amount, - safe = config.safe + safe = config.safe, + auth = config.auth ) } - @Throws(HttpErrorException::class, IOException::class) - internal fun fetchUrl(url: String): String { - if (logger.isLoggable(Level.FINE)) { - logger.fine(url) - } - - val connection = URL(url).openConnection() as HttpURLConnection - connection.setRequestProperty( - "User-Agent", "Mozilla/5.0 (Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0" - ) - - if (connection.responseCode in 200..399) { - val body = connection.inputStream.bufferedReader().readText() - if (logger.isLoggable(Level.FINE)) { - logger.fine(body) - } - return body - } else { - throw httpError(connection.responseCode) - } - } - - private fun httpError(responseCode: Int): HttpErrorException { - val httpException: HttpErrorException - when (responseCode) { - 400 -> httpException = HttpErrorException( - responseCode, "Bad Request", IOException( - "The request you have sent to JokeAPI is formatted incorrectly and cannot be processed." - ) - ) - - 403 -> httpException = HttpErrorException( - responseCode, "Forbidden", IOException( - "You have been added to the blacklist due to malicious behavior and are not allowed" - + " to send requests to JokeAPI anymore." - ) - ) - - 404 -> httpException = HttpErrorException( - responseCode, "Not Found", IOException("The URL you have requested couldn't be found.") - ) - - 413 -> httpException = HttpErrorException( - responseCode, "URI Too Long", IOException("The URL exceeds the maximum length of 250 characters.") - ) - - 414 -> httpException = HttpErrorException( - responseCode, - "Payload Too Large", - IOException("The payload data sent to the server exceeds the maximum size of 5120 bytes.") - ) - - 429 -> httpException = HttpErrorException( - responseCode, "Too Many Requests", IOException( - "You have exceeded the limit of 120 requests per minute and have to wait a bit" - + " until you are allowed to send requests again." - ) - ) - - 500 -> httpException = HttpErrorException( - responseCode, "Internal Server Error", IOException( - "There was a general internal error within JokeAPI. You can get more info from" - + " the properties in the response text." - ) - ) - - 523 -> httpException = HttpErrorException( - responseCode, "Origin Unreachable", IOException( - "The server is temporarily offline due to maintenance or a dynamic IP update." - + " Please be patient in this case." - ) - ) - - else -> httpException = HttpErrorException(responseCode, "Unknown HTTP Error") - } - - return httpException - } - - /** - * Returns a [Joke] instance. - * - * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. - * - * @param splitNewLine Split newline within [Type.SINGLE] joke. - * @see [getRawJokes] - */ - @JvmStatic - @Throws(JokeException::class, HttpErrorException::class, IOException::class, IllegalArgumentException::class) - fun getJoke( - categories: Set = setOf(Category.ANY), - language: Language = Language.ENGLISH, - flags: Set = emptySet(), - type: Type = Type.ALL, - search: String = "", - idRange: IdRange = IdRange(), - safe: Boolean = false, - splitNewLine: Boolean = false - ): Joke { - val json = JSONObject( - getRawJokes( - categories = categories, - language = language, - flags = flags, - type = type, - search = search, - idRange = idRange, - safe = safe - ) - ) - if (json.getBoolean("error")) { - throw parseError(json) - } else { - return parseJoke(json, splitNewLine) - } - } - - /** - * Returns an array of [Joke] instances. - * - * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. - * - * @param amount The required amount of jokes to return. - * @param splitNewLine Split newline within [Type.SINGLE] joke. - * @see [getRawJokes] - */ - @JvmStatic - @Throws(JokeException::class, HttpErrorException::class, IOException::class, IllegalArgumentException::class) - fun getJokes( - amount: Int, - categories: Set = setOf(Category.ANY), - language: Language = Language.ENGLISH, - flags: Set = emptySet(), - type: Type = Type.ALL, - search: String = "", - idRange: IdRange = IdRange(), - safe: Boolean = false, - splitNewLine: Boolean = false - ): Array { - val json = JSONObject( - getRawJokes( - categories = categories, - language = language, - flags = flags, - type = type, - search = search, - idRange = idRange, - amount = amount, - safe = safe - ) - ) - if (json.getBoolean("error")) { - throw parseError(json) - } else { - return if (json.has("amount")) { - val jokes = json.getJSONArray("jokes") - Array(jokes.length()) { i -> parseJoke(jokes.getJSONObject(i), splitNewLine) } - } else { - arrayOf(parseJoke(json, splitNewLine)) - } - } - } - /** * Retrieve a [Joke] instance using a [configuration][JokeConfig]. * * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. */ @JvmStatic - @Throws(JokeException::class, HttpErrorException::class, IOException::class, IllegalArgumentException::class) - fun getJoke(config: JokeConfig): Joke { + @JvmOverloads + @Throws(HttpErrorException::class, JokeException::class) + fun getJoke(config: JokeConfig = JokeConfig.Builder().build()): Joke { return getJoke( categories = config.categories, language = config.language, @@ -384,7 +140,8 @@ class JokeApi { search = config.search, idRange = config.idRange, safe = config.safe, - splitNewLine = config.splitNewLine + splitNewLine = config.splitNewLine, + auth = config.auth ) } @@ -394,7 +151,7 @@ class JokeApi { * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. */ @JvmStatic - @Throws(JokeException::class, HttpErrorException::class, IOException::class, IllegalArgumentException::class) + @Throws(HttpErrorException::class, JokeException::class) fun getJokes(config: JokeConfig): Array { return getJokes( categories = config.categories, @@ -405,51 +162,173 @@ class JokeApi { idRange = config.idRange, amount = config.amount, safe = config.safe, - splitNewLine = config.splitNewLine - ) - } - - private fun parseError(json: JSONObject): JokeException { - val causedBy = json.getJSONArray("causedBy") - val causes = List(causedBy.length()) { i -> causedBy.getString(i) } - return JokeException( - internalError = json.getBoolean("internalError"), - code = json.getInt("code"), - message = json.getString("message"), - causedBy = causes, - additionalInfo = json.getString("additionalInfo"), - timestamp = json.getLong("timestamp") - ) - } - - private fun parseJoke(json: JSONObject, splitNewLine: Boolean): Joke { - val jokes = mutableListOf() - if (json.has("setup")) { - jokes.add(json.getString("setup")) - jokes.add(json.getString(("delivery"))) - } else { - if (splitNewLine) { - jokes.addAll(json.getString("joke").split("\n")) - } else { - jokes.add(json.getString("joke")) - } - } - val enabledFlags = mutableSetOf() - val jsonFlags = json.getJSONObject("flags") - Flag.values().filter { it != Flag.ALL }.forEach { - if (jsonFlags.has(it.value) && jsonFlags.getBoolean(it.value)) { - enabledFlags.add(it) - } - } - return Joke( - category = Category.valueOf(json.getString("category").uppercase()), - type = Type.valueOf(json.getString(Parameter.TYPE).uppercase()), - joke = jokes, - flags = enabledFlags, - safe = json.getBoolean("safe"), - id = json.getInt("id"), - language = Language.valueOf(json.getString(Parameter.LANG).uppercase()) + splitNewLine = config.splitNewLine, + auth = config.auth ) } } } + + +/** + * Returns a [Joke] instance. + * + * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. + * + * @param splitNewLine Split newline within [Type.SINGLE] joke. + */ +fun getJoke( + categories: Set = setOf(Category.ANY), + language: Language = Language.ENGLISH, + flags: Set = emptySet(), + type: Type = Type.ALL, + search: String = "", + idRange: IdRange = IdRange(), + safe: Boolean = false, + splitNewLine: Boolean = false, + auth: String = "" +): Joke { + val json = JSONObject( + getRawJokes( + categories = categories, + language = language, + flags = flags, + type = type, + search = search, + idRange = idRange, + safe = safe, + auth = auth + ) + ) + if (json.getBoolean("error")) { + throw parseError(json) + } else { + return parseJoke(json, splitNewLine) + } +} + +/** + * Returns an array of [Joke] instances. + * + * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. + * + * @param amount The required amount of jokes to return. + * @param splitNewLine Split newline within [Type.SINGLE] joke. + */ +fun getJokes( + amount: Int, + categories: Set = setOf(Category.ANY), + language: Language = Language.ENGLISH, + flags: Set = emptySet(), + type: Type = Type.ALL, + search: String = "", + idRange: IdRange = IdRange(), + safe: Boolean = false, + splitNewLine: Boolean = false, + auth: String = "" +): Array { + val json = JSONObject( + getRawJokes( + categories = categories, + language = language, + flags = flags, + type = type, + search = search, + idRange = idRange, + amount = amount, + safe = safe, + auth = auth + ) + ) + if (json.getBoolean("error")) { + throw parseError(json) + } else { + return if (json.has("amount")) { + val jokes = json.getJSONArray("jokes") + Array(jokes.length()) { i -> parseJoke(jokes.getJSONObject(i), splitNewLine) } + } else { + arrayOf(parseJoke(json, splitNewLine)) + } + } +} + +/** + * Returns one or more jokes. + * + * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. + */ +fun getRawJokes( + categories: Set = setOf(Category.ANY), + language: Language = Language.ENGLISH, + flags: Set = emptySet(), + type: Type = Type.ALL, + format: Format = Format.JSON, + search: String = "", + idRange: IdRange = IdRange(), + amount: Int = 1, + safe: Boolean = false, + auth: String = "" +): String { + val params = mutableMapOf() + + // Categories + val path = if (!categories.contains(Category.ANY)) { + categories.stream().map(Category::value).collect(Collectors.joining(",")) + } else { + Category.ANY.value + } + + // Language + if (language != Language.ENGLISH) { + params[Parameter.LANG] = language.value + } + + // Flags + if (flags.isNotEmpty()) { + if (flags.contains(Flag.ALL)) { + params[Parameter.FLAGS] = Flag.ALL.value + } else { + params[Parameter.FLAGS] = flags.stream().map(Flag::value).collect(Collectors.joining(",")) + } + } + + // Type + if (type != Type.ALL) { + params[Parameter.TYPE] = type.value + } + + // Format + if (format != Format.JSON) { + params[Parameter.FORMAT] = format.value + } + + // Contains + if (search.isNotBlank()) { + params[Parameter.CONTAINS] = search + } + + // Range + if (idRange.start >= 0) { + if (idRange.end == -1 || idRange.start == idRange.end) { + params[Parameter.RANGE] = idRange.start.toString() + } else if (idRange.end > idRange.start) { + params[Parameter.RANGE] = "${idRange.start}-${idRange.end}" + } else { + throw IllegalArgumentException("Invalid ID Range: ${idRange.start}, ${idRange.end}") + } + } + + // Amount + if (amount > 1) { + params[Parameter.AMOUNT] = amount.toString() + } else if (amount <= 0) { + throw IllegalArgumentException("Invalid Amount: $amount") + } + + // Safe + if (safe) { + params[Parameter.SAFE] = "" + } + + return JokeApi.apiCall("joke", path, params, auth) +} diff --git a/src/main/kotlin/net/thauvin/erik/jokeapi/JokeConfig.kt b/src/main/kotlin/net/thauvin/erik/jokeapi/JokeConfig.kt index f27132e..ff6d83c 100644 --- a/src/main/kotlin/net/thauvin/erik/jokeapi/JokeConfig.kt +++ b/src/main/kotlin/net/thauvin/erik/jokeapi/JokeConfig.kt @@ -53,9 +53,10 @@ class JokeConfig private constructor( val format: Format, val search: String, val idRange: IdRange, - val amount: Int = 1, + val amount: Int, val safe: Boolean, val splitNewLine: Boolean, + val auth: String ) { /** * [Builds][build] a new configuration. @@ -74,7 +75,8 @@ class JokeConfig private constructor( var idRange: IdRange = IdRange(), var amount: Int = 1, var safe: Boolean = false, - var splitNewLine: Boolean = false + var splitNewLine: Boolean = false, + var auth: String = "" ) { fun categories(categories: Set) = apply { this.categories = categories } fun language(language: Language) = apply { this.language = language } @@ -86,9 +88,10 @@ class JokeConfig private constructor( fun amount(amount: Int) = apply { this.amount = amount } fun safe(safe: Boolean) = apply { this.safe = safe } fun splitNewLine(splitNewLine: Boolean) = apply { this.splitNewLine = splitNewLine } + fun auth(auth: String) = apply { this.auth = auth } fun build() = JokeConfig( - categories, language, flags, type, format, search, idRange, amount, safe, splitNewLine + categories, language, flags, type, format, search, idRange, amount, safe, splitNewLine, auth ) } } diff --git a/src/main/kotlin/net/thauvin/erik/jokeapi/exceptions/JokeException.kt b/src/main/kotlin/net/thauvin/erik/jokeapi/exceptions/JokeException.kt index 29d7849..84b37fc 100644 --- a/src/main/kotlin/net/thauvin/erik/jokeapi/exceptions/JokeException.kt +++ b/src/main/kotlin/net/thauvin/erik/jokeapi/exceptions/JokeException.kt @@ -45,7 +45,7 @@ class JokeException @JvmOverloads constructor( val additionalInfo: String, val timestamp: Long, cause: Throwable? = null -) : Exception(message, cause) { +) : RuntimeException(message, cause) { companion object { private const val serialVersionUID = 1L } diff --git a/src/main/kotlin/net/thauvin/erik/jokeapi/util.kt b/src/main/kotlin/net/thauvin/erik/jokeapi/util.kt new file mode 100644 index 0000000..cb3b4b6 --- /dev/null +++ b/src/main/kotlin/net/thauvin/erik/jokeapi/util.kt @@ -0,0 +1,172 @@ +/* + * util.kt + * + * Copyright (c) 2022, Erik C. Thauvin (erik@thauvin.net) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * Neither the name of this project nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package net.thauvin.erik.jokeapi + +import net.thauvin.erik.jokeapi.exceptions.HttpErrorException +import net.thauvin.erik.jokeapi.exceptions.JokeException +import net.thauvin.erik.jokeapi.models.Category +import net.thauvin.erik.jokeapi.models.Flag +import net.thauvin.erik.jokeapi.models.Joke +import net.thauvin.erik.jokeapi.models.Language +import net.thauvin.erik.jokeapi.models.Parameter +import net.thauvin.erik.jokeapi.models.Type +import org.json.JSONObject +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.logging.Level + +internal fun fetchUrl(url: String, auth: String = ""): String { + if (JokeApi.logger.isLoggable(Level.FINE)) { + JokeApi.logger.fine(url) + } + + val connection = URL(url).openConnection() as HttpURLConnection + connection.setRequestProperty( + "User-Agent", "Mozilla/5.0 (Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0" + ) + if (auth.isNotEmpty()) { + connection.setRequestProperty("Authentication", auth) + } + + if (connection.responseCode in 200..399) { + val body = connection.inputStream.bufferedReader().readText() + if (JokeApi.logger.isLoggable(Level.FINE)) { + JokeApi.logger.fine(body) + } + return body + } else { + throw httpError(connection.responseCode) + } +} + +private fun httpError(responseCode: Int): HttpErrorException { + val httpException: HttpErrorException + when (responseCode) { + 400 -> httpException = HttpErrorException( + responseCode, "Bad Request", IOException( + "The request you have sent to JokeAPI is formatted incorrectly and cannot be processed." + ) + ) + + 403 -> httpException = HttpErrorException( + responseCode, "Forbidden", IOException( + "You have been added to the blacklist due to malicious behavior and are not allowed" + + " to send requests to JokeAPI anymore." + ) + ) + + 404 -> httpException = HttpErrorException( + responseCode, "Not Found", IOException("The URL you have requested couldn't be found.") + ) + + 413 -> httpException = HttpErrorException( + responseCode, "URI Too Long", IOException("The URL exceeds the maximum length of 250 characters.") + ) + + 414 -> httpException = HttpErrorException( + responseCode, + "Payload Too Large", + IOException("The payload data sent to the server exceeds the maximum size of 5120 bytes.") + ) + + 429 -> httpException = HttpErrorException( + responseCode, "Too Many Requests", IOException( + "You have exceeded the limit of 120 requests per minute and have to wait a bit" + + " until you are allowed to send requests again." + ) + ) + + 500 -> httpException = HttpErrorException( + responseCode, "Internal Server Error", IOException( + "There was a general internal error within JokeAPI. You can get more info from" + + " the properties in the response text." + ) + ) + + 523 -> httpException = HttpErrorException( + responseCode, "Origin Unreachable", IOException( + "The server is temporarily offline due to maintenance or a dynamic IP update." + + " Please be patient in this case." + ) + ) + + else -> httpException = HttpErrorException(responseCode, "Unknown HTTP Error") + } + + return httpException +} + +internal fun parseError(json: JSONObject): JokeException { + val causedBy = json.getJSONArray("causedBy") + val causes = List(causedBy.length()) { i -> causedBy.getString(i) } + return JokeException( + internalError = json.getBoolean("internalError"), + code = json.getInt("code"), + message = json.getString("message"), + causedBy = causes, + additionalInfo = json.getString("additionalInfo"), + timestamp = json.getLong("timestamp") + ) +} + +internal fun parseJoke(json: JSONObject, splitNewLine: Boolean): Joke { + val jokes = mutableListOf() + if (json.has("setup")) { + jokes.add(json.getString("setup")) + jokes.add(json.getString(("delivery"))) + } else { + if (splitNewLine) { + jokes.addAll(json.getString("joke").split("\n")) + } else { + jokes.add(json.getString("joke")) + } + } + val enabledFlags = mutableSetOf() + val jsonFlags = json.getJSONObject("flags") + Flag.values().filter { it != Flag.ALL }.forEach { + if (jsonFlags.has(it.value) && jsonFlags.getBoolean(it.value)) { + enabledFlags.add(it) + } + } + return Joke( + category = Category.valueOf(json.getString("category").uppercase()), + type = Type.valueOf(json.getString(Parameter.TYPE).uppercase()), + joke = jokes, + flags = enabledFlags, + safe = json.getBoolean("safe"), + id = json.getInt("id"), + language = Language.valueOf(json.getString(Parameter.LANG).uppercase()) + ) +} + diff --git a/src/test/kotlin/net/thauvin/erik/jokeapi/ExceptionsTest.kt b/src/test/kotlin/net/thauvin/erik/jokeapi/ExceptionsTest.kt index e0a68d3..cfff0c8 100644 --- a/src/test/kotlin/net/thauvin/erik/jokeapi/ExceptionsTest.kt +++ b/src/test/kotlin/net/thauvin/erik/jokeapi/ExceptionsTest.kt @@ -44,8 +44,6 @@ import assertk.assertions.isNull import assertk.assertions.prop import assertk.assertions.size import assertk.assertions.startsWith -import net.thauvin.erik.jokeapi.JokeApi.Companion.fetchUrl -import net.thauvin.erik.jokeapi.JokeApi.Companion.getJoke import net.thauvin.erik.jokeapi.JokeApi.Companion.logger import net.thauvin.erik.jokeapi.exceptions.HttpErrorException import net.thauvin.erik.jokeapi.exceptions.JokeException @@ -59,8 +57,6 @@ import java.util.logging.ConsoleHandler import java.util.logging.Level internal class ExceptionsTest { - private val httpStat = "https://httpstat.us" - @Test fun `Validate Joke Exception`() { val e = assertThrows { @@ -78,29 +74,19 @@ internal class ExceptionsTest { } } - @ParameterizedTest(name = "HTTP Status Code: {0}") - @ValueSource(ints = [400, 404, 403, 413, 414, 429, 500, 523]) - fun `Validate HTTP Error Exceptions`(input: Int) { + @ParameterizedTest + @ValueSource(ints = [400, 404, 403, 413, 414, 429, 500, 523, 666]) + fun `Validate HTTP Exceptions`(code: Int) { val e = assertThrows { - fetchUrl("$httpStat/$input") + fetchUrl("https://httpstat.us/$code") } - assertThat(e, "fetchUrl($httpStat/$input)").all { - prop(HttpErrorException::statusCode).isEqualTo(input) + assertThat(e, "fetchUrl($code)").all { + prop(HttpErrorException::statusCode).isEqualTo(code) prop(HttpErrorException::message).isNotNull().isNotEmpty() - prop(HttpErrorException::cause).isNotNull().assertThat(Throwable::message).isNotNull() - } - } - - @Test - fun `Fetch Invalid URL`() { - val statusCode = 999 - val e = assertThrows { - fetchUrl("$httpStat/$statusCode") - } - assertThat(e, "fetchUrl($httpStat/$statusCode).statusCode").all { - prop(HttpErrorException::statusCode).isEqualTo(statusCode) - prop(HttpErrorException::message).isNotNull().isNotEmpty() - prop(HttpErrorException::cause).isNull() + if (code < 600) + prop(HttpErrorException::cause).isNotNull().assertThat(Throwable::message).isNotNull() + else + prop(HttpErrorException::cause).isNull() } } diff --git a/src/test/kotlin/net/thauvin/erik/jokeapi/GetJokeTest.kt b/src/test/kotlin/net/thauvin/erik/jokeapi/GetJokeTest.kt index ea0a740..857949f 100644 --- a/src/test/kotlin/net/thauvin/erik/jokeapi/GetJokeTest.kt +++ b/src/test/kotlin/net/thauvin/erik/jokeapi/GetJokeTest.kt @@ -49,7 +49,6 @@ import assertk.assertions.isNotNull import assertk.assertions.isTrue import assertk.assertions.prop import assertk.assertions.size -import net.thauvin.erik.jokeapi.JokeApi.Companion.getJoke import net.thauvin.erik.jokeapi.JokeApi.Companion.logger import net.thauvin.erik.jokeapi.exceptions.JokeException import net.thauvin.erik.jokeapi.models.Category diff --git a/src/test/kotlin/net/thauvin/erik/jokeapi/GetJokesTest.kt b/src/test/kotlin/net/thauvin/erik/jokeapi/GetJokesTest.kt index 489aa74..bbc0bd5 100644 --- a/src/test/kotlin/net/thauvin/erik/jokeapi/GetJokesTest.kt +++ b/src/test/kotlin/net/thauvin/erik/jokeapi/GetJokesTest.kt @@ -1,5 +1,5 @@ /* - * GetJokeTest.kt + * GetJokeTests.kt * * Copyright (c) 2022, Erik C. Thauvin (erik@thauvin.net) * All rights reserved. @@ -44,7 +44,6 @@ import assertk.assertions.isNotNull import assertk.assertions.isTrue import assertk.assertions.prop import assertk.assertions.size -import net.thauvin.erik.jokeapi.JokeApi.Companion.getJokes import net.thauvin.erik.jokeapi.JokeApi.Companion.logger import net.thauvin.erik.jokeapi.models.Joke import net.thauvin.erik.jokeapi.models.Language @@ -79,10 +78,6 @@ internal class GetJokesTest { @Test fun `Get One Joke as Multiple`() { val jokes = getJokes(amount = 1, safe = true) - jokes.forEach { - println(it.joke.joinToString("\n")) - println("-".repeat(46)) - } assertThat(jokes, "jokes").all { size().isEqualTo(1) index(0).all { diff --git a/src/test/kotlin/net/thauvin/erik/jokeapi/GetRawJokesTest.kt b/src/test/kotlin/net/thauvin/erik/jokeapi/GetRawJokesTest.kt index c0f07e1..b745da8 100644 --- a/src/test/kotlin/net/thauvin/erik/jokeapi/GetRawJokesTest.kt +++ b/src/test/kotlin/net/thauvin/erik/jokeapi/GetRawJokesTest.kt @@ -1,5 +1,5 @@ /* - * GetRawJokeTest.kt + * GetRawJokesTest.kt * * Copyright (c) 2022, Erik C. Thauvin (erik@thauvin.net) * All rights reserved. @@ -37,9 +37,9 @@ import assertk.assertThat import assertk.assertions.doesNotContain import assertk.assertions.isNotEmpty import assertk.assertions.startsWith -import net.thauvin.erik.jokeapi.JokeApi.Companion.getRawJokes import net.thauvin.erik.jokeapi.JokeApi.Companion.logger import net.thauvin.erik.jokeapi.models.Format +import net.thauvin.erik.jokeapi.models.IdRange import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import java.util.logging.ConsoleHandler @@ -74,6 +74,12 @@ internal class GetRawJokesTest { assertContains(response, "\"amount\": 2", false, "getRawJoke(2)") } + @Test + fun `Get Raw Invalid Jokes`() { + val response = getRawJokes(search = "foo", safe = true, amount = 2, idRange = IdRange(160, 161)) + assertContains(response, "\"error\": true", false, "getRawJokes(foo)") + } + companion object { @JvmStatic @BeforeAll diff --git a/src/test/kotlin/net/thauvin/erik/jokeapi/JokeConfigTest.kt b/src/test/kotlin/net/thauvin/erik/jokeapi/JokeConfigTest.kt index 2ab52ca..90e7c73 100644 --- a/src/test/kotlin/net/thauvin/erik/jokeapi/JokeConfigTest.kt +++ b/src/test/kotlin/net/thauvin/erik/jokeapi/JokeConfigTest.kt @@ -60,6 +60,15 @@ import java.util.logging.Level import kotlin.test.assertContains class JokeConfigTest { + @Test + fun `Get Joke with Default Builder`() { + val joke = getJoke() + assertThat(joke, "joke").all { + prop(Joke::id).isGreaterThanOrEqualTo(0) + prop(Joke::language).isEqualTo(Language.EN) + } + } + @Test fun `Get Joke with Builder`() { val id = 266 @@ -132,6 +141,47 @@ class JokeConfigTest { } } + @Test + fun `Validate Config`() { + val categories = setOf(Category.ANY) + val language = Language.CS + val flags = setOf(Flag.POLITICAL, Flag.RELIGIOUS) + val type = Type.TWOPART + val format = Format.XML + val search = "foo" + val idRange = IdRange(1, 20) + val amount = 10 + val safe = true + val splitNewLine = true + val auth = "token" + val config = JokeConfig.Builder().apply { + categories(categories) + language(language) + flags(flags) + type(type) + format(format) + search(search) + idRange(idRange) + amount(amount) + safe(safe) + splitNewLine(splitNewLine) + auth(auth) + }.build() + assertThat(config, "config").all { + prop(JokeConfig::categories).isEqualTo(categories) + prop(JokeConfig::language).isEqualTo(language) + prop(JokeConfig::flags).isEqualTo(flags) + prop(JokeConfig::type).isEqualTo(type) + prop(JokeConfig::format).isEqualTo(format) + prop(JokeConfig::search).isEqualTo(search) + prop(JokeConfig::idRange).isEqualTo(idRange) + prop(JokeConfig::amount).isEqualTo(amount) + prop(JokeConfig::safe).isEqualTo(safe) + prop(JokeConfig::splitNewLine).isEqualTo(splitNewLine) + prop(JokeConfig::auth).isEqualTo(auth) + } + } + companion object { @JvmStatic @BeforeAll diff --git a/src/test/kotlin/net/thauvin/erik/jokeapi/UtilTest.kt b/src/test/kotlin/net/thauvin/erik/jokeapi/UtilTest.kt new file mode 100644 index 0000000..34bbedf --- /dev/null +++ b/src/test/kotlin/net/thauvin/erik/jokeapi/UtilTest.kt @@ -0,0 +1,73 @@ +/* + * FetchUrlTest.kt + * + * Copyright (c) 2022, Erik C. Thauvin (erik@thauvin.net) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * Neither the name of this project nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package net.thauvin.erik.jokeapi + +import assertk.assertThat +import assertk.assertions.contains +import org.json.JSONException +import org.json.JSONObject +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.logging.ConsoleHandler +import java.util.logging.Level + +class UtilTest { + @Test + fun `Invalid JSON Error`() { + assertThrows { parseError(JSONObject("{}")) } + } + + @Test + fun `Invalid JSON Joke`() { + assertThrows { parseJoke(JSONObject("{}"), false) } + } + + @Test + fun `Validate Authentication Header`() { + val token = "AUTH-TOKEN" + val body = fetchUrl("https://postman-echo.com/get", token) + assertThat(body, "body").contains("\"authentication\":\"$token\"") + } + + companion object { + @JvmStatic + @BeforeAll + fun beforeAll() { + with(JokeApi.logger) { + addHandler(ConsoleHandler().apply { level = Level.FINE }) + level = Level.FINE + } + } + } +}