Repository: andregasser/bigbone (kotlin)

Issue:
Add support for non mastodon mastodon compatible APIs
**Is your feature request related to a problem? Please describe.**
As a user i would like using this library with mastodon compatible APIs that don't explicitly advertise themselves as mastodon itself.

**Describe the solution you'd like**
Allow configuring the mastodon check to allow other sever software that also supports the mastodon api. Perhaps a lambda that receives the server software with a default implementation that throws the exception.


**Describe alternatives you've considered**
Bytecode patching out the check.

**Additional context**
The mastodon api compatible software is gotosocial (https://gotosocial.org/).

Pull request description:
# Description

#577 requests an option to disable checks for the software and version of servers the BigBone library connects to. This PR does this by offering new functionality in `MastodonClient.Builder`:

Checks disabled by calling `disableCompatibilityChecks()` include the initial check for the server software reporting as Mastodon while building the client, as well as subsequent checks for the exact API version in use. This allows the resulting MastodonClient to be used with other server software offering a Mastodon-compatible API, at the cost of having to check for invalid requests yourself. Endpoints and individual attributes not defined in the official Mastodon API are not supported.

Following this change, all checks of `client.getInstance().apiVersions.mastodon` that we might want to add in the future would need to be wrapped in `if (client.performCompatibilityChecks) { ... }`. `FilterMethods.createFilter(...)` is an example of how this might look.

Closes #577.

#582 requests to at least fail with a proper exception when connecting to a server running outdated Mastodon software. Before, it would potentially fail due to not being able to retrieve instance information from a v2 endpoint not implemented in earlier versions of Mastodon. This PR does this by adding a check against the version number reported by the server via NodeInfo when building a `MastodonClient`.

A `minimumSupportedVersion` ist introduced, currently set to `4.2.0`.

Closes #582.

# Breaking change

Building a `MastodonClient` will now fail with an explicit `UnsupportedServerException` if the server version is below a minimum we define. This can be circumvented by calling `disableCompatibilityChecks()` when building a client, which may lead to further exceptions when using the resulting client with server versions that are not fully supported.

# Type of Change

- New feature

# How Has This Been Tested?

# Mandatory Checklist

- [x] I ran `gradle check` and there were no errors reported
- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] All tests pass locally with my changes
- [x] I have added KDoc documentation to all public methods

