From fc95f95c650896e60a6e83fc48a68c856231759f Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Mon, 6 Nov 2023 14:03:09 +0100 Subject: [PATCH] Add admin/retention methods (#334) * Allow parsing of time strings with +00:00 as well as Z * Add admin/retention methods --- .../rx/admin/RxAdminRetentionMethods.kt | 40 +++++++ .../kotlin/social/bigbone/JsonSerializer.kt | 8 +- .../kotlin/social/bigbone/MastodonClient.kt | 8 ++ .../bigbone/api/entity/admin/AdminCohort.kt | 81 +++++++++++++ .../api/method/admin/AdminRetentionMethods.kt | 41 +++++++ ...alculate_retention_data_daily_success.json | 71 +++++++++++ ...culate_retention_data_monthly_success.json | 13 +++ .../method/admin/AdminRetentionMethodsTest.kt | 110 ++++++++++++++++++ 8 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminRetentionMethods.kt create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCohort.kt create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminRetentionMethods.kt create mode 100644 bigbone/src/test/assets/admin_retention_calculate_retention_data_daily_success.json create mode 100644 bigbone/src/test/assets/admin_retention_calculate_retention_data_monthly_success.json create mode 100644 bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminRetentionMethodsTest.kt diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminRetentionMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminRetentionMethods.kt new file mode 100644 index 000000000..abbc88dfc --- /dev/null +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminRetentionMethods.kt @@ -0,0 +1,40 @@ +package social.bigbone.rx.admin + +import io.reactivex.rxjava3.core.Single +import social.bigbone.MastodonClient +import social.bigbone.MastodonRequest +import social.bigbone.api.entity.admin.AdminCohort +import social.bigbone.api.entity.admin.AdminCohort.FrequencyOneOf +import social.bigbone.api.method.admin.AdminRetentionMethods +import java.time.Instant + +/** + * Reactive implementation of [AdminRetentionMethods]. + * + * Show retention data over time. + * @see Mastodon admin/retention API methods + */ +class RxAdminRetentionMethods(private val client: MastodonClient) { + + private val adminRetentionMethods = AdminRetentionMethods(client) + + /** + * Generate a retention data report for a given time period and bucket. + * + * @param startAt The start date for the time period. If a time is provided, it will be ignored. + * @param endAt The end date for the time period. If a time is provided, it will be ignored. + * @param frequency Specify whether to use [FrequencyOneOf.DAY] or [FrequencyOneOf.MONTH] buckets. + * @see Mastodon API documentation: admin/retention/#create + */ + fun calculateRetentionData( + startAt: Instant, + endAt: Instant, + frequency: FrequencyOneOf + ): Single>> = Single.fromCallable { + adminRetentionMethods.calculateRetentionData( + startAt = startAt, + endAt = endAt, + frequency = frequency + ) + } +} diff --git a/bigbone/src/main/kotlin/social/bigbone/JsonSerializer.kt b/bigbone/src/main/kotlin/social/bigbone/JsonSerializer.kt index 0dc39a82f..f9d201cce 100644 --- a/bigbone/src/main/kotlin/social/bigbone/JsonSerializer.kt +++ b/bigbone/src/main/kotlin/social/bigbone/JsonSerializer.kt @@ -13,6 +13,7 @@ import social.bigbone.PrecisionDateTime.ValidPrecisionDateTime import java.time.Instant import java.time.LocalDate import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException internal val JSON_SERIALIZER: Json = Json { @@ -62,7 +63,12 @@ object DateTimeSerializer : KSerializer { * @param decodedString ISO 8601 string retrieved from JSON */ private fun parseExactDateTime(decodedString: String): ValidPrecisionDateTime.ExactTime = - ValidPrecisionDateTime.ExactTime(Instant.parse(decodedString)) + ValidPrecisionDateTime.ExactTime( + DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse( + decodedString, + Instant::from + ) + ) /** * Attempts to parse an ISO 8601 string into a [LocalDate] and returning an [Instant] at the start of that day in UTC. diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index e8e17a897..6c25f6c9d 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -43,6 +43,7 @@ import social.bigbone.api.method.StreamingMethods import social.bigbone.api.method.SuggestionMethods import social.bigbone.api.method.TagMethods import social.bigbone.api.method.TimelineMethods +import social.bigbone.api.method.admin.AdminRetentionMethods import social.bigbone.extension.emptyRequestBody import social.bigbone.nodeinfo.NodeInfoClient import java.io.IOException @@ -75,6 +76,13 @@ private constructor( @get:JvmName("accounts") val accounts: AccountMethods by lazy { AccountMethods(this) } + /** + * Access API methods under the "admin/retention" endpoint. + */ + @Suppress("unused") // public API + @get:JvmName("adminRetention") + val adminRetention: AdminRetentionMethods by lazy { AdminRetentionMethods(this) } + /** * Access API methods under the "announcements" endpoint. */ diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCohort.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCohort.kt new file mode 100644 index 000000000..0320bba52 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCohort.kt @@ -0,0 +1,81 @@ +package social.bigbone.api.entity.admin + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import social.bigbone.DateTimeSerializer +import social.bigbone.PrecisionDateTime + +/** + * Represents a retention metric. + * @see Mastodon documentation Admin::Cohort + * + */ +@Serializable +data class AdminCohort( + + /** + * The timestamp for the start of the period, at midnight. + */ + @SerialName("period") + @Serializable(with = DateTimeSerializer::class) + val period: PrecisionDateTime = PrecisionDateTime.InvalidPrecisionDateTime.Unavailable, + + /** + * The size of the bucket for the returned data. + */ + @SerialName("frequency") + val frequency: FrequencyOneOf? = null, + + /** + * Retention data for users who registered during the given period. + */ + @SerialName("data") + val data: List? = null +) { + /** + * The size of the bucket for the returned data. + */ + @Serializable + enum class FrequencyOneOf { + /** + * Daily buckets. + */ + @SerialName("day") + DAY, + + /** + * Monthly buckets. + */ + @SerialName("month") + MONTH; + + @OptIn(ExperimentalSerializationApi::class) + val apiName: String get() = serializer().descriptor.getElementName(ordinal) + } + + /** + * Retention data for users who registered during the given period. + */ + @Serializable + data class CohortData( + /** + * The timestamp for the start of the bucket, at midnight. + */ + @SerialName("date") + @Serializable(with = DateTimeSerializer::class) + val date: PrecisionDateTime = PrecisionDateTime.InvalidPrecisionDateTime.Unavailable, + + /** + * The percentage rate of users who registered in the specified period and were active for the given date bucket. + */ + @SerialName("rate") + val rate: Float? = null, + + /** + * How many users registered in the specified period and were active for the given date bucket. + */ + @SerialName("value") + val value: String? = null + ) +} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminRetentionMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminRetentionMethods.kt new file mode 100644 index 000000000..b2d57de1d --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminRetentionMethods.kt @@ -0,0 +1,41 @@ +package social.bigbone.api.method.admin + +import social.bigbone.MastodonClient +import social.bigbone.MastodonRequest +import social.bigbone.Parameters +import social.bigbone.api.entity.admin.AdminCohort +import social.bigbone.api.entity.admin.AdminCohort.FrequencyOneOf +import java.time.Instant + +/** + * Show retention data over time. + * @see Mastodon admin/retention API methods + */ +class AdminRetentionMethods(private val client: MastodonClient) { + + private val adminRetentionEndpoint = "api/v1/admin/retention" + + /** + * Generate a retention data report for a given time period and bucket. + * + * @param startAt The start date for the time period. If a time is provided, it will be ignored. + * @param endAt The end date for the time period. If a time is provided, it will be ignored. + * @param frequency Specify whether to use [FrequencyOneOf.DAY] or [FrequencyOneOf.MONTH] buckets. + * @see Mastodon API documentation: admin/retention/#create + */ + fun calculateRetentionData( + startAt: Instant, + endAt: Instant, + frequency: FrequencyOneOf + ): MastodonRequest> { + return client.getMastodonRequestForList( + endpoint = adminRetentionEndpoint, + method = MastodonClient.Method.POST, + parameters = Parameters().apply { + append("start_at", startAt.toString()) + append("end_at", endAt.toString()) + append("frequency", frequency.apiName) + } + ) + } +} diff --git a/bigbone/src/test/assets/admin_retention_calculate_retention_data_daily_success.json b/bigbone/src/test/assets/admin_retention_calculate_retention_data_daily_success.json new file mode 100644 index 000000000..5706a0e2d --- /dev/null +++ b/bigbone/src/test/assets/admin_retention_calculate_retention_data_daily_success.json @@ -0,0 +1,71 @@ +[ + { + "period": "2022-09-08T00:00:00+00:00", + "frequency": "day", + "data": [ + { + "date": "2022-09-08T00:00:00+00:00", + "rate": 1, + "value": "2" + }, + { + "date": "2022-09-09T00:00:00+00:00", + "rate": 1, + "value": "2" + }, + { + "date": "2022-09-10T00:00:00+00:00", + "rate": 0.5, + "value": "1" + }, + { + "date": "2022-09-14T00:00:00+00:00", + "rate": 0.5, + "value": "1" + } + ] + }, + { + "period": "2022-09-09T00:00:00+00:00", + "frequency": "day", + "data": [ + { + "date": "2022-09-09T00:00:00+00:00", + "rate": 0, + "value": "0" + }, + { + "date": "2022-09-14T00:00:00+00:00", + "rate": 0, + "value": "0" + } + ] + }, + { + "period": "2022-09-10T00:00:00+00:00", + "frequency": "day", + "data": [ + { + "date": "2022-09-10T00:00:00+00:00", + "rate": 0, + "value": "0" + }, + { + "date": "2022-09-14T00:00:00+00:00", + "rate": 0, + "value": "0" + } + ] + }, + { + "period": "2022-09-14T00:00:00+00:00", + "frequency": "day", + "data": [ + { + "date": "2022-09-14T00:00:00+00:00", + "rate": 0, + "value": "0" + } + ] + } +] diff --git a/bigbone/src/test/assets/admin_retention_calculate_retention_data_monthly_success.json b/bigbone/src/test/assets/admin_retention_calculate_retention_data_monthly_success.json new file mode 100644 index 000000000..2d5d26eed --- /dev/null +++ b/bigbone/src/test/assets/admin_retention_calculate_retention_data_monthly_success.json @@ -0,0 +1,13 @@ +[ + { + "period": "2022-09-01T00:00:00+00:00", + "frequency": "month", + "data": [ + { + "date": "2022-09-01T00:00:00+00:00", + "rate": 1.0, + "value": "2" + } + ] + } +] diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminRetentionMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminRetentionMethodsTest.kt new file mode 100644 index 000000000..c0be3cd6c --- /dev/null +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminRetentionMethodsTest.kt @@ -0,0 +1,110 @@ +package social.bigbone.api.method.admin + +import io.mockk.slot +import io.mockk.verify +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldHaveSize +import org.amshove.kluent.shouldNotBeNull +import org.junit.jupiter.api.Test +import social.bigbone.Parameters +import social.bigbone.PrecisionDateTime.ValidPrecisionDateTime.ExactTime +import social.bigbone.api.entity.admin.AdminCohort +import social.bigbone.testtool.MockClient +import java.net.URLEncoder +import java.time.LocalDate +import java.time.ZoneOffset + +class AdminRetentionMethodsTest { + + @Test + fun `Given client returning success, when getting calculated retention data with daily frequency, then call expected endpoint and return expected data`() { + val client = MockClient.mock("admin_retention_calculate_retention_data_daily_success.json") + val adminRetentionMethods = AdminRetentionMethods(client) + val startAt = LocalDate.of(2023, 7, 2).atStartOfDay(ZoneOffset.UTC).toInstant() + val endAt = LocalDate.of(2023, 7, 19).atStartOfDay(ZoneOffset.UTC).toInstant() + + val calculatedRetentionData = adminRetentionMethods.calculateRetentionData( + startAt = startAt, + endAt = endAt, + frequency = AdminCohort.FrequencyOneOf.DAY + ).execute() + with(calculatedRetentionData) { + shouldHaveSize(4) + + with(get(0)) { + period shouldBeEqualTo ExactTime( + LocalDate.of(2022, 9, 8).atStartOfDay(ZoneOffset.UTC).toInstant() + ) + frequency shouldBeEqualTo AdminCohort.FrequencyOneOf.DAY + + with(data) { + shouldNotBeNull() + shouldHaveSize(4) + + get(0).rate shouldBeEqualTo 1.0f + get(0).value shouldBeEqualTo "2" + } + } + } + + val parametersCapturingSlot = slot() + verify { + client.post( + path = "api/v1/admin/retention", + body = capture(parametersCapturingSlot), + addIdempotencyKey = false + ) + } + with(parametersCapturingSlot.captured) { + val startString = URLEncoder.encode(startAt.toString(), "utf-8") + val endString = URLEncoder.encode(endAt.toString(), "utf-8") + toQuery() shouldBeEqualTo "start_at=$startString&end_at=$endString&frequency=day" + } + } + + @Test + fun `Given client returning success, when getting retention data with monthly frequency, then call expected endpoint and return expected data`() { + val client = MockClient.mock("admin_retention_calculate_retention_data_monthly_success.json") + val adminRetentionMethods = AdminRetentionMethods(client) + val startAt = LocalDate.of(2022, 8, 1).atStartOfDay(ZoneOffset.UTC).toInstant() + val endAt = LocalDate.of(2023, 10, 1).atStartOfDay(ZoneOffset.UTC).toInstant() + + val calculatedRetentionData = adminRetentionMethods.calculateRetentionData( + startAt = startAt, + endAt = endAt, + frequency = AdminCohort.FrequencyOneOf.MONTH + ).execute() + with(calculatedRetentionData) { + shouldHaveSize(1) + + with(get(0)) { + period shouldBeEqualTo ExactTime( + LocalDate.of(2022, 9, 1).atStartOfDay(ZoneOffset.UTC).toInstant() + ) + frequency shouldBeEqualTo AdminCohort.FrequencyOneOf.MONTH + + with(data) { + shouldNotBeNull() + shouldHaveSize(1) + + get(0).rate shouldBeEqualTo 1.0f + get(0).value shouldBeEqualTo "2" + } + } + } + + val parametersCapturingSlot = slot() + verify { + client.post( + path = "api/v1/admin/retention", + body = capture(parametersCapturingSlot), + addIdempotencyKey = false + ) + } + with(parametersCapturingSlot.captured) { + val startString = URLEncoder.encode(startAt.toString(), "utf-8") + val endString = URLEncoder.encode(endAt.toString(), "utf-8") + toQuery() shouldBeEqualTo "start_at=$startString&end_at=$endString&frequency=month" + } + } +}