Skip to content

Commit

Permalink
Propagate exceptions occurring during client instantiation (#353)
Browse files Browse the repository at this point in the history
* Auto-close okhttp responses by using Kotlin's Closeable#use

* Return more specific ServerInfoRetrievalException if node info fails

* Fall back but propagate errors in the end when instantiating client

---------

Signed-off-by: PattaFeuFeu <[email protected]>
  • Loading branch information
PattaFeuFeu authored Nov 29, 2023
1 parent 8d8da41 commit dab65d4
Show file tree
Hide file tree
Showing 18 changed files with 620 additions and 455 deletions.
29 changes: 21 additions & 8 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,15 @@ scope = Scope()

## Registering an App

To access the API of a Mastodon server, we first need to create client credentials.
To access the API of a Mastodon server, we first need to create client credentials.


> [!IMPORTANT]
> When building an instance of the `MastodonClient`, it may throw a `BigBoneClientInstantiationException` if we could
> not
> successfully retrieve information about an instance you provide. The stacktrace of that exception should either help you
> find a solution, or give you necessary information you can provide to us, e.g. via the GitHub issues, to help you find
> one.
__Kotlin__

Expand All @@ -46,14 +54,19 @@ val appRegistration = client.apps.createApp(
__Java__

```java
MastodonClient client = new MastodonClient.Builder(instanceHostname).build();
try {
AppRegistration appRegistration = client.apps().createApp(
"bigbone-sample-app",
"urn:ietf:wg:oauth:2.0:oob",
new Scope(),
"https://example.org/"
).execute();
MastodonClient client=new MastodonClient.Builder(instanceHostname).build();
} catch (BigBoneClientInstantiationException e){
// error handling
}

try {
AppRegistration appRegistration=client.apps().createApp(
"bigbone-sample-app",
"urn:ietf:wg:oauth:2.0:oob",
new Scope(),
"https://example.org/"
).execute();
} catch (BigBoneRequestException e) {
// error handling
}
Expand Down
154 changes: 98 additions & 56 deletions bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import social.bigbone.api.Pageable
import social.bigbone.api.entity.data.InstanceVersion
import social.bigbone.api.exception.BigBoneClientInstantiationException
import social.bigbone.api.exception.BigBoneRequestException
import social.bigbone.api.exception.InstanceVersionRetrievalException
import social.bigbone.api.exception.ServerInfoRetrievalException
import social.bigbone.api.method.AccountMethods
import social.bigbone.api.method.AnnouncementMethods
import social.bigbone.api.method.AppMethods
Expand Down Expand Up @@ -50,6 +53,7 @@ import social.bigbone.api.method.admin.AdminMeasureMethods
import social.bigbone.api.method.admin.AdminRetentionMethods
import social.bigbone.extension.emptyRequestBody
import social.bigbone.nodeinfo.NodeInfoClient
import social.bigbone.nodeinfo.entity.Server
import java.io.IOException
import java.security.SecureRandom
import java.security.cert.X509Certificate
Expand All @@ -66,12 +70,12 @@ import javax.net.ssl.X509TrustManager
class MastodonClient
private constructor(
private val instanceName: String,
private val client: OkHttpClient
) {
private var debug = false
private var instanceVersion: String? = null
private var scheme: String = "https"
private val client: OkHttpClient,
private var debug: Boolean = false,
private var instanceVersion: String? = null,
private var scheme: String = "https",
private var port: Int = 443
) {

/**
* Access API methods under the "accounts" endpoint.
Expand Down Expand Up @@ -436,19 +440,20 @@ private constructor(
* @param endpoint the Mastodon API endpoint to call
* @param method the HTTP method to use
* @param parameters parameters to use in the action; can be null
* @throws BigBoneRequestException in case the action to be performed yielded an unsuccessful response
*/
@Throws(BigBoneRequestException::class)
internal fun performAction(endpoint: String, method: Method, parameters: Parameters? = null) {
val response = when (method) {
when (method) {
Method.DELETE -> delete(endpoint, parameters)
Method.GET -> get(endpoint, parameters)
Method.PATCH -> patch(endpoint, parameters)
Method.POST -> post(endpoint, parameters)
Method.PUT -> put(endpoint, parameters)
}
response.close()
if (!response.isSuccessful) {
throw BigBoneRequestException(response)
}.use { response: Response ->
if (!response.isSuccessful) {
throw BigBoneRequestException(response)
}
}
}

Expand Down Expand Up @@ -722,44 +727,74 @@ private constructor(
/**
* Get the version string for this Mastodon instance.
* @return a string corresponding to the version of this Mastodon instance
* @throws BigBoneRequestException if instance version can not be retrieved using any known method or API version
* @throws BigBoneClientInstantiationException if instance version cannot be retrieved using any known method or API version
*/
@Throws(BigBoneClientInstantiationException::class)
private fun getInstanceVersion(): String {
try {
val serverInfoVersion = NodeInfoClient
.retrieveServerInfo(instanceName)
?.software
?.takeIf { it.name == "mastodon" }
?.version
if (serverInfoVersion != null) return serverInfoVersion
} catch (_: BigBoneRequestException) {
return try {
getInstanceVersionViaServerInfo()
} catch (error: BigBoneClientInstantiationException) {
// fall back to retrieving from Mastodon API itself
try {
getInstanceVersionViaApi()
} catch (instanceException: InstanceVersionRetrievalException) {
throw BigBoneClientInstantiationException(
message = "Failed to get instance version of $instanceName",
cause = if (instanceException.cause == instanceException) {
instanceException.initCause(error)
} else {
instanceException
}
)
}
}
}

@Throws(ServerInfoRetrievalException::class)
private fun getInstanceVersionViaServerInfo(): String {
val serverSoftwareInfo: Server.Software? = NodeInfoClient
.retrieveServerInfo(instanceName)
?.software
?.takeIf { it.name == "mastodon" }

// fall back to retrieving from Mastodon API itself
val instanceVersion = getInstanceVersionFromApi(2) ?: getInstanceVersionFromApi(1)
return instanceVersion ?: throw BigBoneRequestException("Unable to fetch instance version")
if (serverSoftwareInfo != null) return serverSoftwareInfo.version

throw ServerInfoRetrievalException(
cause = IllegalArgumentException("Server $instanceName doesn't appear to run Mastodon")
)
}

@Throws(InstanceVersionRetrievalException::class)
private fun getInstanceVersionViaApi(): String {
return try {
getInstanceVersionFromApi(2)
} catch (e: InstanceVersionRetrievalException) {
getInstanceVersionFromApi(1)
}
}

/**
* Get the version string for this Mastodon instance, using a specific API version.
* @param apiVersion the version of API call to use in this request
* @return a string corresponding to the version of this Mastodon instance, or null if no version string can be
* retrieved using the specified API version.
* @throws InstanceVersionRetrievalException in case we got a server response but no version, or an unsucessful response
*/
private fun getInstanceVersionFromApi(apiVersion: Int): String? {
return try {
val response = versionedInstanceRequest(apiVersion)
@Throws(InstanceVersionRetrievalException::class)
private fun getInstanceVersionFromApi(apiVersion: Int): String {
return versionedInstanceRequest(apiVersion).use { response: Response ->
if (response.isSuccessful) {
val instanceVersion: InstanceVersion? = response.body?.string()?.let { responseBody: String ->
JSON_SERIALIZER.decodeFromString(responseBody)
}
instanceVersion?.version
instanceVersion
?.version
?: throw InstanceVersionRetrievalException(
cause = IllegalStateException("Instance version was null unexpectedly")
)
} else {
response.close()
null
throw InstanceVersionRetrievalException(response = response)
}
} catch (e: Exception) {
null
}
}

Expand All @@ -779,28 +814,36 @@ private constructor(
* @return server response for this request
*/
internal fun versionedInstanceRequest(version: Int): Response {
val versionString = if (version == 2) {
"v2"
} else {
"v1"
}
val versionString = if (version == 2) "v2" else "v1"

val clientBuilder = OkHttpClient.Builder()
if (trustAllCerts) {
configureForTrustAll(clientBuilder)
}
val client = clientBuilder.build()
return client.newCall(
Request.Builder().url(
fullUrl(
scheme,
instanceName,
port,
"api/$versionString/instance"
)
).get().build()
).execute()
if (trustAllCerts) configureForTrustAll(clientBuilder)

return clientBuilder
.build()
.newCall(
Request.Builder()
.url(
fullUrl(
scheme = scheme,
instanceName = instanceName,
port = port,
path = "api/$versionString/instance"
)
)
.get()
.build()
)
.execute()
}

/**
* Builds this MastodonClient.
*
* @throws BigBoneClientInstantiationException if the client could not be instantiated, likely due to an issue
* when getting the instance version of the server in [instanceName]. Other exceptions, e.g. due to no Internet
* connection are _not_ caught by this library.
*/
fun build(): MastodonClient {
return MastodonClient(
instanceName = instanceName,
Expand All @@ -809,13 +852,12 @@ private constructor(
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(writeTimeoutSeconds, TimeUnit.SECONDS)
.connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS)
.build()
).also {
it.debug = debug
it.instanceVersion = getInstanceVersion()
it.scheme = scheme
it.port = port
}
.build(),
debug = debug,
instanceVersion = getInstanceVersion(),
scheme = scheme,
port = port
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package social.bigbone.api.exception

import okhttp3.Response
import social.bigbone.MastodonClient
import social.bigbone.nodeinfo.entity.NodeInfo

/**
* Exception thrown if we could not instantiate a [MastodonClient]. Mostly used to wrap other more specific exceptions.
*/
open class BigBoneClientInstantiationException : Exception {
constructor() : super()
constructor(message: String) : super(message)
constructor(message: String, cause: Throwable?) : super(message, cause)
constructor(cause: Throwable?) : super(cause)
}

/**
* Exception thrown if we could not retrieve server information during [MastodonClient] instantiation.
*/
class ServerInfoRetrievalException : BigBoneClientInstantiationException {
constructor(cause: Throwable?) : super(cause)
constructor(message: String, cause: Throwable?) : super(message, cause)
constructor(response: Response, message: String? = null) : super(
message = "${message ?: ""}${response.message}"
)
}

/**
* Exception thrown if we could not successfully get the [NodeInfo] server URL during [MastodonClient] instantiation.
*/
class ServerInfoUrlRetrievalException(
response: Response,
message: String? = null
) :
BigBoneClientInstantiationException(
message = "${message ?: ""}${response.message}"
)

/**
* Exception thrown if we could not retrieve the instance version of a Mastodon server during [MastodonClient] instantiation.
*/
class InstanceVersionRetrievalException : BigBoneClientInstantiationException {
constructor(cause: Throwable?) : super(cause)
constructor(response: Response, message: String? = null) : super(
message = "${response.code}${message ?: ""}${response.message}"
)
}
Loading

0 comments on commit dab65d4

Please sign in to comment.