Added message and description to CallResponse

This commit is contained in:
Erik C. Thauvin 2023-01-30 23:22:17 -08:00
parent 191fa0ef44
commit a272599b09
4 changed files with 181 additions and 154 deletions

View file

@ -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<String, Any> = emptyMap(), method: Methods = Methods.POST): CallResponse {
return Utils.call(accessToken, endPoint, params, method)
return Utils.call(accessToken, endPoint.toEndPoint(), params, method)
}
}

View file

@ -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<String, Any> = 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<String, Any> = 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("/")}"
}
}
}

View file

@ -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")
}
}