Initial commit.

This commit is contained in:
Erik C. Thauvin 2020-02-25 13:51:54 -08:00
commit a444b72b87
23 changed files with 1333 additions and 0 deletions

View file

@ -0,0 +1,252 @@
package net.thauvin.erik.bitly
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import org.json.JSONException
import org.json.JSONObject
import java.io.File
import java.net.MalformedURLException
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import java.util.Properties
import java.util.logging.Level
import java.util.logging.Logger
/**
* The HTTP methods.
*/
enum class Methods {
DELETE, GET, PATCH, POST
}
/**
* A simple implementation of the Bitly API v4.
*
* @constructor Creates new instance.
*/
open class Bitly() {
/** Constants for this package. **/
object Constants {
/** The Bitly API base URL. **/
const val API_BASE_URL = "https://api-ssl.bitly.com/v4"
/** The API access token environment variable. **/
const val ENV_ACCESS_TOKEN = "BITLY_ACCESS_TOKEN"
}
/** The API access token. **/
var accessToken: String = System.getenv(Constants.ENV_ACCESS_TOKEN) ?: ""
/** The logger instance. **/
val logger: Logger by lazy { Logger.getLogger(Bitly::class.java.simpleName) }
private var client: OkHttpClient
init {
if (logger.isLoggable(Level.FINE)) {
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
redactHeader("Authorization")
}
client = OkHttpClient.Builder().addInterceptor(httpLoggingInterceptor).build()
} else {
client = OkHttpClient.Builder().build()
}
}
/**
* Creates a new instance using an [API Access Token][accessToken].
*
* @param accessToken The API access token.
*/
@Suppress("unused")
constructor(accessToken: String) : this() {
this.accessToken = accessToken
}
/**
* Creates a new instance using a [Properties][properties] and [Property Key][key].
*
* @param properties The properties.
* @param key The property key.
*/
@Suppress("unused")
@JvmOverloads
constructor(properties: Properties, key: String = Constants.ENV_ACCESS_TOKEN) : this() {
accessToken = properties.getProperty(key, accessToken)
}
/**
* Creates a new instance using a [Properties File Path][propertiesFilePath] and [Property Key][key].
*
* @param propertiesFilePath The properties file path.
* @param key The property key.
*/
@JvmOverloads
constructor(propertiesFilePath: Path, key: String = Constants.ENV_ACCESS_TOKEN) : this() {
if (Files.exists(propertiesFilePath)) {
accessToken = Properties().apply {
Files.newInputStream(propertiesFilePath).use { nis ->
load(nis)
}
}.getProperty(key, accessToken)
}
}
/**
* Creates a new instance using a [Properties File][propertiesFile] and [Property Key][key].
*
* @param propertiesFile The properties file.
* @param key The property key.
*/
@Suppress("unused")
@JvmOverloads
constructor(propertiesFile: File, key: String = Constants.ENV_ACCESS_TOKEN) : this(propertiesFile.toPath(), key)
/**
* Builds the full API endpoint URL using the [Constants.API_BASE_URL].
*
* @param endPointPath The REST method path. (eg. `/shorten', '/user`)
*/
fun buildEndPointUrl(endPointPath: String): String {
return if (endPointPath.startsWith('/')) {
"${Constants.API_BASE_URL}$endPointPath"
} else {
"${Constants.API_BASE_URL}/$endPointPath"
}
}
/**
* Executes an API call.
*
* @param endPoint The API endpoint. (eg. `/shorten`, `/user`)
* @param params The request parameters kev/value map.
* @param method The submission [Method][Methods].
* @return The response (JSON) from the API.
*/
fun executeCall(endPoint: String, params: Map<String, String>, method: Methods = Methods.POST): String {
var returnValue = ""
if (endPoint.isBlank()) {
logger.severe("Please specify a valid API endpoint.")
} else if (accessToken.isBlank()) {
logger.severe("Please specify a valid API access token.")
} else {
val apiUrl = endPoint.toHttpUrlOrNull()
val builder: Request.Builder
if (apiUrl != null) {
if (method == Methods.POST || method == Methods.PATCH) {
val formBody = JSONObject(params).toString()
.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
builder = Request.Builder().apply {
url(apiUrl.newBuilder().build())
if (method == Methods.POST)
post(formBody)
else
patch(formBody)
}
} else if (method == Methods.DELETE) {
builder = Request.Builder().url(apiUrl.newBuilder().build()).delete()
} else {
val httpUrl = apiUrl.newBuilder().apply {
params.forEach {
addQueryParameter(it.key, it.value)
}
}.build()
builder = Request.Builder().url(httpUrl)
}
builder.addHeader("Authorization", "Bearer $accessToken")
val result = client.newCall(builder.build()).execute()
val body = result.body?.string()
if (body != null) {
if (!result.isSuccessful && body.isNotEmpty()) {
logApiError(body, result.code)
}
returnValue = body
}
}
}
return returnValue
}
/**
* Shortens a long URL.
*
* See the [Bit.ly API](https://dev.bitly.com/v4/#operation/createBitlink) for more information.
*
* @param long_url The long URL.
* @param group_guid The group UID.
* @param domain The domain, defaults to `bit.ly`.
* @param isJson Returns the full JSON API response if `true`
* @return THe short URL or JSON API response.
*/
@JvmOverloads
fun shorten(long_url: String, group_guid: String = "", domain: String = "", isJson: Boolean = false): String {
var returnValue = if (isJson) "{}" else ""
if (!validateUrl(long_url)) {
logger.severe("Please specify a valid URL to shorten.")
} else {
val params: HashMap<String, String> = HashMap()
if (group_guid.isNotBlank())
params["group_guid"] = group_guid
if (domain.isNotBlank())
params["domain"] = domain
params["long_url"] = long_url
val response = executeCall(buildEndPointUrl("/shorten"), params)
if (response.isNotEmpty()) {
if (isJson) {
returnValue = response
} else {
try {
val json = JSONObject(response)
if (json.has("link"))
returnValue = json.getString("link")
} catch (ignore: JSONException) {
logger.severe("An error occurred parsing the response from bitly.")
}
}
}
}
return returnValue
}
private fun logApiError(body: String, resultCode: Int) {
try {
val jsonResponse = JSONObject(body)
if (jsonResponse.has("message")) {
logger.severe(jsonResponse.getString("message") + " ($resultCode)")
}
if (jsonResponse.has("description")) {
val description = jsonResponse.getString("description")
if (description.isNotBlank()) {
logger.severe(description)
}
}
} catch (ignore: JSONException) {
logger.severe("An error occurred parsing the error response from bitly.")
}
}
private fun validateUrl(url: String): Boolean {
var isValid = url.isNotBlank()
if (isValid) {
try {
URL(url)
} catch (e: MalformedURLException) {
logger.log(Level.FINE, "Invalid URL: $url", e)
isValid = false
}
}
return isValid
}
}

