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.Bitly
import net.thauvin.erik.bitly.Methods 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 org.json.JSONObject
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -10,7 +10,7 @@ fun main() {
val bitly = Bitly(/* "YOUR_API_ACCESS_TOKEN from https://bitly.is/accesstoken" */) val bitly = Bitly(/* "YOUR_API_ACCESS_TOKEN from https://bitly.is/accesstoken" */)
// See https://dev.bitly.com/v4/#operation/getBitlink // 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) { if (response.isSuccessful) {
val json = JSONObject(response.body) val json = JSONObject(response.body)
@ -18,7 +18,7 @@ fun main() {
println("URL : " + json.getString("long_url")) println("URL : " + json.getString("long_url"))
println("By : " + json.getString("created_by")) println("By : " + json.getString("created_by"))
} else { } else {
println("Invalid Response: ${response.resultCode}") println("${response.message}: ${response.description} (${response.statusCode})")
} }
exitProcess(0) exitProcess(0)

View file

@ -31,6 +31,7 @@
package net.thauvin.erik.bitly package net.thauvin.erik.bitly
import net.thauvin.erik.bitly.Utils.toEndPoint
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@ -107,13 +108,13 @@ open class Bitly() {
/** /**
* Executes an API call. * 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 params The request parameters key/value map.
* @param method The submission [Method][Methods]. * @param method The submission [Method][Methods].
* @return A [CallResponse] object. * @return A [CallResponse] object.
*/ */
@JvmOverloads @JvmOverloads
fun call(endPoint: String, params: Map<String, Any> = emptyMap(), method: Methods = Methods.POST): CallResponse { 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 package net.thauvin.erik.bitly
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@ -46,157 +46,146 @@ import java.util.logging.Level
import java.util.logging.Logger import java.util.logging.Logger
/** Provides useful generic functions. */ /** Provides useful generic functions. */
open class Utils private constructor() { object Utils {
companion object { /** The logger instance. */
/** The logger instance. */ @JvmStatic
val logger: Logger by lazy { Logger.getLogger(Utils::class.java.name) } val logger: Logger by lazy { Logger.getLogger(Utils::class.java.name) }
/** /**
* Executes an API call. * Executes an API call.
* *
* @param accessToken The API access token. * @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 params The request parameters key/value map.
* @param method The submission [Method][Methods]. * @param method The submission [Method][Methods].
* @return A [CallResponse] object. * @return A [CallResponse] object.
*/ */
@JvmOverloads @JvmStatic
fun call( @JvmOverloads
accessToken: String, fun call(
endPoint: String, accessToken: String,
params: Map<String, Any> = emptyMap(), endPoint: String,
method: Methods = Methods.POST params: Map<String, Any> = emptyMap(),
): CallResponse { method: Methods = Methods.POST
val response = CallResponse() ): CallResponse {
if (validateCall(accessToken, endPoint)) { require(endPoint.isNotBlank()) { "A valid API endpoint must be specified." }
endPoint.toHttpUrlOrNull()?.let { apiUrl -> require(accessToken.isNotBlank()) { "A valid API access token must be provided." }
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)
}
}
}
Methods.DELETE -> Request.Builder().url(apiUrl.newBuilder().build()).delete() endPoint.toHttpUrl().let { apiUrl ->
else -> { // Methods.GET val builder = when (method) {
val httpUrl = apiUrl.newBuilder().apply { Methods.POST, Methods.PATCH -> {
params.forEach { val formBody = JSONObject(params).toString()
if (it.value is String) { .toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
addQueryParameter(it.key, it.value.toString()) Request.Builder().apply {
} url(apiUrl.newBuilder().build())
} if (method == Methods.POST) {
}.build() post(formBody)
Request.Builder().url(httpUrl) } else {
} patch(formBody)
}.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
)
} }
} }
} }
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
} }
}
/** private fun newHttpClient(): OkHttpClient {
* Is [Level.SEVERE] logging enabled. return OkHttpClient.Builder().apply {
*/ if (logger.isLoggable(Level.FINE)) {
fun Logger.isSevereLoggable(): Boolean = this.isLoggable(Level.SEVERE) addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
redactHeader("Authorization")
})
}
}.build()
}
/** private fun parseResponse(response: Response, endPoint: String): CallResponse {
* Validates a URL. var message = response.message
*/ var description = ""
fun String.isValidUrl(): Boolean { var json = Constants.EMPTY_JSON
if (this.isNotBlank()) { response.body?.string()?.let { body ->
json = body
if (!response.isSuccessful && body.isNotEmpty()) {
try { try {
URL(this) with(JSONObject(body)) {
return true if (has("message")) {
} catch (e: MalformedURLException) { message = getString("message")
if (logger.isLoggable(Level.WARNING)) { }
logger.log(Level.WARNING, "Invalid URL: $this", e) 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. * Determines if [Level.SEVERE] logging is enabled.
*/ */
fun String.removeHttp(): String { fun Logger.isSevereLoggable(): Boolean = this.isLoggable(Level.SEVERE)
return this.replaceFirst("^[Hh][Tt]{2}[Pp][Ss]?://".toRegex(), "")
}
/** /**
* Builds the full API endpoint URL using the [Constants.API_BASE_URL]. * Validates a URL.
*/ */
fun String.toEndPoint(): String { @JvmStatic
return if (this.startsWith('/')) { fun String.isValidUrl(): Boolean {
"${Constants.API_BASE_URL}$this" if (this.isNotBlank()) {
} else { try {
"${Constants.API_BASE_URL}/$this" 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 { * Removes http(s) scheme from string.
endPoint.isBlank() -> { */
if (logger.isSevereLoggable()) logger.severe("Please specify a valid API endpoint.") @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.") * Builds the full API endpoint URL using the [Constants.API_BASE_URL].
} */
@JvmStatic
else -> return true fun String.toEndPoint(): String {
} return if (this.isBlank() || this.startsWith("http", true)) {
return false this
} else {
"${Constants.API_BASE_URL}/${this.removePrefix("/")}"
} }
} }
} }

View file

@ -34,22 +34,27 @@ package net.thauvin.erik.bitly
import assertk.all import assertk.all
import assertk.assertThat import assertk.assertThat
import assertk.assertions.contains import assertk.assertions.contains
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo import assertk.assertions.isEqualTo
import assertk.assertions.isFalse import assertk.assertions.isFalse
import assertk.assertions.isNotEqualTo
import assertk.assertions.isTrue import assertk.assertions.isTrue
import assertk.assertions.matches import assertk.assertions.matches
import assertk.assertions.prop import assertk.assertions.prop
import net.thauvin.erik.bitly.Utils.Companion.isValidUrl import assertk.assertions.startsWith
import net.thauvin.erik.bitly.Utils.Companion.removeHttp import net.thauvin.erik.bitly.Utils.isValidUrl
import net.thauvin.erik.bitly.Utils.Companion.toEndPoint 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.json.JSONObject
import org.junit.Before import org.junit.Before
import java.io.File import java.io.File
import java.util.logging.Level import java.util.logging.Level
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
class BitlyTest { class BitlyTest {
@ -76,7 +81,12 @@ class BitlyTest {
if (System.getenv("CI") == "true") { if (System.getenv("CI") == "true") {
test.accessToken = Constants.EMPTY 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 @Test
@ -100,7 +110,19 @@ class BitlyTest {
@Test @Test
fun `endPoint should be specified`() { 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 @Test
@ -110,16 +132,18 @@ class BitlyTest {
} }
@Test @Test
fun `as json`() { fun `shorten as json`() {
assertTrue(bitly.bitlinks().shorten(longUrl, toJson = true).startsWith("{\"created_at\":")) assertTrue(bitly.bitlinks().shorten(longUrl, toJson = true).startsWith("{\"created_at\":"))
} }
@Test @Test
fun `get user`() { fun `get user`() {
assertThat(bitly.call("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)")
.prop(CallResponse::isSuccessful).isTrue() .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 @Test
@ -128,7 +152,7 @@ class BitlyTest {
"ethauvin", "ethauvin",
JSONObject( JSONObject(
bitly.call( bitly.call(
"/bitlinks/${shortUrl.removeHttp()}".toEndPoint(), "/bitlinks/${shortUrl.removeHttp()}",
method = Methods.GET method = Methods.GET
).body ).body
).getString("created_by") ).getString("created_by")
@ -151,22 +175,31 @@ class BitlyTest {
bl.shorten(longUrl, domain = "bit.ly") bl.shorten(longUrl, domain = "bit.ly")
assertThat(bl.lastCallResponse, "shorten(longUrl)").all { assertThat(bl.lastCallResponse, "shorten(longUrl)").all {
prop(CallResponse::isSuccessful).isTrue() prop(CallResponse::isSuccessful).isTrue()
prop(CallResponse::resultCode).isEqualTo(200) prop(CallResponse::statusCode).isEqualTo(200)
prop(CallResponse::body).contains("\"link\":\"$shortUrl\"") prop(CallResponse::body).contains("\"link\":\"$shortUrl\"")
prop(CallResponse::message).isEmpty()
} }
bl.shorten(shortUrl) bl.shorten(shortUrl)
assertThat(bl.lastCallResponse, "shorten(shortUrl)").all { assertThat(bl.lastCallResponse, "shorten(shortUrl)").all {
prop(CallResponse::isSuccessful).isFalse() prop(CallResponse::isSuccessful).isFalse()
prop(CallResponse::resultCode).isEqualTo(400) prop(CallResponse::statusCode).isEqualTo(400)
prop(CallResponse::isBadRequest).isTrue() 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 @Test
fun `clicks summary`() { 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 @Test
@ -233,7 +266,9 @@ class BitlyTest {
bl.update("bit.ly/407GjJU", id = "foo") bl.update("bit.ly/407GjJU", id = "foo")
assertThat(bl.lastCallResponse).all { assertThat(bl.lastCallResponse).all {
prop(CallResponse::isForbidden).isTrue() 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 { assertThat(bl.lastCallResponse).all {
prop(CallResponse::isUnprocessableEntity).isTrue() 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")
} }
} }