diff --git a/bigbone/build.gradle b/bigbone/build.gradle index 3f57c6927..83e4e5397 100644 --- a/bigbone/build.gradle +++ b/bigbone/build.gradle @@ -8,6 +8,7 @@ plugins { dependencies { api libs.okhttp + implementation libs.kotlin.reflect implementation libs.kotlinx.serialization.json testImplementation libs.junit.jupiter diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonMinServerVersion.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonMinServerVersion.kt new file mode 100644 index 000000000..9d3bccb02 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonMinServerVersion.kt @@ -0,0 +1,81 @@ +package social.bigbone + +import social.bigbone.api.exception.BigBoneVersionException +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.math.max +import kotlin.reflect.KFunction +import kotlin.reflect.full.findAnnotation + +/** + * Specifies the first version of a Mastodon server where a declaration has appeared. + * In essence, this defines the minimum server version that is required in order to make a call successful. + * + * @property version the version in the following formats: `.` or `..`, where major, minor and patch + * are non-negative integer numbers without leading zeros. + */ +@Target(FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +internal annotation class MastodonMinServerVersion(val version: String) + +/** + * Tries to get the version defined in the [MastodonMinServerVersion] of this [KFunction], if any. + * + * @return [String] version of the [MastodonMinServerVersion] if this function is annotated, `null` otherwise + */ +internal fun KFunction.minMastodonVersion(): String? = findAnnotation()?.version + +/** + * Helper function to ensure that the annotated [MastodonMinServerVersion] of this [KFunction] is lower than that of the + * instance the [client] is connected to. + */ +@Throws(BigBoneVersionException::class) +fun KFunction.requireMinVersion(client: MastodonClient) { + val minMastodonVersion = minMastodonVersion() ?: return + val instanceVersion = client.getInstanceVersion() ?: return + if (SemanticVersion(instanceVersion) >= SemanticVersion(minMastodonVersion)) return + + throw BigBoneVersionException( + methodName = name, + minVersion = minMastodonVersion, + actualVersion = instanceVersion + ) +} + +/** + * Wrapper to allow comparison of version [String]s that follow semantic versioning. + * @see SemVer.org + */ +private class SemanticVersion(val version: String) { + + init { + require(version.matches(versionRegex)) { "String $version doesn't appear to contain a semantic version" } + } + + operator fun compareTo(other: SemanticVersion): Int { + val thisParts = parts() + val otherParts = other.parts() + for (i in 0 until max(thisParts.size, otherParts.size)) { + val thisPart = if (i < thisParts.size) thisParts[i].toInt() else 0 + val thatPart = if (i < otherParts.size) otherParts[i].toInt() else 0 + if (thisPart < thatPart) return -1 + if (thisPart > thatPart) return 1 + } + return 0 + } + + private fun parts() = version.split(".") + + companion object { + /** + * Suggested regular expression to parse all valid SemVer strings. + * @see SemVer RegEx + */ + private val versionRegex = ( + """^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)""" + + """(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)""" + + """(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?""" + + """(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?${'$'}""" + ).toRegex() + } +} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/exception/BigBoneVersionException.kt b/bigbone/src/main/kotlin/social/bigbone/api/exception/BigBoneVersionException.kt new file mode 100644 index 000000000..2c09e1190 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/exception/BigBoneVersionException.kt @@ -0,0 +1,11 @@ +package social.bigbone.api.exception + +import social.bigbone.MastodonMinServerVersion + +/** + * Exception which is thrown if a method annotated with a [MastodonMinServerVersion] requires a higher version of the + * Mastodon software to be running on a server than is actually running on it. + */ +class BigBoneVersionException(methodName: String, minVersion: String, actualVersion: String) : Exception( + "$methodName requires the server to run at least Mastodon $minVersion but it runs $actualVersion" +) diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/InstanceMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/InstanceMethods.kt index dd44e2292..983fd707a 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/InstanceMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/InstanceMethods.kt @@ -1,6 +1,7 @@ package social.bigbone.api.method import social.bigbone.MastodonClient +import social.bigbone.MastodonMinServerVersion import social.bigbone.MastodonRequest import social.bigbone.api.entity.DomainBlock import social.bigbone.api.entity.ExtendedDescription @@ -8,6 +9,8 @@ import social.bigbone.api.entity.Instance import social.bigbone.api.entity.InstanceActivity import social.bigbone.api.entity.InstanceV1 import social.bigbone.api.entity.Rule +import social.bigbone.api.exception.BigBoneVersionException +import social.bigbone.requireMinVersion /** * Allows access to API methods with endpoints having an "api/vX/instance" prefix. @@ -21,9 +24,14 @@ class InstanceMethods(private val client: MastodonClient) { /** * Obtain general information about the server. + * @throws BigBoneVersionException if the Mastodon server version doesn't match the [MastodonMinServerVersion] for this method * @see Mastodon API documentation: methods/instance/#v2 */ + @MastodonMinServerVersion("4.0.0") + @Throws(BigBoneVersionException::class) fun getInstance(): MastodonRequest { + InstanceMethods::getInstance.requireMinVersion(client) + return client.getMastodonRequest( endpoint = instanceEndpointV2, method = MastodonClient.Method.GET diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/InstanceMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/InstanceMethodsTest.kt index 374b169e7..740efcee8 100644 --- a/bigbone/src/test/kotlin/social/bigbone/api/method/InstanceMethodsTest.kt +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/InstanceMethodsTest.kt @@ -1,5 +1,6 @@ package social.bigbone.api.method +import io.mockk.Called import io.mockk.verify import org.amshove.kluent.invoking import org.amshove.kluent.shouldBeEqualTo @@ -16,6 +17,7 @@ import social.bigbone.api.entity.ExtendedDescription import social.bigbone.api.entity.InstanceActivity import social.bigbone.api.entity.Rule import social.bigbone.api.exception.BigBoneRequestException +import social.bigbone.api.exception.BigBoneVersionException import social.bigbone.testtool.MockClient import social.bigbone.testtool.TestUtil import java.time.Instant @@ -23,7 +25,7 @@ import java.time.Instant class InstanceMethodsTest { @Test fun getInstance() { - val client = MockClient.mock("instance.json") + val client = MockClient.mock(jsonName = "instance.json") val instanceMethods = InstanceMethods(client) val instance = instanceMethods.getInstance().execute() @@ -41,6 +43,18 @@ class InstanceMethodsTest { } } + @Test + fun `Given an instance with Mastodon 3-4-0, when calling getInstance, then fail with BigBoneVersionException`() { + val client = MockClient.mock(jsonName = "instance.json", instanceVersion = "3.4.0") + val instanceMethods = InstanceMethods(client) + + invoking { instanceMethods.getInstance().execute() } + .shouldThrow(BigBoneVersionException::class) + .withMessage("getInstance requires the server to run at least Mastodon 4.0.0 but it runs 3.4.0") + + verify { client.get(path = "/api/v2/instance", query = null) wasNot Called } + } + @Test fun getInstanceExtended() { val client = MockClient.mock("instance_extended.json") diff --git a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt index f97f908fd..507b2e88a 100644 --- a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt +++ b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt @@ -21,7 +21,8 @@ object MockClient { maxId: String? = null, sinceId: String? = null, requestUrl: String = "https://example.com", - responseBaseUrl: String = "https://mstdn.jp/api/v1/timelines/public" + responseBaseUrl: String = "https://mstdn.jp/api/v1/timelines/public", + instanceVersion: String = "1337.42.23" ): MastodonClient { val clientMock: MastodonClient = mockk() val response: Response = Response.Builder() @@ -59,11 +60,13 @@ object MockClient { every { clientMock.postRequestBody(any(), any()) } returns response every { clientMock.put(any(), any()) } returns response every { clientMock.performAction(any(), any(), any()) } returns Unit + every { clientMock.getInstanceVersion() } returns instanceVersion return clientMock } fun ioException( - requestUrl: String = "https://example.com" + requestUrl: String = "https://example.com", + instanceVersion: String = "1337.42.23" ): MastodonClient { val clientMock: MastodonClient = mockk() val responseBodyMock: ResponseBody = mockk() @@ -89,13 +92,15 @@ object MockClient { any() ) } throws BigBoneRequestException("mock") + every { clientMock.getInstanceVersion() } returns instanceVersion return clientMock } fun failWithResponse( responseJsonAssetPath: String, responseCode: Int, - message: String + message: String, + instanceVersion: String = "1337.42.23" ): MastodonClient { val clientMock: MastodonClient = mockk() val responseBodyMock: ResponseBody = mockk() @@ -124,6 +129,7 @@ object MockClient { any() ) } throws BigBoneRequestException(response) + every { clientMock.getInstanceVersion() } returns instanceVersion return clientMock } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 473c909d6..2f238d0ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ junit-platform-suite-engine = { module = "org.junit.platform:junit-platform-suit kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-dsl = { module = "io.mockk:mockk-dsl", version.ref = "mockk" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } diff --git a/sample-java/src/main/java/social/bigbone/sample/GetInstanceInfo.java b/sample-java/src/main/java/social/bigbone/sample/GetInstanceInfo.java index 1887ad162..03d7a85cc 100644 --- a/sample-java/src/main/java/social/bigbone/sample/GetInstanceInfo.java +++ b/sample-java/src/main/java/social/bigbone/sample/GetInstanceInfo.java @@ -3,11 +3,12 @@ import social.bigbone.MastodonClient; import social.bigbone.api.entity.Instance; import social.bigbone.api.exception.BigBoneRequestException; +import social.bigbone.api.exception.BigBoneVersionException; @SuppressWarnings("PMD.SystemPrintln") public class GetInstanceInfo { - public static void main(final String[] args) throws BigBoneRequestException { + public static void main(final String[] args) throws BigBoneRequestException, BigBoneVersionException { final String instance = args[0]; // Instantiate client