diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 241be52f5..f5593a11e 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -226,11 +226,13 @@ 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 + * @param addIdempotencyKey if true, adds idempotency key to header to avoid duplicate POST requests */ internal inline fun getMastodonRequest( endpoint: String, method: Method, - parameters: Parameters? = null + parameters: Parameters? = null, + addIdempotencyKey: Boolean = false ): MastodonRequest { return MastodonRequest( { @@ -238,7 +240,7 @@ private constructor( Method.DELETE -> delete(endpoint, parameters) Method.GET -> get(endpoint, parameters) Method.PATCH -> patch(endpoint, parameters) - Method.POST -> post(endpoint, parameters) + Method.POST -> post(endpoint, parameters, addIdempotencyKey) Method.PUT -> put(endpoint, parameters) } }, @@ -386,27 +388,38 @@ private constructor( * Get a response from the Mastodon instance defined for this client using the POST method. * @param path an absolute path to the API endpoint to call * @param body the parameters to use in the request body for this request; may be null + * @param addIdempotencyKey if true, generate idempotency key for this request */ - fun post(path: String, body: Parameters? = null): Response = - postRequestBody(path, parameterBody(body)) + fun post(path: String, body: Parameters? = null, addIdempotencyKey: Boolean = false): Response { + val idempotencyKey = if (addIdempotencyKey) { + body?.uuid() + } else { + null + } + return postRequestBody(path, parameterBody(body), idempotencyKey) + } /** * Get a response from the Mastodon instance defined for this client using the POST method. Use this method if * you need to build your own RequestBody; see post() otherwise. * @param path an absolute path to the API endpoint to call * @param body the RequestBody to use for this request + * @param idempotencyKey optional idempotency value to avoid duplicate calls * * @see post */ - fun postRequestBody(path: String, body: RequestBody): Response { + fun postRequestBody(path: String, body: RequestBody, idempotencyKey: String? = null): Response { try { val url = fullUrl(scheme, instanceName, port, path) debugPrintUrl(url) val call = client.newCall( - Request.Builder() - .url(url) - .post(body) - .build() + Request.Builder().apply { + url(url) + post(body) + idempotencyKey?.let { + header("Idempotency-Key", it) + } + }.build() ) return call.execute() } catch (e: IllegalArgumentException) { diff --git a/bigbone/src/main/kotlin/social/bigbone/Parameters.kt b/bigbone/src/main/kotlin/social/bigbone/Parameters.kt index fd8d723d3..3bb5a05ff 100644 --- a/bigbone/src/main/kotlin/social/bigbone/Parameters.kt +++ b/bigbone/src/main/kotlin/social/bigbone/Parameters.kt @@ -2,6 +2,7 @@ package social.bigbone import java.net.URLEncoder import java.util.ArrayList +import java.util.UUID /** * Parameters holds a list of String key/value pairs that can be used as query part of a URL, or in the body of a request. @@ -80,4 +81,18 @@ class Parameters { parameterList.joinToString(separator = "&") { "${it.first}=${URLEncoder.encode(it.second, "utf-8")}" } + + /** + * Generates a UUID string for this parameter list. UUIDs returned for different Parameters instances should be + * the same if they contain the same list of key/value pairs, even if pairs were appended in different order, + * and should be different as soon as at least one parameter key or value differs. + * @return Type 3 UUID as a String. + */ + fun uuid(): String { + val parameterString = parameterList + .sortedWith(compareBy { it.first }) + .joinToString { "${it.first}${it.second}" } + val uuid = UUID.nameUUIDFromBytes(parameterString.toByteArray()) + return uuid.toString() + } } diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StatusMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StatusMethods.kt index c74102dd1..d8e5e2ec7 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StatusMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StatusMethods.kt @@ -131,7 +131,8 @@ class StatusMethods(private val client: MastodonClient) { append("sensitive", sensitive) spoilerText?.let { append("spoiler_text", it) } language?.let { append("language", it) } - } + }, + addIdempotencyKey = true ) } @@ -171,7 +172,8 @@ class StatusMethods(private val client: MastodonClient) { append("sensitive", sensitive) spoilerText?.let { append("spoiler_text", it) } language?.let { append("language", it) } - } + }, + addIdempotencyKey = true ) } @@ -212,7 +214,8 @@ class StatusMethods(private val client: MastodonClient) { append("sensitive", sensitive) spoilerText?.let { append("spoiler_text", it) } language?.let { append("language", it) } - } + }, + addIdempotencyKey = true ) } @@ -255,7 +258,8 @@ class StatusMethods(private val client: MastodonClient) { append("sensitive", sensitive) spoilerText?.let { append("spoiler_text", it) } language?.let { append("language", it) } - } + }, + addIdempotencyKey = true ) } diff --git a/bigbone/src/test/kotlin/social/bigbone/ParametersTest.kt b/bigbone/src/test/kotlin/social/bigbone/ParametersTest.kt index fad3388b7..673df4771 100644 --- a/bigbone/src/test/kotlin/social/bigbone/ParametersTest.kt +++ b/bigbone/src/test/kotlin/social/bigbone/ParametersTest.kt @@ -1,6 +1,7 @@ package social.bigbone import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBeEqualTo import org.junit.jupiter.api.Test class ParametersTest { @@ -32,4 +33,41 @@ class ParametersTest { .append("media_ids", listOf(1, 3, 4)) .toQuery() shouldBeEqualTo "media_ids[]=1&media_ids[]=3&media_ids[]=4" } + + @Test + fun sameUuidWithDifferentParameterOrder() { + val params1 = Parameters() + .append("One", "1") + .append("Two", "2") + .append("Three", "3") + + val params2 = Parameters() + .append("Three", "3") + .append("Two", "2") + .append("One", "1") + + params1.uuid() shouldBeEqualTo params2.uuid() + } + + @Test + fun differentUuidWithDifferentKey() { + val params1 = Parameters() + .append("Key", "foo") + + val params2 = Parameters() + .append("OtherKey", "foo") + + params1.uuid() shouldNotBeEqualTo params2.uuid() + } + + @Test + fun differentUuidWithDifferentValue() { + val params1 = Parameters() + .append("Key", "foo") + + val params2 = Parameters() + .append("Key", "bar") + + params1.uuid() shouldNotBeEqualTo params2.uuid() + } } diff --git a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt index 66b2a94ed..dbab350ef 100644 --- a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt +++ b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt @@ -46,6 +46,7 @@ object MockClient { every { clientMock.get(ofType(), any()) } returns response every { clientMock.patch(ofType(), any()) } returns response every { clientMock.post(ofType(), any()) } returns response + every { clientMock.post(ofType(), any(), any()) } returns response every { clientMock.postRequestBody(ofType(), any()) } returns response every { clientMock.put(ofType(), any()) } returns response every { clientMock.getSerializer() } returns Gson() @@ -69,6 +70,7 @@ object MockClient { every { clientMock.get(ofType(), any()) } returns response every { clientMock.patch(ofType(), any()) } returns response every { clientMock.post(ofType(), any()) } returns response + every { clientMock.post(ofType(), any(), any()) } returns response every { clientMock.postRequestBody(ofType(), any()) } returns response every { clientMock.put(ofType(), any()) } returns response every { clientMock.performAction(ofType(), any()) } throws BigBoneRequestException("mock")