Repository: andregasser/bigbone (kotlin)

Issue:
Implement Admin Domain Allows API
Mastodon's Admin Domain Allows API allows an admin to configure which domains are allowed to federate. The goal of this task is to implement the following endpoints of this API in BigBone:

- List all allowed domains
- Get a single allowed domain
- Allow a domain to federate
- Delete an allowed domain

The Admin Domain Allows API as it is documented by Mastodon can be found here:
https://docs.joinmastodon.org/methods/admin/domain_allows/

Tip: A lot of other endpoints similar to this have already been implemented. In order to get an idea how to implement this, I suggest that you study the code upfront. Start with one of the `*Methods.kt` files.

Please consult the following documents to make sure your contribution meets our projects criteria:

- [Code of Conduct](https://github.com/andregasser/bigbone/blob/master/CODE_OF_CONDUCT.md)
- [Code Contribution Guidelines](https://github.com/andregasser/bigbone/blob/master/CONTRIBUTING.md)

Pull request:
# Description

This PR implements the [domain allows API](https://docs.joinmastodon.org/methods/admin/domain_allows/) so that users with role of admin to both see allowed federated domains, remove allowed domain and add new domain to be federated.

This closes https://github.com/andregasser/bigbone/issues/328

# Type of Change

- New feature

# How Has This Been Tested?

Gradle ktlint, gradle check, gradle detect and gradle build

# 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

# Optional checks

I implemented related RX counter part and implemented unit test.
I also updated the API coverage page with corresponding ones developed in this PR.

Patch:
diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminDomainAllowMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminDomainAllowMethods.kt
new file mode 100644
index 000000000..74af79211
--- /dev/null
+++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminDomainAllowMethods.kt
@@ -0,0 +1,57 @@
+package social.bigbone.rx.admin
+
+import io.reactivex.rxjava3.core.Completable
+import io.reactivex.rxjava3.core.Single
+import social.bigbone.MastodonClient
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.admin.AdminDomainAllow
+import social.bigbone.api.method.admin.AdminDomainAllowMethods
+
+/**
+ * Reactive implementation of [AdminDomainAllowMethods].
+ *
+ * Allow certain domains to federate.
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/domain_allows/">Mastodon admin/domain_allows API methods</a>
+ */
+class RxAdminDomainAllowMethods(client: MastodonClient) {
+
+    private val adminAllowMethods = AdminDomainAllowMethods(client)
+
+    /**
+     * Show information about all allowed domains.
+     * @param range optional Range for the pageable return value
+     * @see <a href="https://docs.joinmastodon.org/methods/domain_allows/#get">Mastodon API documentation: methods/domain_allows/#get</a>
+     */
+    @JvmOverloads
+    fun getAllAllowedDomains(range: Range = Range()): Single<Pageable<AdminDomainAllow>> = Single.fromCallable {
+        adminAllowMethods.getAllAllowedDomains(range = range).execute()
+    }
+
+    /**
+     * Show information about a single allowed domain.
+     * @param id The ID of the DomainAllow in the database.
+     * @see <a href="https://docs.joinmastodon.org/methods/domain_allows/#get-one">Mastodon API documentation: methods/domain_allows/#get-one</a>
+     */
+    fun getAllowedDomain(id: String): Single<AdminDomainAllow> = Single.fromCallable {
+        adminAllowMethods.getAllowedDomain(id = id).execute()
+    }
+
+    /**
+     * Add a domain to the list of domains allowed to federate, to be used when the instance is in allow-list federation mode.
+     * @param domain The domain to allow federation with.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/domain_allows/#create">Mastodon API documentation: admin/domain_allows/#create</a>
+     */
+    fun allowDomainToFederate(domain: String): Single<AdminDomainAllow> = Single.fromCallable {
+        adminAllowMethods.allowDomainToFederate(domain = domain).execute()
+    }
+
+    /**
+     * Delete a domain from the allowed domains list.
+     * @param id The ID of the DomainAllow in the database.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/domain_allows/#delete">Mastodon API documentation: admin/domain_allows/#delete</a>
+     */
+    fun removeAllowedDomain(id: String): Completable = Completable.fromAction {
+        adminAllowMethods.removeAllowedDomain(id = id)
+    }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
index 641f141f9..af62f96c1 100644
--- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
@@ -68,6 +68,7 @@ import social.bigbone.api.method.TrendMethods
 import social.bigbone.api.method.admin.AdminAccountMethods
 import social.bigbone.api.method.admin.AdminCanonicalEmailBlockMethods
 import social.bigbone.api.method.admin.AdminDimensionMethods
+import social.bigbone.api.method.admin.AdminDomainAllowMethods
 import social.bigbone.api.method.admin.AdminDomainBlockMethods
 import social.bigbone.api.method.admin.AdminEmailDomainBlockMethods
 import social.bigbone.api.method.admin.AdminIpBlockMethods
@@ -130,6 +131,13 @@ private constructor(
     @get:JvmName("adminDimensions")
     val adminDimensions: AdminDimensionMethods by lazy { AdminDimensionMethods(this) }
 
+    /**
+     * Access API methods under the "admin/domain_allows" endpoint.
+     */
+    @Suppress("unused") // public API
+    @get:JvmName("adminDomainAllow")
+    val adminDomainAllow: AdminDomainAllowMethods by lazy { AdminDomainAllowMethods(this) }
+
     /**
      * Access API methods under the "admin/domain_blocks" endpoint.
      */
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminDomainAllow.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminDomainAllow.kt
new file mode 100644
index 000000000..1dbde083c
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminDomainAllow.kt
@@ -0,0 +1,32 @@
+package social.bigbone.api.entity.admin
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import social.bigbone.DateTimeSerializer
+import social.bigbone.PrecisionDateTime
+
+/**
+ * Represents a domain allowed to federate.
+ * @see <a href="https://docs.joinmastodon.org/entities/Admin_DomainAllow/">Mastodon API Admin::DomainAllow/</a>
+ */
+@Serializable
+data class AdminDomainAllow(
+    /**
+     * The ID of the DomainAllow in the database.
+     */
+    @SerialName("id")
+    val id: String = "0",
+
+    /**
+     * The domain that is allowed to federate.
+     */
+    @SerialName("domain")
+    val domain: String = "",
+
+    /**
+     * When the domain was allowed to federate.
+     */
+    @SerialName("created_at")
+    @Serializable(with = DateTimeSerializer::class)
+    val createdAt: PrecisionDateTime = PrecisionDateTime.InvalidPrecisionDateTime.Unavailable
+)
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminDomainAllowMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminDomainAllowMethods.kt
new file mode 100644
index 000000000..956747241
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminDomainAllowMethods.kt
@@ -0,0 +1,70 @@
+package social.bigbone.api.method.admin
+
+import social.bigbone.MastodonClient
+import social.bigbone.MastodonRequest
+import social.bigbone.Parameters
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.admin.AdminDomainAllow
+
+/**
+ * Allow certain domains to federate.
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/domain_allows/">Mastodon admin/domain_allows API methods</a>
+ */
+class AdminDomainAllowMethods(private val client: MastodonClient) {
+
+    private val adminDomainAllowEndpoint = "api/v1/admin/domain_allows"
+
+    /**
+     * Show information about all allowed domains.
+     * @param range optional Range for the pageable return value
+     * @see <a href="https://docs.joinmastodon.org/methods/domain_allows/#get">Mastodon API documentation: methods/domain_allows/#get</a>
+     */
+    @JvmOverloads
+    fun getAllAllowedDomains(range: Range = Range()): MastodonRequest<Pageable<AdminDomainAllow>> {
+        return client.getPageableMastodonRequest(
+            endpoint = adminDomainAllowEndpoint,
+            method = MastodonClient.Method.GET,
+            parameters = range.toParameters()
+        )
+    }
+
+    /**
+     * Show information about a single allowed domain.
+     * @param id The ID of the DomainAllow in the database.
+     * @see <a href="https://docs.joinmastodon.org/methods/domain_allows/#get-one">Mastodon API documentation: methods/domain_allows/#get-one</a>
+     */
+    fun getAllowedDomain(id: String): MastodonRequest<AdminDomainAllow> {
+        return client.getMastodonRequest(
+            endpoint = "$adminDomainAllowEndpoint/$id",
+            method = MastodonClient.Method.GET
+        )
+    }
+
+    /**
+     * Add a domain to the list of domains allowed to federate, to be used when the instance is in allow-list federation mode.
+     * @param domain The domain to allow federation with.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/domain_allows/#create">Mastodon API documentation: admin/domain_allows/#create</a>
+     */
+    fun allowDomainToFederate(domain: String): MastodonRequest<AdminDomainAllow> {
+        return client.getMastodonRequest(
+            endpoint = adminDomainAllowEndpoint,
+            method = MastodonClient.Method.POST,
+            parameters = Parameters().apply {
+                append("domain", domain)
+            }
+        )
+    }
+
+    /**
+     * Delete a domain from the allowed domains list.
+     * @param id The ID of the DomainAllow in the database.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/domain_allows/#delete">Mastodon API documentation: admin/domain_allows/#delete</a>
+     */
+    fun removeAllowedDomain(id: String) {
+        client.performAction(
+            endpoint = "$adminDomainAllowEndpoint/$id",
+            method = MastodonClient.Method.DELETE
+        )
+    }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminDomainBlockMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminDomainBlockMethods.kt
index 1dc8f10ac..aa554a092 100644
--- a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminDomainBlockMethods.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminDomainBlockMethods.kt
@@ -15,7 +15,7 @@ import social.bigbone.api.entity.admin.AdminDomainBlock.Severity
  */
 class AdminDomainBlockMethods(private val client: MastodonClient) {
 
-    private val adminDomainBlocksEndpoint = "api/v1/admin/domain_blocks"
+    private val endpoint = "api/v1/admin/domain_blocks"
 
     /**
      * Show information about all blocked domains.
@@ -27,7 +27,7 @@ class AdminDomainBlockMethods(private val client: MastodonClient) {
     @JvmOverloads
     fun getAllBlockedDomains(range: Range = Range()): MastodonRequest<Pageable<AdminDomainBlock>> {
         return client.getPageableMastodonRequest(
-            endpoint = adminDomainBlocksEndpoint,
+            endpoint = endpoint,
             method = MastodonClient.Method.GET,
             parameters = range.toParameters()
         )
@@ -42,7 +42,7 @@ class AdminDomainBlockMethods(private val client: MastodonClient) {
      */
     fun getBlockedDomain(id: String): MastodonRequest<AdminDomainBlock> {
         return client.getMastodonRequest(
-            endpoint = "$adminDomainBlocksEndpoint/$id",
+            endpoint = "$endpoint/$id",
             method = MastodonClient.Method.GET
         )
     }
@@ -71,7 +71,7 @@ class AdminDomainBlockMethods(private val client: MastodonClient) {
         publicComment: String? = null
     ): MastodonRequest<AdminDomainBlock> {
         return client.getMastodonRequest(
-            endpoint = adminDomainBlocksEndpoint,
+            endpoint = endpoint,
             method = MastodonClient.Method.POST,
             parameters = Parameters().apply {
                 append("domain", domain)
@@ -113,7 +113,7 @@ class AdminDomainBlockMethods(private val client: MastodonClient) {
         publicComment: String? = null
     ): MastodonRequest<AdminDomainBlock> {
         return client.getMastodonRequest(
-            endpoint = "$adminDomainBlocksEndpoint/$id",
+            endpoint = "$endpoint/$id",
             method = MastodonClient.Method.PUT,
             parameters = Parameters().apply {
                 severity?.let { append("severity", severity.apiName) }
@@ -138,7 +138,7 @@ class AdminDomainBlockMethods(private val client: MastodonClient) {
      */
     fun removeBlockedDomain(id: String) {
         client.performAction(
-            endpoint = "$adminDomainBlocksEndpoint/$id",
+            endpoint = "$endpoint/$id",
             method = MastodonClient.Method.DELETE
         )
     }
diff --git a/docs/api-coverage/admin/domain-allows.md b/docs/api-coverage/admin/domain-allows.md
index 431e4586f..f093c16d7 100644
--- a/docs/api-coverage/admin/domain-allows.md
+++ b/docs/api-coverage/admin/domain-allows.md
@@ -11,8 +11,8 @@ nav_order: 7
 <a href="https://docs.joinmastodon.org/methods/admin/domain_allows/" target="_blank">https://docs.joinmastodon.org/methods/admin/domain_allows/</a>
 
 | Method                                   | Description                 | Status | Comments | 
-|------------------------------------------|-----------------------------|--------|----------|
-| `GET /api/v1/admin/domain_allows`        | List all allowed domains    | 🔴     |          |
-| `GET /api/v1/admin/domain_allows/:id`    | Get a single allowed domain | 🔴     |          |
-| `POST /api/v1/admin/domain_allows`       | Allow a domain to federate  | 🔴     |          |
-| `DELETE /api/v1/admin/domain_allows/:id` | Delete an allowed domain    | 🔴     |          |
+|------------------------------------------|-----------------------------|----|--|
+| `GET /api/v1/admin/domain_allows`        | List all allowed domains    | <img src="/assets/green16.png"> | Fully supported. |
+| `GET /api/v1/admin/domain_allows/:id`    | Get a single allowed domain | <img src="/assets/green16.png"> | Fully supported. |
+| `POST /api/v1/admin/domain_allows`       | Allow a domain to federate  | <img src="/assets/green16.png"> | Fully supported. |
+| `DELETE /api/v1/admin/domain_allows/:id` | Delete an allowed domain    | <img src="/assets/green16.png"> | Fully supported. |


Test patch:
diff --git a/bigbone/src/test/assets/admin_domain_allow.json b/bigbone/src/test/assets/admin_domain_allow.json
new file mode 100644
index 000000000..94fd8ae3a
--- /dev/null
+++ b/bigbone/src/test/assets/admin_domain_allow.json
@@ -0,0 +1,5 @@
+{
+  "id": "1",
+  "domain": "mastodon.social",
+  "created_at": "2022-09-14T21:23:02.755Z"
+}
\ No newline at end of file
diff --git a/bigbone/src/test/assets/admin_domain_allow_list.json b/bigbone/src/test/assets/admin_domain_allow_list.json
new file mode 100644
index 000000000..f70c0faee
--- /dev/null
+++ b/bigbone/src/test/assets/admin_domain_allow_list.json
@@ -0,0 +1,12 @@
+[
+  {
+    "id": "2",
+    "domain": "mastodon",
+    "created_at": "2022-09-14T21:24:15.360Z"
+  },
+  {
+    "id": "1",
+    "domain": "mastodon.social",
+    "created_at": "2022-09-14T21:23:02.755Z"
+  }
+]
\ No newline at end of file
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminDomainAllowMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminDomainAllowMethodsTest.kt
new file mode 100644
index 000000000..6deb1356f
--- /dev/null
+++ b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminDomainAllowMethodsTest.kt
@@ -0,0 +1,116 @@
+package social.bigbone.api.method.admin
+
+import io.mockk.slot
+import io.mockk.verify
+import org.amshove.kluent.AnyException
+import org.amshove.kluent.invoking
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldNotThrow
+import org.amshove.kluent.shouldThrow
+import org.amshove.kluent.withMessage
+import org.junit.jupiter.api.Test
+import social.bigbone.MastodonClient
+import social.bigbone.Parameters
+import social.bigbone.api.Range
+import social.bigbone.api.exception.BigBoneRequestException
+import social.bigbone.testtool.MockClient
+
+class AdminDomainAllowMethodsTest {
+
+    private val adminDomainAllowEndpoint = "api/v1/admin/domain_allows"
+
+    @Test
+    fun `Given client returning success, when getting all allowed domains, then return list of allowed domains`() {
+        val client = MockClient.mock("admin_domain_allow_list.json")
+        val adminDomainAllowMethods = AdminDomainAllowMethods(client)
+        val range = Range("1", "10", "1", 10)
+        val allowedDomains = adminDomainAllowMethods.getAllAllowedDomains(range).execute()
+        allowedDomains.part.size shouldBeEqualTo 2
+        with(allowedDomains.part[1]) {
+            id shouldBeEqualTo "1"
+            domain shouldBeEqualTo "mastodon.social"
+        }
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.get(
+                path = adminDomainAllowEndpoint,
+                query = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "max_id=${range.maxId}&min_id=${range.minId}&since_id=${range.sinceId}&limit=${range.limit}"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when getting specific allowed domain, then return expected data for single domain`() {
+        val client = MockClient.mock("admin_domain_allow.json")
+        val adminDomainAllowMethods = AdminDomainAllowMethods(client)
+        val pathVariable = "1"
+        val adminDomainAllowed = adminDomainAllowMethods.getAllowedDomain(pathVariable).execute()
+        with(adminDomainAllowed) {
+            id shouldBeEqualTo pathVariable
+            domain shouldBeEqualTo "mastodon.social"
+        }
+
+        verify {
+            client.get(
+                path = "$adminDomainAllowEndpoint/$pathVariable"
+            )
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when posting a domain to be allowed to federate, then return expected data about the domain`() {
+        val client = MockClient.mock("admin_domain_allow.json")
+        val adminDomainAllowMethods = AdminDomainAllowMethods(client)
+
+        invoking {
+            adminDomainAllowMethods.allowDomainToFederate(domain = "mastodon.social").execute()
+        } shouldNotThrow AnyException
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.post(
+                path = adminDomainAllowEndpoint,
+                body = capture(parametersCapturingSlot),
+                addIdempotencyKey = false
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "domain=mastodon.social"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when removing specific domain from being allowd, then check data for single ip`() {
+        val client = MockClient.mock("admin_domain_allow.json")
+        val adminDomainAllowMethods = AdminDomainAllowMethods(client)
+        val id = "1"
+
+        invoking {
+            adminDomainAllowMethods.removeAllowedDomain(id = id)
+        } shouldNotThrow AnyException
+
+        verify {
+            client.performAction(
+                endpoint = "$adminDomainAllowEndpoint/$id",
+                method = MastodonClient.Method.DELETE
+            )
+        }
+    }
+
+    @Test
+    fun `Given a client returning forbidden, when getting specific allowed domain, then propagate error`() {
+        val client = MockClient.failWithResponse(
+            responseJsonAssetPath = "error_403_forbidden.json",
+            responseCode = 403,
+            message = "Forbidden"
+        )
+
+        invoking {
+            AdminDomainAllowMethods(client).getAllowedDomain(id = "1").execute()
+        } shouldThrow BigBoneRequestException::class withMessage "Forbidden"
+    }
+}