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

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

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,20 +46,21 @@ import java.util.logging.Level
import java.util.logging.Logger
/** Provides useful generic functions. */
open class Utils private constructor() {
companion object {
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 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,
@ -67,9 +68,10 @@ open class Utils private constructor() {
params: Map<String, Any> = emptyMap(),
method: Methods = Methods.POST
): CallResponse {
val response = CallResponse()
if (validateCall(accessToken, endPoint)) {
endPoint.toHttpUrlOrNull()?.let { apiUrl ->
require(endPoint.isNotBlank()) { "A valid API endpoint must be specified." }
require(accessToken.isNotBlank()) { "A valid API access token must be provided." }
endPoint.toHttpUrl().let { apiUrl ->
val builder = when (method) {
Methods.POST, Methods.PATCH -> {
val formBody = JSONObject(params).toString()
@ -97,14 +99,13 @@ open class Utils private constructor() {
}
}.addHeader("Authorization", "Bearer $accessToken")
val result = createHttpClient().newCall(builder.build()).execute()
return CallResponse(parseBody(endPoint, result), result.code)
newHttpClient().newCall(builder.build()).execute().use {
return parseResponse(it, endPoint)
}
}
return response
}
private fun createHttpClient(): OkHttpClient {
private fun newHttpClient(): OkHttpClient {
return OkHttpClient.Builder().apply {
if (logger.isLoggable(Level.FINE)) {
addInterceptor(HttpLoggingInterceptor().apply {
@ -115,18 +116,20 @@ open class Utils private constructor() {
}.build()
}
private fun parseBody(endPoint: String, result: Response): String {
result.body?.string()?.let { body ->
if (!result.isSuccessful && body.isNotEmpty()) {
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 {
with(JSONObject(body)) {
if (logger.isSevereLoggable()) {
if (has("message")) {
logger.severe(getString("message") + " (${result.code})")
message = getString("message")
}
if (has("description")) {
logger.severe(getString("description"))
}
description = getString("description")
}
}
} catch (jse: JSONException) {
@ -139,19 +142,19 @@ open class Utils private constructor() {
}
}
}
return body
}
return Constants.EMPTY
return CallResponse(json, message, description, response.code)
}
/**
* Is [Level.SEVERE] logging enabled.
* Determines if [Level.SEVERE] logging is enabled.
*/
fun Logger.isSevereLoggable(): Boolean = this.isLoggable(Level.SEVERE)
/**
* Validates a URL.
*/
@JvmStatic
fun String.isValidUrl(): Boolean {
if (this.isNotBlank()) {
try {
@ -169,6 +172,7 @@ open class Utils private constructor() {
/**
* Removes http(s) scheme from string.
*/
@JvmStatic
fun String.removeHttp(): String {
return this.replaceFirst("^[Hh][Tt]{2}[Pp][Ss]?://".toRegex(), "")
}
@ -176,27 +180,12 @@ open class Utils private constructor() {
/**
* Builds the full API endpoint URL using the [Constants.API_BASE_URL].
*/
@JvmStatic
fun String.toEndPoint(): String {
return if (this.startsWith('/')) {
"${Constants.API_BASE_URL}$this"
return if (this.isBlank() || this.startsWith("http", true)) {
this
} else {
"${Constants.API_BASE_URL}/$this"
}
}
private fun validateCall(accessToken: String, endPoint: String): Boolean {
when {
endPoint.isBlank() -> {
if (logger.isSevereLoggable()) logger.severe("Please specify a valid API endpoint.")
}
accessToken.isBlank() -> {
if (logger.isSevereLoggable()) logger.severe("Please specify a valid API access token.")
}
else -> return true
}
return false
"${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")
}
}