Moved Kotlin-only functions to top-level

This commit is contained in:
Erik C. Thauvin 2022-10-08 22:36:01 -07:00
parent 9435df289a
commit de30b3f638
13 changed files with 525 additions and 366 deletions

View file

@ -9,7 +9,7 @@ A simple Kotlin/Java library to retrieve jokes from [Sv443's JokeAPI](https://v2
## Examples (TL;DR) ## Examples (TL;DR)
```kotlin ```kotlin
import net.thauvin.erik.jokeapi.JokeApi.Companion.getJoke import net.thauvin.erik.jokeapi.getJoke
val joke = getJoke() val joke = getJoke()
val safe = getJoke(safe = true) val safe = getJoke(safe = true)
@ -124,9 +124,7 @@ var config = new JokeConfig.Builder()
.safe(true) .safe(true)
.build(); .build();
var joke = JokeApi.getJoke(config); var joke = JokeApi.getJoke(config);
for (var j : joke.getJoke()) { joke.getJoke().forEach(System.out::println);
System.out.println(j);
}
``` ```
## Extending ## 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): For example to retrieve the French [language code](https://v2.jokeapi.dev/#langcode-endpoint):
```kotlin ```kotlin
val lang = apiCall( val lang = JokeApi.apiCall(
endPoint = "langcode", endPoint = "langcode",
path = "french", path = "french",
params = mapOf(Parameter.FORMAT to Format.YAML.value) params = mapOf(Parameter.FORMAT to Format.YAML.value)

View file

@ -2,24 +2,22 @@
<SmellBaseline> <SmellBaseline>
<ManuallySuppressedIssues/> <ManuallySuppressedIssues/>
<CurrentIssues> <CurrentIssues>
<ID>ComplexMethod:JokeApi.kt$JokeApi.Companion$@JvmStatic @Throws(HttpErrorException::class, IOException::class, IllegalArgumentException::class) fun getRawJokes( categories: Set&lt;Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set&lt;Flag> = emptySet(), type: Type = Type.ALL, format: Format = Format.JSON, search: String = "", idRange: IdRange = IdRange(), amount: Int = 1, safe: Boolean = false, ): String</ID> <ID>ComplexMethod:JokeApi.kt$fun getRawJokes( categories: Set&lt;Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set&lt;Flag> = emptySet(), type: Type = Type.ALL, format: Format = Format.JSON, search: String = "", idRange: IdRange = IdRange(), amount: Int = 1, safe: Boolean = false, auth: String = "" ): String</ID>
<ID>LongParameterList:JokeApi.kt$JokeApi.Companion$( amount: Int, categories: Set&lt;Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set&lt;Flag> = emptySet(), type: Type = Type.ALL, search: String = "", idRange: IdRange = IdRange(), safe: Boolean = false, splitNewLine: Boolean = false )</ID> <ID>LongParameterList:JokeApi.kt$( amount: Int, categories: Set&lt;Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set&lt;Flag> = emptySet(), type: Type = Type.ALL, search: String = "", idRange: IdRange = IdRange(), safe: Boolean = false, splitNewLine: Boolean = false, auth: String = "" )</ID>
<ID>LongParameterList:JokeApi.kt$JokeApi.Companion$( categories: Set&lt;Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set&lt;Flag> = emptySet(), type: Type = Type.ALL, format: Format = Format.JSON, search: String = "", idRange: IdRange = IdRange(), amount: Int = 1, safe: Boolean = false, )</ID> <ID>LongParameterList:JokeApi.kt$( categories: Set&lt;Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set&lt;Flag> = emptySet(), type: Type = Type.ALL, format: Format = Format.JSON, search: String = "", idRange: IdRange = IdRange(), amount: Int = 1, safe: Boolean = false, auth: String = "" )</ID>
<ID>LongParameterList:JokeApi.kt$JokeApi.Companion$( categories: Set&lt;Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set&lt;Flag> = emptySet(), type: Type = Type.ALL, search: String = "", idRange: IdRange = IdRange(), safe: Boolean = false, splitNewLine: Boolean = false )</ID> <ID>LongParameterList:JokeApi.kt$( categories: Set&lt;Category> = setOf(Category.ANY), language: Language = Language.ENGLISH, flags: Set&lt;Flag> = emptySet(), type: Type = Type.ALL, search: String = "", idRange: IdRange = IdRange(), safe: Boolean = false, splitNewLine: Boolean = false, auth: String = "" )</ID>
<ID>LongParameterList:JokeConfig.kt$JokeConfig$( val categories: Set&lt;Category>, val language: Language, val flags: Set&lt;Flag>, val type: Type, val format: Format, val search: String, val idRange: IdRange, val amount: Int = 1, val safe: Boolean, val splitNewLine: Boolean, )</ID> <ID>LongParameterList:JokeConfig.kt$JokeConfig$( val categories: Set&lt;Category>, val language: Language, val flags: Set&lt;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 )</ID>
<ID>LongParameterList:JokeException.kt$JokeException$( val internalError: Boolean, val code: Int, message: String, val causedBy: List&lt;String>, val additionalInfo: String, val timestamp: Long, cause: Throwable? = null )</ID> <ID>LongParameterList:JokeException.kt$JokeException$( val internalError: Boolean, val code: Int, message: String, val causedBy: List&lt;String>, val additionalInfo: String, val timestamp: Long, cause: Throwable? = null )</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$200</ID> <ID>MagicNumber:util.kt$200</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$399</ID> <ID>MagicNumber:util.kt$399</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$400</ID> <ID>MagicNumber:util.kt$400</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$403</ID> <ID>MagicNumber:util.kt$403</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$404</ID> <ID>MagicNumber:util.kt$404</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$413</ID> <ID>MagicNumber:util.kt$413</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$414</ID> <ID>MagicNumber:util.kt$414</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$429</ID> <ID>MagicNumber:util.kt$429</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$500</ID> <ID>MagicNumber:util.kt$500</ID>
<ID>MagicNumber:JokeApi.kt$JokeApi.Companion$523</ID> <ID>MagicNumber:util.kt$523</ID>
<ID>TooManyFunctions:JokeApi.kt$JokeApi$Companion</ID>
<ID>TooManyFunctions:JokeConfig.kt$JokeConfig$Builder</ID> <ID>TooManyFunctions:JokeConfig.kt$JokeConfig$Builder</ID>
<ID>UtilityClassWithPublicConstructor:JokeApi.kt$JokeApi</ID>
</CurrentIssues> </CurrentIssues>
</SmellBaseline> </SmellBaseline>

View file

@ -10,7 +10,7 @@
<artifactId>jokeapi</artifactId> <artifactId>jokeapi</artifactId>
<version>0.9-SNAPSHOT</version> <version>0.9-SNAPSHOT</version>
<name>jokeapi</name> <name>jokeapi</name>
<description>Kotlin/Java Wrapper for Sv443's JokeApi</description> <description>Kotlin/Java Wrapper for Sv443's JokeAPI</description>
<url>https://github.com/ethauvin/jokeapi</url> <url>https://github.com/ethauvin/jokeapi</url>
<licenses> <licenses>
<license> <license>

View file

@ -43,22 +43,17 @@ import net.thauvin.erik.jokeapi.models.Language
import net.thauvin.erik.jokeapi.models.Parameter import net.thauvin.erik.jokeapi.models.Parameter
import net.thauvin.erik.jokeapi.models.Type import net.thauvin.erik.jokeapi.models.Type
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.logging.Level
import java.util.logging.Logger import java.util.logging.Logger
import java.util.stream.Collectors import java.util.stream.Collectors
/** /**
* Implements the [Sv443's JokeAPI](https://jokeapi.dev/). * Implements the [Sv443's JokeAPI](https://jokeapi.dev/).
*/ */
class JokeApi { class JokeApi private constructor() {
companion object { companion object {
private const val API_URL = "https://v2.jokeapi.dev/" private const val API_URL = "https://v2.jokeapi.dev/"
private const val JOKE_ENDPOINT = "joke"
@JvmStatic @JvmStatic
val logger: Logger by lazy { Logger.getLogger(JokeApi::class.java.simpleName) } val logger: Logger by lazy { Logger.getLogger(JokeApi::class.java.simpleName) }
@ -70,8 +65,13 @@ class JokeApi {
*/ */
@JvmStatic @JvmStatic
@JvmOverloads @JvmOverloads
@Throws(HttpErrorException::class, IOException::class) @Throws(HttpErrorException::class)
fun apiCall(endPoint: String, path: String = "", params: Map<String, String> = emptyMap()): String { fun apiCall(
endPoint: String,
path: String = "",
params: Map<String, String> = emptyMap(),
auth: String = ""
): String {
val urlBuilder = StringBuilder("$API_URL$endPoint") val urlBuilder = StringBuilder("$API_URL$endPoint")
if (path.isNotEmpty()) { if (path.isNotEmpty()) {
@ -98,99 +98,16 @@ class JokeApi {
} }
} }
} }
return fetchUrl(urlBuilder.toString()) return fetchUrl(urlBuilder.toString(), auth)
}
/**
* 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<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 {
val params = mutableMapOf<String, String>()
// 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)
} }
/** /**
* Returns one or more jokes using a [configuration][JokeConfig]. * 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 @JvmStatic
@Throws(HttpErrorException::class, IOException::class, IllegalArgumentException::class) @Throws(HttpErrorException::class)
fun getRawJokes(config: JokeConfig): String { fun getRawJokes(config: JokeConfig): String {
return getRawJokes( return getRawJokes(
categories = config.categories, categories = config.categories,
@ -201,181 +118,20 @@ class JokeApi {
search = config.search, search = config.search,
idRange = config.idRange, idRange = config.idRange,
amount = config.amount, 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<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
): 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<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
): Array<Joke> {
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]. * Retrieve a [Joke] instance using a [configuration][JokeConfig].
* *
* Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details. * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details.
*/ */
@JvmStatic @JvmStatic
@Throws(JokeException::class, HttpErrorException::class, IOException::class, IllegalArgumentException::class) @JvmOverloads
fun getJoke(config: JokeConfig): Joke { @Throws(HttpErrorException::class, JokeException::class)
fun getJoke(config: JokeConfig = JokeConfig.Builder().build()): Joke {
return getJoke( return getJoke(
categories = config.categories, categories = config.categories,
language = config.language, language = config.language,
@ -384,7 +140,8 @@ class JokeApi {
search = config.search, search = config.search,
idRange = config.idRange, idRange = config.idRange,
safe = config.safe, 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. * Sse the [JokeAPI Documentation](https://jokeapi.dev/#joke-endpoint) for more details.
*/ */
@JvmStatic @JvmStatic
@Throws(JokeException::class, HttpErrorException::class, IOException::class, IllegalArgumentException::class) @Throws(HttpErrorException::class, JokeException::class)
fun getJokes(config: JokeConfig): Array<Joke> { fun getJokes(config: JokeConfig): Array<Joke> {
return getJokes( return getJokes(
categories = config.categories, categories = config.categories,
@ -405,51 +162,173 @@ class JokeApi {
idRange = config.idRange, idRange = config.idRange,
amount = config.amount, amount = config.amount,
safe = config.safe, safe = config.safe,
splitNewLine = config.splitNewLine splitNewLine = config.splitNewLine,
) auth = config.auth
}
private fun parseError(json: JSONObject): JokeException {
val causedBy = json.getJSONArray("causedBy")
val causes = List<String>(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<String>()
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<Flag>()
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())
) )
} }
} }
} }
/**
* 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<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 = ""
): 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<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 = ""
): Array<Joke> {
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<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 {
val params = mutableMapOf<String, String>()
// 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)
}

View file

@ -53,9 +53,10 @@ class JokeConfig private constructor(
val format: Format, val format: Format,
val search: String, val search: String,
val idRange: IdRange, val idRange: IdRange,
val amount: Int = 1, val amount: Int,
val safe: Boolean, val safe: Boolean,
val splitNewLine: Boolean, val splitNewLine: Boolean,
val auth: String
) { ) {
/** /**
* [Builds][build] a new configuration. * [Builds][build] a new configuration.
@ -74,7 +75,8 @@ class JokeConfig private constructor(
var idRange: IdRange = IdRange(), var idRange: IdRange = IdRange(),
var amount: Int = 1, var amount: Int = 1,
var safe: Boolean = false, var safe: Boolean = false,
var splitNewLine: Boolean = false var splitNewLine: Boolean = false,
var auth: String = ""
) { ) {
fun categories(categories: Set<Category>) = apply { this.categories = categories } fun categories(categories: Set<Category>) = apply { this.categories = categories }
fun language(language: Language) = apply { this.language = language } 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 amount(amount: Int) = apply { this.amount = amount }
fun safe(safe: Boolean) = apply { this.safe = safe } fun safe(safe: Boolean) = apply { this.safe = safe }
fun splitNewLine(splitNewLine: Boolean) = apply { this.splitNewLine = splitNewLine } fun splitNewLine(splitNewLine: Boolean) = apply { this.splitNewLine = splitNewLine }
fun auth(auth: String) = apply { this.auth = auth }
fun build() = JokeConfig( fun build() = JokeConfig(
categories, language, flags, type, format, search, idRange, amount, safe, splitNewLine categories, language, flags, type, format, search, idRange, amount, safe, splitNewLine, auth
) )
} }
} }

View file

@ -45,7 +45,7 @@ class JokeException @JvmOverloads constructor(
val additionalInfo: String, val additionalInfo: String,
val timestamp: Long, val timestamp: Long,
cause: Throwable? = null cause: Throwable? = null
) : Exception(message, cause) { ) : RuntimeException(message, cause) {
companion object { companion object {
private const val serialVersionUID = 1L private const val serialVersionUID = 1L
} }

View file

@ -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<String>(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<String>()
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<Flag>()
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())
)
}

View file

@ -44,8 +44,6 @@ import assertk.assertions.isNull
import assertk.assertions.prop import assertk.assertions.prop
import assertk.assertions.size import assertk.assertions.size
import assertk.assertions.startsWith 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.JokeApi.Companion.logger
import net.thauvin.erik.jokeapi.exceptions.HttpErrorException import net.thauvin.erik.jokeapi.exceptions.HttpErrorException
import net.thauvin.erik.jokeapi.exceptions.JokeException import net.thauvin.erik.jokeapi.exceptions.JokeException
@ -59,8 +57,6 @@ import java.util.logging.ConsoleHandler
import java.util.logging.Level import java.util.logging.Level
internal class ExceptionsTest { internal class ExceptionsTest {
private val httpStat = "https://httpstat.us"
@Test @Test
fun `Validate Joke Exception`() { fun `Validate Joke Exception`() {
val e = assertThrows<JokeException> { val e = assertThrows<JokeException> {
@ -78,29 +74,19 @@ internal class ExceptionsTest {
} }
} }
@ParameterizedTest(name = "HTTP Status Code: {0}") @ParameterizedTest
@ValueSource(ints = [400, 404, 403, 413, 414, 429, 500, 523]) @ValueSource(ints = [400, 404, 403, 413, 414, 429, 500, 523, 666])
fun `Validate HTTP Error Exceptions`(input: Int) { fun `Validate HTTP Exceptions`(code: Int) {
val e = assertThrows<HttpErrorException> { val e = assertThrows<HttpErrorException> {
fetchUrl("$httpStat/$input") fetchUrl("https://httpstat.us/$code")
} }
assertThat(e, "fetchUrl($httpStat/$input)").all { assertThat(e, "fetchUrl($code)").all {
prop(HttpErrorException::statusCode).isEqualTo(input) prop(HttpErrorException::statusCode).isEqualTo(code)
prop(HttpErrorException::message).isNotNull().isNotEmpty() prop(HttpErrorException::message).isNotNull().isNotEmpty()
prop(HttpErrorException::cause).isNotNull().assertThat(Throwable::message).isNotNull() if (code < 600)
} prop(HttpErrorException::cause).isNotNull().assertThat(Throwable::message).isNotNull()
} else
prop(HttpErrorException::cause).isNull()
@Test
fun `Fetch Invalid URL`() {
val statusCode = 999
val e = assertThrows<HttpErrorException> {
fetchUrl("$httpStat/$statusCode")
}
assertThat(e, "fetchUrl($httpStat/$statusCode).statusCode").all {
prop(HttpErrorException::statusCode).isEqualTo(statusCode)
prop(HttpErrorException::message).isNotNull().isNotEmpty()
prop(HttpErrorException::cause).isNull()
} }
} }

View file

@ -49,7 +49,6 @@ import assertk.assertions.isNotNull
import assertk.assertions.isTrue import assertk.assertions.isTrue
import assertk.assertions.prop import assertk.assertions.prop
import assertk.assertions.size 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.JokeApi.Companion.logger
import net.thauvin.erik.jokeapi.exceptions.JokeException import net.thauvin.erik.jokeapi.exceptions.JokeException
import net.thauvin.erik.jokeapi.models.Category import net.thauvin.erik.jokeapi.models.Category

View file

@ -1,5 +1,5 @@
/* /*
* GetJokeTest.kt * GetJokeTests.kt
* *
* Copyright (c) 2022, Erik C. Thauvin (erik@thauvin.net) * Copyright (c) 2022, Erik C. Thauvin (erik@thauvin.net)
* All rights reserved. * All rights reserved.
@ -44,7 +44,6 @@ import assertk.assertions.isNotNull
import assertk.assertions.isTrue import assertk.assertions.isTrue
import assertk.assertions.prop import assertk.assertions.prop
import assertk.assertions.size 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.JokeApi.Companion.logger
import net.thauvin.erik.jokeapi.models.Joke import net.thauvin.erik.jokeapi.models.Joke
import net.thauvin.erik.jokeapi.models.Language import net.thauvin.erik.jokeapi.models.Language
@ -79,10 +78,6 @@ internal class GetJokesTest {
@Test @Test
fun `Get One Joke as Multiple`() { fun `Get One Joke as Multiple`() {
val jokes = getJokes(amount = 1, safe = true) val jokes = getJokes(amount = 1, safe = true)
jokes.forEach {
println(it.joke.joinToString("\n"))
println("-".repeat(46))
}
assertThat(jokes, "jokes").all { assertThat(jokes, "jokes").all {
size().isEqualTo(1) size().isEqualTo(1)
index(0).all { index(0).all {

View file

@ -1,5 +1,5 @@
/* /*
* GetRawJokeTest.kt * GetRawJokesTest.kt
* *
* Copyright (c) 2022, Erik C. Thauvin (erik@thauvin.net) * Copyright (c) 2022, Erik C. Thauvin (erik@thauvin.net)
* All rights reserved. * All rights reserved.
@ -37,9 +37,9 @@ import assertk.assertThat
import assertk.assertions.doesNotContain import assertk.assertions.doesNotContain
import assertk.assertions.isNotEmpty import assertk.assertions.isNotEmpty
import assertk.assertions.startsWith 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.JokeApi.Companion.logger
import net.thauvin.erik.jokeapi.models.Format 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.BeforeAll
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.util.logging.ConsoleHandler import java.util.logging.ConsoleHandler
@ -74,6 +74,12 @@ internal class GetRawJokesTest {
assertContains(response, "\"amount\": 2", false, "getRawJoke(2)") 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 { companion object {
@JvmStatic @JvmStatic
@BeforeAll @BeforeAll

View file

@ -60,6 +60,15 @@ import java.util.logging.Level
import kotlin.test.assertContains import kotlin.test.assertContains
class JokeConfigTest { 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 @Test
fun `Get Joke with Builder`() { fun `Get Joke with Builder`() {
val id = 266 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 { companion object {
@JvmStatic @JvmStatic
@BeforeAll @BeforeAll

View file

@ -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<JSONException> { parseError(JSONObject("{}")) }
}
@Test
fun `Invalid JSON Joke`() {
assertThrows<JSONException> { 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
}
}
}
}