From a272599b098efcc4b8f35b1600d725c12c595133 Mon Sep 17 00:00:00 2001 From: "Erik C. Thauvin" Date: Mon, 30 Jan 2023 23:22:17 -0800 Subject: [PATCH] Added message and description to CallResponse --- .../main/kotlin/com/example/BitlyRetrieve.kt | 6 +- .../kotlin/net/thauvin/erik/bitly/Bitly.kt | 5 +- .../kotlin/net/thauvin/erik/bitly/Utils.kt | 253 +++++++++--------- .../net/thauvin/erik/bitly/BitlyTest.kt | 71 +++-- 4 files changed, 181 insertions(+), 154 deletions(-) diff --git a/examples/src/main/kotlin/com/example/BitlyRetrieve.kt b/examples/src/main/kotlin/com/example/BitlyRetrieve.kt index d3fe1cf..58f99a0 100644 --- a/examples/src/main/kotlin/com/example/BitlyRetrieve.kt +++ b/examples/src/main/kotlin/com/example/BitlyRetrieve.kt @@ -2,7 +2,7 @@ package com.example import net.thauvin.erik.bitly.Bitly import net.thauvin.erik.bitly.Methods -import net.thauvin.erik.bitly.Utils.Companion.toEndPoint +import net.thauvin.erik.bitly.Utils.toEndPoint import org.json.JSONObject import kotlin.system.exitProcess @@ -10,7 +10,7 @@ fun main() { val bitly = Bitly(/* "YOUR_API_ACCESS_TOKEN from https://bitly.is/accesstoken" */) // See https://dev.bitly.com/v4/#operation/getBitlink - val response = bitly.call("/bitlinks/bit.ly/380ojFd".toEndPoint(), method = Methods.GET) + val response = bitly.call("/bitlinks/bit.ly/380ojFd", method = Methods.GET) if (response.isSuccessful) { val json = JSONObject(response.body) @@ -18,7 +18,7 @@ fun main() { println("URL : " + json.getString("long_url")) println("By : " + json.getString("created_by")) } else { - println("Invalid Response: ${response.resultCode}") + println("${response.message}: ${response.description} (${response.statusCode})") } exitProcess(0) diff --git a/src/main/kotlin/net/thauvin/erik/bitly/Bitly.kt b/src/main/kotlin/net/thauvin/erik/bitly/Bitly.kt index a3e03a6..b354ab2 100644 --- a/src/main/kotlin/net/thauvin/erik/bitly/Bitly.kt +++ b/src/main/kotlin/net/thauvin/erik/bitly/Bitly.kt @@ -31,6 +31,7 @@ package net.thauvin.erik.bitly +import net.thauvin.erik.bitly.Utils.toEndPoint import java.io.File import java.nio.file.Files import java.nio.file.Path @@ -107,13 +108,13 @@ open class Bitly() { /** * Executes an API call. * - * @param endPoint The REST endpoint. (eg. `https://api-ssl.bitly.com/v4/shorten`) + * @param endPoint The REST endpoint path. (eg. `shorten`, `expand`, etc.) * @param params The request parameters key/value map. * @param method The submission [Method][Methods]. * @return A [CallResponse] object. */ @JvmOverloads fun call(endPoint: String, params: Map = emptyMap(), method: Methods = Methods.POST): CallResponse { - return Utils.call(accessToken, endPoint, params, method) + return Utils.call(accessToken, endPoint.toEndPoint(), params, method) } } diff --git a/src/main/kotlin/net/thauvin/erik/bitly/Utils.kt b/src/main/kotlin/net/thauvin/erik/bitly/Utils.kt index 34b85ee..831ab94 100644 --- a/src/main/kotlin/net/thauvin/erik/bitly/Utils.kt +++ b/src/main/kotlin/net/thauvin/erik/bitly/Utils.kt @@ -31,7 +31,7 @@ package net.thauvin.erik.bitly -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request @@ -46,157 +46,146 @@ import java.util.logging.Level import java.util.logging.Logger /** Provides useful generic functions. */ -open class Utils private constructor() { - companion object { - /** The logger instance. */ - val logger: Logger by lazy { Logger.getLogger(Utils::class.java.name) } +object Utils { + /** The logger instance. */ + @JvmStatic + val logger: Logger by lazy { Logger.getLogger(Utils::class.java.name) } - /** - * Executes an API call. - * - * @param accessToken The API access token. - * @param endPoint The REST endpoint. (eg. `https://api-ssl.bitly.com/v4/shorten`) - * @param params The request parameters key/value map. - * @param method The submission [Method][Methods]. - * @return A [CallResponse] object. - */ - @JvmOverloads - fun call( - accessToken: String, - endPoint: String, - params: Map = emptyMap(), - method: Methods = Methods.POST - ): CallResponse { - val response = CallResponse() - if (validateCall(accessToken, endPoint)) { - endPoint.toHttpUrlOrNull()?.let { apiUrl -> - val builder = when (method) { - Methods.POST, Methods.PATCH -> { - val formBody = JSONObject(params).toString() - .toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - Request.Builder().apply { - url(apiUrl.newBuilder().build()) - if (method == Methods.POST) { - post(formBody) - } else { - patch(formBody) - } - } - } + /** + * Executes an API call. + * + * @param accessToken The API access token. + * @param endPoint The REST endpoint URI. (eg. `https://api-ssl.bitly.com/v4/shorten`) + * @param params The request parameters key/value map. + * @param method The submission [Method][Methods]. + * @return A [CallResponse] object. + */ + @JvmStatic + @JvmOverloads + fun call( + accessToken: String, + endPoint: String, + params: Map = emptyMap(), + method: Methods = Methods.POST + ): CallResponse { + require(endPoint.isNotBlank()) { "A valid API endpoint must be specified." } + require(accessToken.isNotBlank()) { "A valid API access token must be provided." } - Methods.DELETE -> Request.Builder().url(apiUrl.newBuilder().build()).delete() - else -> { // Methods.GET - val httpUrl = apiUrl.newBuilder().apply { - params.forEach { - if (it.value is String) { - addQueryParameter(it.key, it.value.toString()) - } - } - }.build() - Request.Builder().url(httpUrl) - } - }.addHeader("Authorization", "Bearer $accessToken") - - val result = createHttpClient().newCall(builder.build()).execute() - return CallResponse(parseBody(endPoint, result), result.code) - } - } - return response - } - - private fun createHttpClient(): OkHttpClient { - return OkHttpClient.Builder().apply { - if (logger.isLoggable(Level.FINE)) { - addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - redactHeader("Authorization") - }) - } - }.build() - } - - private fun parseBody(endPoint: String, result: Response): String { - result.body?.string()?.let { body -> - if (!result.isSuccessful && body.isNotEmpty()) { - try { - with(JSONObject(body)) { - if (logger.isSevereLoggable()) { - if (has("message")) { - logger.severe(getString("message") + " (${result.code})") - } - if (has("description")) { - logger.severe(getString("description")) - } - } - } - } catch (jse: JSONException) { - if (logger.isSevereLoggable()) { - logger.log( - Level.SEVERE, - "An error occurred parsing the error response from Bitly. [$endPoint]", - jse - ) + endPoint.toHttpUrl().let { apiUrl -> + val builder = when (method) { + Methods.POST, Methods.PATCH -> { + val formBody = JSONObject(params).toString() + .toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) + Request.Builder().apply { + url(apiUrl.newBuilder().build()) + if (method == Methods.POST) { + post(formBody) + } else { + patch(formBody) } } } - return body + + Methods.DELETE -> Request.Builder().url(apiUrl.newBuilder().build()).delete() + else -> { // Methods.GET + val httpUrl = apiUrl.newBuilder().apply { + params.forEach { + if (it.value is String) { + addQueryParameter(it.key, it.value.toString()) + } + } + }.build() + Request.Builder().url(httpUrl) + } + }.addHeader("Authorization", "Bearer $accessToken") + + newHttpClient().newCall(builder.build()).execute().use { + return parseResponse(it, endPoint) } - return Constants.EMPTY } + } - /** - * Is [Level.SEVERE] logging enabled. - */ - fun Logger.isSevereLoggable(): Boolean = this.isLoggable(Level.SEVERE) + private fun newHttpClient(): OkHttpClient { + return OkHttpClient.Builder().apply { + if (logger.isLoggable(Level.FINE)) { + addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + redactHeader("Authorization") + }) + } + }.build() + } - /** - * Validates a URL. - */ - fun String.isValidUrl(): Boolean { - if (this.isNotBlank()) { + private fun parseResponse(response: Response, endPoint: String): CallResponse { + var message = response.message + var description = "" + var json = Constants.EMPTY_JSON + response.body?.string()?.let { body -> + json = body + if (!response.isSuccessful && body.isNotEmpty()) { try { - URL(this) - return true - } catch (e: MalformedURLException) { - if (logger.isLoggable(Level.WARNING)) { - logger.log(Level.WARNING, "Invalid URL: $this", e) + with(JSONObject(body)) { + if (has("message")) { + message = getString("message") + } + if (has("description")) { + description = getString("description") + } + } + } catch (jse: JSONException) { + if (logger.isSevereLoggable()) { + logger.log( + Level.SEVERE, + "An error occurred parsing the error response from Bitly. [$endPoint]", + jse + ) } } } - return false } + return CallResponse(json, message, description, response.code) + } - /** - * Removes http(s) scheme from string. - */ - fun String.removeHttp(): String { - return this.replaceFirst("^[Hh][Tt]{2}[Pp][Ss]?://".toRegex(), "") - } + /** + * Determines if [Level.SEVERE] logging is enabled. + */ + fun Logger.isSevereLoggable(): Boolean = this.isLoggable(Level.SEVERE) - /** - * Builds the full API endpoint URL using the [Constants.API_BASE_URL]. - */ - fun String.toEndPoint(): String { - return if (this.startsWith('/')) { - "${Constants.API_BASE_URL}$this" - } else { - "${Constants.API_BASE_URL}/$this" + /** + * Validates a URL. + */ + @JvmStatic + fun String.isValidUrl(): Boolean { + if (this.isNotBlank()) { + try { + URL(this) + return true + } catch (e: MalformedURLException) { + if (logger.isLoggable(Level.WARNING)) { + logger.log(Level.WARNING, "Invalid URL: $this", e) + } } } + return false + } - private fun validateCall(accessToken: String, endPoint: String): Boolean { - when { - endPoint.isBlank() -> { - if (logger.isSevereLoggable()) logger.severe("Please specify a valid API endpoint.") - } + /** + * Removes http(s) scheme from string. + */ + @JvmStatic + fun String.removeHttp(): String { + return this.replaceFirst("^[Hh][Tt]{2}[Pp][Ss]?://".toRegex(), "") + } - accessToken.isBlank() -> { - if (logger.isSevereLoggable()) logger.severe("Please specify a valid API access token.") - } - - else -> return true - } - return false + /** + * Builds the full API endpoint URL using the [Constants.API_BASE_URL]. + */ + @JvmStatic + fun String.toEndPoint(): String { + return if (this.isBlank() || this.startsWith("http", true)) { + this + } else { + "${Constants.API_BASE_URL}/${this.removePrefix("/")}" } } } diff --git a/src/test/kotlin/net/thauvin/erik/bitly/BitlyTest.kt b/src/test/kotlin/net/thauvin/erik/bitly/BitlyTest.kt index 8405d3f..f1139a2 100644 --- a/src/test/kotlin/net/thauvin/erik/bitly/BitlyTest.kt +++ b/src/test/kotlin/net/thauvin/erik/bitly/BitlyTest.kt @@ -34,22 +34,27 @@ package net.thauvin.erik.bitly import assertk.all import assertk.assertThat import assertk.assertions.contains +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isFalse +import assertk.assertions.isNotEqualTo import assertk.assertions.isTrue import assertk.assertions.matches import assertk.assertions.prop -import net.thauvin.erik.bitly.Utils.Companion.isValidUrl -import net.thauvin.erik.bitly.Utils.Companion.removeHttp -import net.thauvin.erik.bitly.Utils.Companion.toEndPoint +import assertk.assertions.startsWith +import net.thauvin.erik.bitly.Utils.isValidUrl +import net.thauvin.erik.bitly.Utils.removeHttp +import net.thauvin.erik.bitly.Utils.toEndPoint +import net.thauvin.erik.bitly.config.CreateConfig +import net.thauvin.erik.bitly.config.UpdateConfig import org.json.JSONObject import org.junit.Before import java.io.File import java.util.logging.Level import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertTrue class BitlyTest { @@ -76,7 +81,12 @@ class BitlyTest { if (System.getenv("CI") == "true") { test.accessToken = Constants.EMPTY } - assertEquals(longUrl, test.bitlinks().shorten(longUrl)) + assertFailsWith(IllegalArgumentException::class) { + test.bitlinks().shorten(longUrl) + } + assertFailsWith(IllegalArgumentException::class, "Utils.call()") { + Utils.call("", "foo") + } } @Test @@ -100,7 +110,19 @@ class BitlyTest { @Test fun `endPoint should be specified`() { - assertThat(bitly.call("")).prop(CallResponse::isSuccessful).isFalse() + assertFailsWith(IllegalArgumentException::class, "bitly.call()") { + bitly.call("") + } + assertFailsWith(IllegalArgumentException::class, "Utils.call()") { + Utils.call("1234568", "") + } + } + + @Test + fun `endPoint conversion`() { + assertThat(Constants.API_BASE_URL.toEndPoint()).isEqualTo(Constants.API_BASE_URL) + assertThat("path".toEndPoint()).isEqualTo("${Constants.API_BASE_URL}/path") + assertThat("/path".toEndPoint()).isEqualTo("${Constants.API_BASE_URL}/path") } @Test @@ -110,16 +132,18 @@ class BitlyTest { } @Test - fun `as json`() { + fun `shorten as json`() { assertTrue(bitly.bitlinks().shorten(longUrl, toJson = true).startsWith("{\"created_at\":")) } @Test fun `get user`() { - assertThat(bitly.call("user".toEndPoint(), method = Methods.GET), "call(user)") - .prop(CallResponse::isSuccessful).isTrue() - assertThat(Utils.call(bitly.accessToken, "/user".toEndPoint(), method = Methods.GET), "call(/user)") + assertThat(bitly.call("user", method = Methods.GET), "call(user)") .prop(CallResponse::isSuccessful).isTrue() + assertThat(Utils.call(bitly.accessToken, "user".toEndPoint(), method = Methods.GET), "call(/user)").all { + prop(CallResponse::isSuccessful).isTrue() + prop(CallResponse::body).contains("login") + } } @Test @@ -128,7 +152,7 @@ class BitlyTest { "ethauvin", JSONObject( bitly.call( - "/bitlinks/${shortUrl.removeHttp()}".toEndPoint(), + "/bitlinks/${shortUrl.removeHttp()}", method = Methods.GET ).body ).getString("created_by") @@ -151,22 +175,31 @@ class BitlyTest { bl.shorten(longUrl, domain = "bit.ly") assertThat(bl.lastCallResponse, "shorten(longUrl)").all { prop(CallResponse::isSuccessful).isTrue() - prop(CallResponse::resultCode).isEqualTo(200) + prop(CallResponse::statusCode).isEqualTo(200) prop(CallResponse::body).contains("\"link\":\"$shortUrl\"") + prop(CallResponse::message).isEmpty() } bl.shorten(shortUrl) assertThat(bl.lastCallResponse, "shorten(shortUrl)").all { prop(CallResponse::isSuccessful).isFalse() - prop(CallResponse::resultCode).isEqualTo(400) + prop(CallResponse::statusCode).isEqualTo(400) prop(CallResponse::isBadRequest).isTrue() - prop(CallResponse::body).contains("ALREADY_A_BITLY_LINK") + prop(CallResponse::message).isEqualTo("ALREADY_A_BITLY_LINK") + prop(CallResponse::description).isEqualTo("The value provided is invalid.") } } @Test fun `clicks summary`() { - assertNotEquals(Constants.EMPTY, bitly.bitlinks().clicks(shortUrl)) + val bl = bitly.bitlinks() + assertThat(bl.clicks(shortUrl)).isNotEqualTo(Constants.EMPTY) + bl.clicks(shortUrl, unit = Units.MONTH, units = 6) + assertThat(bl.lastCallResponse).all { + prop(CallResponse::isUpgradeRequired) + prop(CallResponse::statusCode).isEqualTo(402) + prop(CallResponse::description).startsWith("Metrics") + } } @Test @@ -233,7 +266,9 @@ class BitlyTest { bl.update("bit.ly/407GjJU", id = "foo") assertThat(bl.lastCallResponse).all { prop(CallResponse::isForbidden).isTrue() - prop(CallResponse::resultCode).isEqualTo(403) + prop(CallResponse::statusCode).isEqualTo(403) + prop(CallResponse::message).isEqualTo("FORBIDDEN") + prop(CallResponse::description).contains("forbidden") } } @@ -264,7 +299,9 @@ class BitlyTest { assertThat(bl.lastCallResponse).all { prop(CallResponse::isUnprocessableEntity).isTrue() - prop(CallResponse::resultCode).isEqualTo(422) + prop(CallResponse::statusCode).isEqualTo(422) + prop(CallResponse::message).isEqualTo("UNPROCESSABLE_ENTITY") + prop(CallResponse::description).contains("JSON") } }