View file

@ -0,0 +1,72 @@
package net.thauvin.erik.bitly
import org.junit.Before
import java.io.File
import java.io.FileInputStream
import java.util.Properties
import java.util.logging.Level
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
fun getKey(key: String): String {
var value = System.getenv(key) ?: ""
if (value.isBlank()) {
val localProps = File("local.properties")
if (localProps.exists())
localProps.apply {
if (exists()) {
FileInputStream(this).use { fis ->
Properties().apply {
load(fis)
value = getProperty(key, "")
}
}
}
}
}
return value
}
class BitlyTest {
private val bitly = Bitly(getKey(Bitly.Constants.ENV_ACCESS_TOKEN))
@Before
fun before() {
with(bitly.logger) {
level = Level.FINE
}
}
@Test
fun `token should be specified`() {
val test = Bitly()
assertEquals("", test.shorten("https://erik.thauvin.net/blog/"))
}
@Test
fun `token should be valid`() {
val test = Bitly().apply { accessToken = "12345679" }
assertEquals("{\"message\":\"FORBIDDEN\"}", test.shorten("https://erik.thauvin.net/blog", isJson = true))
}
@Test
fun `long url should be valid`() {
assertEquals("", bitly.shorten(""))
}
@Test
fun `blog should be valid`() {
assertEquals("http://bit.ly/2SVHsnd", bitly.shorten("https://erik.thauvin.net/blog/", domain = "bit.ly"))
}
@Test
fun `blog as json`() {
assertTrue(bitly.shorten("https://erik.thauvin.net/blog/", isJson = true).startsWith("{\"created_at\":"))
}
@Test
fun `get user`() {
assertTrue(bitly.executeCall(bitly.buildEndPointUrl("user"), emptyMap(), Methods.GET).contains("\"login\":"))
}
}