Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Propagate exceptions occurring during client instantiation #353

Merged
merged 17 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
155 changes: 99 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,37 @@ 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.
*/
@Throws(BigBoneClientInstantiationException::class)
fun build(): MastodonClient {
return MastodonClient(
instanceName = instanceName,
Expand All @@ -809,13 +853,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