Patch:
diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
index d5ffd1dc4..d7963cedd 100644
--- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
@@ -89,7 +89,7 @@ import javax.net.ssl.SSLSession
 import javax.net.ssl.X509TrustManager
 
 /**
- * This class is used by method classes (e.g. AccountMethods, RxAcccountMethods, ...) and performs HTTP calls
+ * This class is used by method classes (e.g. AccountMethods, RxAccountMethods, ...) and performs HTTP calls
  * towards the Mastodon instance specified. Request/response data is serialized/deserialized accordingly.
  */
 class MastodonClient private constructor(
@@ -98,7 +98,8 @@ class MastodonClient private constructor(
     private val accessToken: String? = null,
     private val debug: Boolean = false,
     private val scheme: String = "https",
-    private val port: Int = 443
+    private val port: Int = 443,
+    internal val performCompatibilityChecks: Boolean = true
 ) {
 
     internal val streamingUrl: HttpUrl by lazy {
@@ -826,6 +827,11 @@ class MastodonClient private constructor(
     }
 
     companion object {
+        /**
+         * The minimum Mastodon server version supported by the BigBone library.
+         */
+        private val minimumSupportedVersion = SemanticVersion("4.2.0")
+
         /**
          * Returns a HttpUrl.
          * @param scheme the schema to be used, either "http" or "https"
@@ -893,6 +899,7 @@ class MastodonClient private constructor(
         private var scheme = "https"
         private var port = 443
         private var trustAllCerts = false
+        private var performCompatibilityChecks = true
         private var readTimeoutSeconds = 10L
         private var writeTimeoutSeconds = 10L
         private var connectTimeoutSeconds = 10L
@@ -963,6 +970,18 @@ class MastodonClient private constructor(
             connectTimeoutSeconds = timeoutSeconds
         }
 
+        /**
+         * Disable checks of the server software and version.
+         * Checks disabled by this include the initial check for the server software reporting as Mastodon and
+         * its version compared to a minimum supported version while building the client, as well as subsequent
+         * checks for the exact API version in use. This allows the resulting MastodonClient to be used with other
+         * server software offering a Mastodon-compatible API, at the cost of having to check for invalid requests
+         * yourself. Endpoints and individual attributes not defined in the official Mastodon API are not supported.
+         */
+        fun disableCompatibilityChecks() = apply {
+            this.performCompatibilityChecks = false
+        }
+
         fun debug() = apply {
             this.debug = true
         }
@@ -973,14 +992,22 @@ class MastodonClient private constructor(
          * @throws BigBoneClientInstantiationException if server doesn't appear to run Mastodon
          */
         @Throws(BigBoneClientInstantiationException::class)
-        private fun requireServerRunsMastodon() {
-            val serverRunsMastodon: Boolean = NodeInfoClient
-                .retrieveServerInfo(host = instanceName, scheme = scheme, port = port)
-                ?.software
-                ?.name == "mastodon"
-            if (serverRunsMastodon) return
-
-            throw UnsupportedServerException(message = "Server $instanceName doesn't appear to run Mastodon")
+        private fun requireServerRunsMastodonAtMinimumVersion() {
+            val server = NodeInfoClient.retrieveServerInfo(host = instanceName, scheme = scheme, port = port)
+            val serverRunsMastodon: Boolean = server?.software?.name == "mastodon"
+
+            val versionString = server?.software?.version ?: ""
+            val serverRunsMinimumVersion: Boolean = SemanticVersion(versionString) >= minimumSupportedVersion
+
+            if (!serverRunsMastodon) {
+                throw UnsupportedServerException(message = "Server $instanceName doesn't appear to run Mastodon")
+            }
+
+            if (!serverRunsMinimumVersion) {
+                throw UnsupportedServerException(
+                    message = "Server $instanceName runs Mastodon at unsupported version $versionString < $minimumSupportedVersion"
+                )
+            }
         }
 
         private fun configureForTrustAll(clientBuilder: OkHttpClient.Builder) {
@@ -1060,7 +1087,9 @@ class MastodonClient private constructor(
          * connection are _not_ caught by this library.
          */
         fun build(): MastodonClient {
-            requireServerRunsMastodon()
+            if (performCompatibilityChecks) {
+                requireServerRunsMastodonAtMinimumVersion()
+            }
 
             val httpClient = okHttpClientBuilder
                 .addNetworkInterceptor(AuthorizationInterceptor(accessToken))
@@ -1077,7 +1106,8 @@ class MastodonClient private constructor(
                 accessToken = accessToken,
                 debug = debug,
                 scheme = scheme,
-                port = port
+                port = port,
+                performCompatibilityChecks = performCompatibilityChecks,
             )
         }
     }
diff --git a/bigbone/src/main/kotlin/social/bigbone/SemanticVersion.kt b/bigbone/src/main/kotlin/social/bigbone/SemanticVersion.kt
new file mode 100644
index 000000000..2c068d422
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/SemanticVersion.kt
@@ -0,0 +1,56 @@
+package social.bigbone
+
+/**
+ * Partial implementation of semantic versioning, enough to deal with version strings as returned
+ * by Mastodon, e.g. as part of Instance information or via NodeInfo. This implementation interprets
+ * the first three integer values as major, minor and patch, respectively, while ignoring additional
+ * information such as pre-release and build.
+ * @see <a href="https://semver.org/">https://semver.org/</a>
+ * @property versionString string to parse as Semantic Version
+ */
+class SemanticVersion(val versionString: String) : Comparable<SemanticVersion> {
+    /**
+     * Major version number for this Semantic Version.
+     */
+    val major: Int
+
+    /**
+     * Minor version number for this Semantic Version.
+     */
+    val minor: Int
+
+    /**
+     * Patch version number for this Semantic Version.
+     */
+    val patch: Int
+
+    init {
+        val versionStringParts = versionString.split(".", "-", "+")
+        major = versionStringParts.getOrNull(0)?.toIntOrNull() ?: -1
+        minor = versionStringParts.getOrNull(1)?.toIntOrNull() ?: 0
+        patch = versionStringParts.getOrNull(2)?.toIntOrNull() ?: 0
+    }
+
+    /**
+     * Returns true if at least [major] is a valid value; false else.
+     */
+    val valid: Boolean
+        get() = major > -1
+
+    /**
+     * Compares this SemanticVersion with another for order. Returns zero if this object is equal to the specified other
+     * object, a negative number if it's less than other, or a positive number if it's greater than other.
+     * @param other SemanticVersion to compare to
+     */
+    override fun compareTo(other: SemanticVersion): Int =
+        compareBy(
+            SemanticVersion::major,
+            SemanticVersion::minor,
+            SemanticVersion::patch
+        ).compare(this, other)
+
+    /**
+     * Returns this version as a string.
+     */
+    override fun toString(): String = "$major.$minor.$patch"
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/FilterMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/FilterMethods.kt
index f539f7cd3..c175745f2 100644
--- a/bigbone/src/main/kotlin/social/bigbone/api/method/FilterMethods.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/FilterMethods.kt
@@ -61,7 +61,7 @@ class FilterMethods(private val client: MastodonClient) {
         filterAction: Filter.FilterAction = Filter.FilterAction.WARN
     ): MastodonRequest<Filter> {
         // see: https://github.com/mastodon/mastodon/pull/34256
-        if (filterAction == Filter.FilterAction.BLUR && client.getInstance().apiVersions.mastodon < 5) {
+        if (client.performCompatibilityChecks && filterAction == Filter.FilterAction.BLUR && client.getInstance().apiVersions.mastodon < 5) {
             throw BigBoneRequestException("FilterAction.BLUR not available on Mastodon instance with API version < 5")
         }
 


Test patch:
diff --git a/bigbone/src/test/kotlin/social/bigbone/MastodonClientTest.kt b/bigbone/src/test/kotlin/social/bigbone/MastodonClientTest.kt
index 91df5a0c4..f152fca57 100644
--- a/bigbone/src/test/kotlin/social/bigbone/MastodonClientTest.kt
+++ b/bigbone/src/test/kotlin/social/bigbone/MastodonClientTest.kt
@@ -133,6 +133,30 @@ class MastodonClientTest {
             .withMessage("Server $serverUrl doesn't appear to run Mastodon")
     }
 
+    @Test
+    fun `Given a Mastodon server running a version too small, when building MastodonClient with defaults, then fail with UnsupportedServerException`() {
+        val serverUrl = "mastodon.example"
+        val clientBuilder = spyk(MastodonClient.Builder(serverUrl)) {
+            // Mock internal NodeInfoClient so that we don't open the site in unit testing
+            mockkObject(NodeInfoClient)
+            every { NodeInfoClient.retrieveServerInfo(host = serverUrl) } returns Server(
+                schemaVersion = "2.0",
+                software = Server.Software(name = "mastodon", version = "4.0.0")
+            )
+
+            val responseMock = mockk<Response> {
+                every { code } answers { 404 }
+                every { message } answers { "Not Found" }
+                every { isSuccessful } answers { false }
+                every { close() } returns Unit
+            }
+            every { executeInstanceRequest(any()) } answers { responseMock }
+        }
+        invoking(clientBuilder::build)
+            .shouldThrow(UnsupportedServerException::class)
+            .withMessage("Server $serverUrl runs Mastodon at unsupported version 4.0.0 < 4.2.0")
+    }
+
     @Test
     fun `Given a server that cannot be reached, when building MastodonClient, then propagate UnknownHostException`() {
         val clientBuilder: MastodonClient.Builder = spyk(MastodonClient.Builder("unreachabledomain"))
@@ -150,7 +174,7 @@ class MastodonClientTest {
             mockkObject(NodeInfoClient)
             every { NodeInfoClient.retrieveServerInfo(host = serverUrl) } returns Server(
                 schemaVersion = "2.0",
-                software = Server.Software(name = "mastodon", version = "4.0.0")
+                software = Server.Software(name = "mastodon", version = "4.3.5")
             )
 
             val jsonResponse = """
@@ -193,7 +217,7 @@ class MastodonClientTest {
             mockkObject(NodeInfoClient)
             every { NodeInfoClient.retrieveServerInfo(host = serverUrl) } returns Server(
                 schemaVersion = "2.0",
-                software = Server.Software(name = "mastodon", version = "4.0.0")
+                software = Server.Software(name = "mastodon", version = "4.3.5")
             )
 
             val jsonResponse = """
diff --git a/bigbone/src/test/kotlin/social/bigbone/SemanticVersionTest.kt b/bigbone/src/test/kotlin/social/bigbone/SemanticVersionTest.kt
new file mode 100644
index 000000000..054d53210
--- /dev/null
+++ b/bigbone/src/test/kotlin/social/bigbone/SemanticVersionTest.kt
@@ -0,0 +1,66 @@
+package social.bigbone
+
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.jupiter.api.Test
+
+class SemanticVersionTest {
+    @Test
+    fun parseValidVersionStrings() {
+        // given valid version strings, when parsing them as SemanticVersion
+        val semVerStandardRelease = SemanticVersion("4.3.8")
+        val semVerNightly = SemanticVersion("4.4.0-nightly.2025-05-24")
+        val semVerQoto = SemanticVersion("3.5.19-qoto")
+
+        // then these should have the major, minor, patch values expected
+        semVerStandardRelease.valid shouldBeEqualTo true
+        semVerStandardRelease.major shouldBeEqualTo 4
+        semVerStandardRelease.minor shouldBeEqualTo 3
+        semVerStandardRelease.patch shouldBeEqualTo 8
+
+        semVerNightly.valid shouldBeEqualTo true
+        semVerNightly.major shouldBeEqualTo 4
+        semVerNightly.minor shouldBeEqualTo 4
+        semVerNightly.patch shouldBeEqualTo 0
+
+        semVerQoto.valid shouldBeEqualTo true
+        semVerQoto.major shouldBeEqualTo 3
+        semVerQoto.minor shouldBeEqualTo 5
+        semVerQoto.patch shouldBeEqualTo 19
+    }
+
+    @Test
+    fun parseInvalidVersionString() {
+        // given invalid version strings, when parsing them as SemanticVersion
+        val leadingV = SemanticVersion("v1.2.3")
+        val nonVersion = SemanticVersion("foobar")
+
+        // then these should not be valid either
+        leadingV.valid shouldBeEqualTo false
+        nonVersion.valid shouldBeEqualTo false
+    }
+
+    @Test
+    fun checkComparison() {
+        // given valid version strings that are different, when parsing them as SemanticVersion
+        val semVer123 = SemanticVersion("1.2.3")
+        val semVer124 = SemanticVersion("1.2.4")
+        val semVer130 = SemanticVersion("1.3.0")
+        val semVer400 = SemanticVersion("4.0.0")
+
+        // then larger ones should return true when compared to smaller ones
+        (semVer400 > semVer130) shouldBeEqualTo true
+        (semVer130 > semVer124) shouldBeEqualTo true
+        (semVer124 > semVer123) shouldBeEqualTo true
+
+        // and smaller ones should return false when compared to larger ones
+        (semVer123 > semVer124) shouldBeEqualTo false
+        (semVer124 > semVer130) shouldBeEqualTo false
+        (semVer130 > semVer400) shouldBeEqualTo false
+
+        // and one compared to itself should return false for ">"
+        (semVer123 > semVer123) shouldBeEqualTo false
+
+        // but true for ">="
+        (semVer123 >= semVer123) shouldBeEqualTo true
+    }
+}
diff --git a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt
index 37e6bb3bc..0c48254b3 100644
--- a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt
+++ b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt
@@ -106,6 +106,7 @@ object MockClient {
         every { clientMock.put(any<String>(), any<Parameters>()) } returns response
         every { clientMock.putRequestBody(any<String>(), any<RequestBody>()) } returns response
         every { clientMock.performAction(any<String>(), any<MastodonClient.Method>(), any<Parameters>()) } returns Unit
+        every { clientMock.performCompatibilityChecks } returns true
         return clientMock
     }
 
@@ -137,6 +138,7 @@ object MockClient {
                 any<Parameters>()
             )
         } throws BigBoneRequestException("mock")
+        every { clientMock.performCompatibilityChecks } returns true
         return clientMock
     }