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

Proof of concept: MastodonMinServerVersion annotation #335

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions bigbone/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ plugins {
dependencies {
api libs.okhttp

implementation libs.kotlin.reflect
implementation libs.kotlinx.serialization.json

testImplementation libs.junit.jupiter
Expand Down
81 changes: 81 additions & 0 deletions bigbone/src/main/kotlin/social/bigbone/MastodonMinServerVersion.kt
Original file line number Diff line number Diff line change
@@ -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: `<major>.<minor>` or `<major>.<minor>.<patch>`, 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 <T> KFunction<T>.minMastodonVersion(): String? = findAnnotation<MastodonMinServerVersion>()?.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 <T> KFunction<T>.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 <a href="https://semver.org">SemVer.org</a>
*/
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 <a href="https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string">SemVer RegEx</a>
*/
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-]+)*))?${'$'}"""
Comment on lines +75 to +78
Copy link
Collaborator Author

@PattaFeuFeu PattaFeuFeu Nov 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might need to be a bit more lenient, seeing as Mastodon doesn’t follow semantic versioning to the letter at least when it comes to release candidates, as can be seen in this example JSON:

They only switched to a more SemVer-friendly variant starting with 4.2.0-beta3 in mastodon/mastodon#26653:

https://github.com/mastodon/mastodon/blob/v4.2.0-beta3/lib/mastodon/version.rb#L36-L38

).toRegex()
}
}
Original file line number Diff line number Diff line change
@@ -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"
)
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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
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.
Expand All @@ -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 <a href="https://docs.joinmastodon.org/methods/instance/#v2">Mastodon API documentation: methods/instance/#v2</a>
*/
@MastodonMinServerVersion("4.0.0")
@Throws(BigBoneVersionException::class)
fun getInstance(): MastodonRequest<Instance> {
InstanceMethods::getInstance.requireMinVersion(client)

return client.getMastodonRequest(
endpoint = instanceEndpointV2,
method = MastodonClient.Method.GET
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,14 +17,15 @@ 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

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()
Expand All @@ -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")
Expand Down
12 changes: 9 additions & 3 deletions bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -59,11 +60,13 @@ object MockClient {
every { clientMock.postRequestBody(any<String>(), any<RequestBody>()) } returns response
every { clientMock.put(any<String>(), any<Parameters>()) } returns response
every { clientMock.performAction(any<String>(), any<MastodonClient.Method>(), any<Parameters>()) } 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()
Expand All @@ -89,13 +92,15 @@ object MockClient {
any<Parameters>()
)
} 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()
Expand Down Expand Up @@ -124,6 +129,7 @@ object MockClient {
any<Parameters>()
)
} throws BigBoneRequestException(response)
every { clientMock.getInstanceVersion() } returns instanceVersion
return clientMock
}
}
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down