diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxNotificationMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxNotificationMethods.kt index e7531e5d3..4bf073100 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxNotificationMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxNotificationMethods.kt @@ -17,12 +17,22 @@ class RxNotificationMethods(client: MastodonClient) { private val notificationMethods = NotificationMethods(client) + /** + * Notifications concerning the user. + * @param includeTypes Types to include in the results. + * @param excludeTypes Types to exclude from the results. + * @param accountId Return only notifications received from the specified account. + * @param range optional Range for the pageable return value. + * @see Mastodon API documentation: methods/notifications/#get + */ @JvmOverloads fun getAllNotifications( + includeTypes: List? = null, excludeTypes: List? = null, + accountId: String? = null, range: Range = Range() ): Single> = Single.fromCallable { - notificationMethods.getAllNotifications(excludeTypes, range).execute() + notificationMethods.getAllNotifications(includeTypes, excludeTypes, accountId, range).execute() } fun getNotification(id: String): Single = Single.fromCallable { diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminMeasuresMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminMeasuresMethods.kt new file mode 100644 index 000000000..d9b815aab --- /dev/null +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminMeasuresMethods.kt @@ -0,0 +1,41 @@ +package social.bigbone.rx.admin + +import io.reactivex.rxjava3.core.Single +import social.bigbone.MastodonClient +import social.bigbone.api.entity.admin.AdminMeasure +import social.bigbone.api.entity.admin.AdminMeasure.Key +import social.bigbone.api.method.admin.AdminMeasureMethods +import social.bigbone.api.method.admin.RequestMeasure +import java.time.Instant + +/** + * Reactive implementation of [AdminMeasureMethods]. + * + * Obtain quantitative metrics about the server. + * @see Mastodon admin/measures API methods + */ +class RxAdminMeasuresMethods(client: MastodonClient) { + + private val adminMeasureMethods = AdminMeasureMethods(client) + + /** + * Obtain statistical measures for your server. + * + * @param measures Request specific measures. Uses helper wrapper [RequestMeasure] to ensure that required fields are set for any given [Key]. + * @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. + * + * @see Mastodon API documentation: admin/measures/#get + */ + fun getMeasurableDate( + measures: List, + startAt: Instant, + endAt: Instant + ): Single> = Single.fromCallable { + adminMeasureMethods.getMeasurableData( + measures = measures, + startAt = startAt, + endAt = endAt + ).execute() + } +} diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 6c25f6c9d..c537d1278 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.AdminMeasureMethods import social.bigbone.api.method.admin.AdminRetentionMethods import social.bigbone.extension.emptyRequestBody import social.bigbone.nodeinfo.NodeInfoClient @@ -76,6 +77,13 @@ private constructor( @get:JvmName("accounts") val accounts: AccountMethods by lazy { AccountMethods(this) } + /** + * Access API methods under the "admin/measures" endpoint. + */ + @Suppress("unused") // public API + @get:JvmName("adminMeasures") + val adminMeasures: AdminMeasureMethods by lazy { AdminMeasureMethods(this) } + /** * Access API methods under the "admin/retention" endpoint. */ @@ -244,6 +252,13 @@ private constructor( @get:JvmName("preferences") val preferences: PreferenceMethods by lazy { PreferenceMethods(this) } + /** + * Access API methods under "push" endpoint. + */ + @Suppress("unused") // public API + @get:JvmName("pushNotifications") + val pushNotifications: PushNotificationMethods by lazy { PushNotificationMethods(this) } + /** * Access API methods under the "reports" endpoint. */ @@ -293,13 +308,6 @@ private constructor( @get:JvmName("timelines") val timelines: TimelineMethods by lazy { TimelineMethods(this) } - /** - * Access API methods under "push" endpoint. - */ - @Suppress("unused") // public API - @get:JvmName("pushNotifications") - val pushNotifications: PushNotificationMethods by lazy { PushNotificationMethods(this) } - /** * Specifies the HTTP methods / HTTP verb that can be used by this class. */ diff --git a/bigbone/src/main/kotlin/social/bigbone/Parameters.kt b/bigbone/src/main/kotlin/social/bigbone/Parameters.kt index 3bb5a05ff..5e93391cd 100644 --- a/bigbone/src/main/kotlin/social/bigbone/Parameters.kt +++ b/bigbone/src/main/kotlin/social/bigbone/Parameters.kt @@ -1,15 +1,18 @@ package social.bigbone import java.net.URLEncoder -import java.util.ArrayList import java.util.UUID +typealias Key = String +typealias Value = String + /** - * 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. + * Parameters holds a mapping of [Key] to [Value]s that can be used as query part of a URL, or in the body of a request. * Add new pairs using one of the available append() functions. */ class Parameters { - private val parameterList = ArrayList>() + + internal val parameters: MutableMap> = mutableMapOf() /** * Appends a new key/value pair with a String value. @@ -18,7 +21,7 @@ class Parameters { * @return this Parameters instance */ fun append(key: String, value: String): Parameters { - parameterList.add(Pair(key, value)) + parameters.getOrPut(key, ::mutableListOf).add(value) return this } @@ -77,10 +80,15 @@ class Parameters { * Converts this Parameters instance into a query string. * @return String, formatted like: "key1=value1&key2=value2&..." */ - fun toQuery(): String = - parameterList.joinToString(separator = "&") { - "${it.first}=${URLEncoder.encode(it.second, "utf-8")}" - } + fun toQuery(): String { + return parameters + .map { (key, values) -> + values.joinToString(separator = "&") { value -> + "$key=${URLEncoder.encode(value, "utf-8")}" + } + } + .joinToString(separator = "&") + } /** * Generates a UUID string for this parameter list. UUIDs returned for different Parameters instances should be @@ -89,10 +97,14 @@ class Parameters { * @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() + return UUID + .nameUUIDFromBytes( + parameters + .entries + .sortedWith(compareBy { (key, _) -> key }) + .joinToString { (key, value) -> "$key$value" } + .toByteArray() + ) + .toString() } } diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/Notification.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/Notification.kt index 855afcc64..317a95abc 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/Notification.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/Notification.kt @@ -1,5 +1,6 @@ package social.bigbone.api.entity +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import social.bigbone.DateTimeSerializer @@ -22,7 +23,7 @@ data class Notification( * The type of event that resulted in the notification. */ @SerialName("type") - val type: NotificationType = NotificationType.MENTION, + val type: NotificationType? = null, /** * The timestamp of the notification. @@ -54,16 +55,38 @@ data class Notification( */ @Serializable enum class NotificationType { + + @SerialName("admin.report") + ADMIN_REPORT, + + @SerialName("admin.sign_up") + ADMIN_SIGN_UP, + + @SerialName("favourite") + FAVOURITE, + + @SerialName("follow") + FOLLOW, + + @SerialName("follow_request") + FOLLOW_REQUEST, + @SerialName("mention") MENTION, + @SerialName("poll") + POLL, + @SerialName("reblog") REBLOG, - @SerialName("favourite") - FAVOURITE, + @SerialName("status") + STATUS, - @SerialName("follow") - FOLLOW + @SerialName("update") + UPDATE; + + @OptIn(ExperimentalSerializationApi::class) + val apiName: String get() = serializer().descriptor.getElementName(ordinal) } } diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminMeasure.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminMeasure.kt new file mode 100644 index 000000000..83783c372 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminMeasure.kt @@ -0,0 +1,167 @@ +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 +import social.bigbone.PrecisionDateTime.InvalidPrecisionDateTime + +/** + * Represents quantitative data about the server. + * @see ? = null +) { + + /** + * The unique keystring for the requested measure. + */ + @Serializable + enum class Key { + /** + * Total active users on your instance within the time period. + */ + @SerialName("active_users") + ACTIVE_USERS, + + /** + * Users who joined your instance within the time period. + */ + @SerialName("new_users") + NEW_USERS, + + /** + * Total interactions (favourites, boosts, replies) on local statuses within the time period. + */ + @SerialName("interactions") + INTERACTIONS, + + /** + * Total reports filed within the time period. + */ + @SerialName("opened_reports") + OPENED_REPORTS, + + /** + * Total reports resolved within the time period. + */ + @SerialName("resolved_reports") + RESOLVED_REPORTS, + + /** + * Total accounts who used a tag in at least one status within the time period. + */ + @SerialName("tag_accounts") + TAG_ACCOUNTS, + + /** + * Total statuses which used a tag within the time period. + */ + @SerialName("tag_uses") + TAG_USES, + + /** + * Total remote origin servers for statuses which used a tag within the time period. + */ + @SerialName("tag_servers") + TAG_SERVERS, + + /** + * Total accounts originating from a remote domain within the time period. + */ + @SerialName("instance_accounts") + INSTANCE_ACCOUNTS, + + /** + * Total space used by media attachments from a remote domain within the time period. + */ + @SerialName("instance_media_attachments") + INSTANCE_MEDIA_ATTACHMENTS, + + /** + * Total reports filed against accounts from a remote domain within the time period. + */ + @SerialName("instance_reports") + INSTANCE_REPORTS, + + /** + * Total statuses originating from a remote domain within the time period. + */ + @SerialName("instance_statuses") + INSTANCE_STATUSES, + + /** + * Total accounts from a remote domain followed by a local user within the time period. + */ + @SerialName("instance_follows") + INSTANCE_FOLLOWS, + + /** + * Total local accounts followed by accounts from a remote domain within the time period. + */ + @SerialName("instance_followers") + INSTANCE_FOLLOWERS; + + @OptIn(ExperimentalSerializationApi::class) + val apiName: String get() = serializer().descriptor.getElementName(ordinal) + } + + /** + * The data available for the requested measure, split into daily buckets. + */ + @Serializable + data class Data( + /** + * Midnight on the requested day in the time period. + */ + @SerialName("date") + @Serializable(with = DateTimeSerializer::class) + val date: PrecisionDateTime = InvalidPrecisionDateTime.Unavailable, + + /** + * The numeric value for the requested measure. + */ + @SerialName("value") + val value: String? = null + ) +} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/NotificationMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/NotificationMethods.kt index 14fdc607f..04cea3dcb 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/NotificationMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/NotificationMethods.kt @@ -13,26 +13,34 @@ import social.bigbone.api.exception.BigBoneRequestException */ class NotificationMethods(private val client: MastodonClient) { - private val notificationsEndpoint = "/api/v1/notifications" + private val notificationsEndpoint = "api/v1/notifications" /** * Notifications concerning the user. - * @param excludeTypes Types to exclude from the results. See Mastodon API documentation for details. - * @param range optional Range for the pageable return value + * @param includeTypes Types to include in the results. + * @param excludeTypes Types to exclude from the results. + * @param accountId Return only notifications received from the specified account. + * @param range optional Range for the pageable return value. * @see Mastodon API documentation: methods/notifications/#get */ @JvmOverloads fun getAllNotifications( + includeTypes: List? = null, excludeTypes: List? = null, + accountId: String? = null, range: Range = Range() ): MastodonRequest> { return client.getPageableMastodonRequest( endpoint = notificationsEndpoint, method = MastodonClient.Method.GET, parameters = range.toParameters().apply { + includeTypes?.let { + append("types", includeTypes.map(Notification.NotificationType::apiName)) + } excludeTypes?.let { - append("exclude_types", excludeTypes.map { it.name.lowercase() }) + append("exclude_types", excludeTypes.map(Notification.NotificationType::apiName)) } + accountId?.let { append("account_id", accountId) } } ) } diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/SearchMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/SearchMethods.kt index 4feee9beb..7ef88de07 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/SearchMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/SearchMethods.kt @@ -1,5 +1,6 @@ package social.bigbone.api.method +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import social.bigbone.MastodonClient @@ -25,7 +26,10 @@ class SearchMethods(private val client: MastodonClient) { HASHTAGS, @SerialName("statuses") - STATUSES + STATUSES; + + @OptIn(ExperimentalSerializationApi::class) + val apiName: String get() = serializer().descriptor.getElementName(ordinal) } /** @@ -87,7 +91,7 @@ class SearchMethods(private val client: MastodonClient) { append("exclude_unreviewed", true) } if (type != null) { - append("type", type.name) + append("type", type.apiName) } if (!accountId.isNullOrEmpty() && accountId.isNotBlank()) { append("account_id", accountId) diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminMeasureMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminMeasureMethods.kt new file mode 100644 index 000000000..d56d99aef --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminMeasureMethods.kt @@ -0,0 +1,226 @@ +package social.bigbone.api.method.admin + +import social.bigbone.MastodonClient +import social.bigbone.MastodonRequest +import social.bigbone.Parameters +import social.bigbone.api.entity.admin.AdminMeasure +import social.bigbone.api.entity.admin.AdminMeasure.Key +import java.time.Instant + +/** + * Obtain quantitative metrics about the server. + * @see Mastodon admin/measures API methods + */ +class AdminMeasureMethods(private val client: MastodonClient) { + + private val adminMeasuresEndpoint = "api/v1/admin/measures" + + /** + * Obtain statistical measures for your server. + * + * @param measures Request specific measures. Uses helper wrapper [RequestMeasure] to ensure that required fields are set for any given [Key]. + * @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. + * + * @see Mastodon API documentation: admin/measures/#get + */ + fun getMeasurableData( + measures: List, + startAt: Instant, + endAt: Instant + ): MastodonRequest> { + return client.getMastodonRequestForList( + endpoint = adminMeasuresEndpoint, + method = MastodonClient.Method.POST, + parameters = Parameters().apply { + measures + .flatMap(RequestMeasure::getRequestKeyValues) + .forEach { (key, value) -> append(key, value) } + + append("start_at", startAt.toString()) + append("end_at", endAt.toString()) + } + ) + } +} + +/** + * Wrapper class to ensure that required parameters are added when sending a specific [Key] for which to get measures. + */ +sealed class RequestMeasure(val key: Key, val apiName: String = key.apiName) { + + /** + * Get the key=value pairs to request this measure. + * Can be used to craft a [Parameters] entry from. + * Defaults to [KEYS_NAME] = [apiName] for implementations of [RequestMeasure] that do not have additional properties. + */ + open fun getRequestKeyValues(): List> = listOf( + KEYS_NAME to apiName + ) + + companion object { + private const val KEYS_NAME = "keys[]" + private const val TAG_ID_NAME = "id" + private const val REMOTE_DOMAIN_NAME = "domain" + } + + /** + * Requests [Key.ACTIVE_USERS], i.e. Total active users on your instance within the time period. + */ + data object ActiveUsers : RequestMeasure(key = Key.ACTIVE_USERS) + + /** + * Requests [Key.NEW_USERS], i.e. Users who joined your instance within the time period. + */ + data object NewUsers : RequestMeasure(key = Key.NEW_USERS) + + /** + * Requests [Key.INTERACTIONS], i.e., Total interactions (favourites, boosts, replies) on local statuses within the time period. + */ + data object Interactions : RequestMeasure(key = Key.INTERACTIONS) + + /** + * Requests [Key.OPENED_REPORTS], i.e., Total reports filed within the time period. + */ + data object OpenedReports : RequestMeasure(key = Key.OPENED_REPORTS) + + /** + * Requests [Key.RESOLVED_REPORTS], i.e., Total reports resolved within the time period. + */ + data object ResolvedReports : RequestMeasure(key = Key.RESOLVED_REPORTS) + + /** + * Requests [Key.TAG_ACCOUNTS], i.e., Total accounts who used a tag in at least one status within the time period. + */ + data class TagAccounts( + /** + * When [Key.TAG_ACCOUNTS] is one of the requested keys, you must provide a tag ID + * to obtain the measure of how many accounts used that hashtag in at least one status + * within the given time period. + */ + val tagId: String + ) : RequestMeasure(key = Key.TAG_ACCOUNTS) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$TAG_ID_NAME]" to tagId) + } + + /** + * Requests [Key.TAG_USES], i.e., Total statuses which used a tag within the time period. + */ + data class TagUses( + /** + * When [Key.TAG_USES] is one of the requested keys, you must provide a tag ID + * to obtain the measure of how many statuses used that hashtag + * within the given time period. + */ + val tagId: String + ) : RequestMeasure(Key.TAG_USES) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$TAG_ID_NAME]" to tagId) + } + + /** + * Requests [Key.TAG_SERVERS], i.e., Total remote origin servers for statuses which used a tag within the time period. + */ + data class TagServers( + /** + * When [Key.TAG_SERVERS] is one of the requested keys, you must provide a tag ID + * to obtain the measure of how many servers used that hashtag in at least one status + * within the given time period. + */ + val tagId: String + ) : RequestMeasure(Key.TAG_SERVERS) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$TAG_ID_NAME]" to tagId) + } + + /** + * Requests [Key.INSTANCE_ACCOUNTS], i.e., Total accounts originating from a remote domain within the time period. + */ + data class InstanceAccounts( + /** + * When [Key.INSTANCE_ACCOUNTS] is one of the requested keys, you must provide a remote domain + * to obtain the measure of how many accounts have been discovered from that server + * within the given time period. + */ + val remoteDomain: String + ) : RequestMeasure(Key.INSTANCE_ACCOUNTS) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$REMOTE_DOMAIN_NAME]" to remoteDomain) + } + + /** + * Requests [Key.INSTANCE_MEDIA_ATTACHMENTS], i.e., Total space used by media attachments from a remote domain within the time period. + */ + data class InstanceMediaAttachments( + /** + * When [Key.INSTANCE_MEDIA_ATTACHMENTS] is one of the requested keys, you must provide a remote domain + * to obtain the measure of how much space is used by media attachments from that server + * within the given time period. + */ + val remoteDomain: String + ) : RequestMeasure(Key.INSTANCE_MEDIA_ATTACHMENTS) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$REMOTE_DOMAIN_NAME]" to remoteDomain) + } + + /** + * Requests [Key.INSTANCE_REPORTS], i.e., Total reports filed against accounts from a remote domain within the time period. + */ + data class InstanceReports( + /** + * When [Key.INSTANCE_REPORTS] is one of the requested keys, you must provide a remote domain + * to obtain the measure of how many reports have been filed against accounts from that server + * within the given time period. + */ + val remoteDomain: String + ) : RequestMeasure(Key.INSTANCE_REPORTS) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$REMOTE_DOMAIN_NAME]" to remoteDomain) + } + + /** + * Requests [Key.INSTANCE_STATUSES], i.e., Total statuses originating from a remote domain within the time period. + */ + data class InstanceStatuses( + /** + * When [Key.INSTANCE_STATUSES] is one of the requested keys, you must provide a remote domain + * to obtain the measure of how many statuses originate from that server + * within the given time period. + */ + val remoteDomain: String + ) : RequestMeasure(Key.INSTANCE_STATUSES) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$REMOTE_DOMAIN_NAME]" to remoteDomain) + } + + /** + * Requests [Key.INSTANCE_FOLLOWS], i.e., Total accounts from a remote domain followed by a local user within the time period. + */ + data class InstanceFollows( + /** + * When [Key.INSTANCE_FOLLOWS] is one of the requested keys, you must provide a remote domain + * to obtain the measure of how many follows were performed on accounts from that server by local accounts + * within the given time period. + */ + val remoteDomain: String + ) : RequestMeasure(Key.INSTANCE_FOLLOWS) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$REMOTE_DOMAIN_NAME]" to remoteDomain) + } + + /** + * Requests [Key.INSTANCE_FOLLOWERS], i.e., Total local accounts followed by accounts from a remote domain within the time period. + */ + data class InstanceFollowers( + /** + * When [Key.INSTANCE_STATUSES] is one of the requested keys, you must provide a remote domain + * to obtain the measure of how many follows were performed by accounts from that server on local accounts + * within the given time period. + */ + val remoteDomain: String + ) : RequestMeasure(Key.INSTANCE_FOLLOWERS) { + override fun getRequestKeyValues(): List> = + super.getRequestKeyValues() + listOf("$apiName[$REMOTE_DOMAIN_NAME]" to remoteDomain) + } +} diff --git a/bigbone/src/test/assets/admin_measures_get_measurable_data_success.json b/bigbone/src/test/assets/admin_measures_get_measurable_data_success.json new file mode 100644 index 000000000..fb7b507c4 --- /dev/null +++ b/bigbone/src/test/assets/admin_measures_get_measurable_data_success.json @@ -0,0 +1,165 @@ +[ + { + "key": "active_users", + "unit": null, + "total": "2", + "previous_total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00Z", + "value": "0" + } + ] + }, + { + "key": "new_users", + "unit": null, + "total": "2", + "previous_total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + }, + { + "key": "interactions", + "unit": null, + "total": "0", + "previous_total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00Z", + "value": "0" + } + ] + }, + { + "key": "opened_reports", + "unit": null, + "total": "0", + "previous_total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + }, + { + "key": "resolved_reports", + "unit": null, + "total": "0", + "previous_total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + }, + { + "key": "tag_accounts", + "unit": null, + "total": "1", + "previous_total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00Z", + "value": "0" + } + ] + }, + { + "key": "tag_uses", + "unit": null, + "total": "2", + "previous_total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00Z", + "value": "0" + } + ] + }, + { + "key": "tag_servers", + "unit": null, + "total": "0", + "previous_total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + }, + { + "key": "instance_accounts", + "unit": null, + "total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + }, + { + "key": "instance_media_attachments", + "unit": "bytes", + "total": "0", + "human_value": "0 Bytes", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "" + } + ] + }, + { + "key": "instance_reports", + "unit": null, + "total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + }, + { + "key": "instance_statuses", + "unit": null, + "total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + }, + { + "key": "instance_follows", + "unit": null, + "total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + }, + { + "key": "instance_followers", + "unit": null, + "total": "0", + "data": [ + { + "date": "2022-09-14T00:00:00.000+00:00", + "value": "0" + } + ] + } +] diff --git a/bigbone/src/test/assets/error_403_forbidden.json b/bigbone/src/test/assets/error_403_forbidden.json new file mode 100644 index 000000000..a9264822d --- /dev/null +++ b/bigbone/src/test/assets/error_403_forbidden.json @@ -0,0 +1,3 @@ +{ + "error": "This action is not allowed" +} diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/NotificationMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/NotificationMethodsTest.kt index 488a44b94..24d9f842f 100644 --- a/bigbone/src/test/kotlin/social/bigbone/api/method/NotificationMethodsTest.kt +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/NotificationMethodsTest.kt @@ -1,21 +1,40 @@ package social.bigbone.api.method +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.shouldBeNull +import org.amshove.kluent.shouldContainAll import org.amshove.kluent.shouldNotBeNull import org.amshove.kluent.shouldNotThrow import org.amshove.kluent.shouldThrow import org.junit.jupiter.api.Test +import social.bigbone.JSON_SERIALIZER import social.bigbone.MastodonClient import social.bigbone.Parameters import social.bigbone.PrecisionDateTime.ValidPrecisionDateTime.ExactTime +import social.bigbone.api.entity.Notification import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.testtool.MockClient import java.time.Instant class NotificationMethodsTest { + + @Test + fun `Given a JSON response with invalid status, when deserialising, then default to null`() { + val json = """ + { + "type": "new_unknown_type" + } + """.trimIndent() + + val notification: Notification = JSON_SERIALIZER.decodeFromString(json) + + notification.type.shouldBeNull() + } + @Test fun getMentionNotification() { val client = MockClient.mock("notifications.json") @@ -24,7 +43,8 @@ class NotificationMethodsTest { val pageable = notificationMethods.getAllNotifications().execute() with(pageable.part.first()) { - type.name.lowercase() shouldBeEqualTo "mention" + type.shouldNotBeNull() + type!!.name.lowercase() shouldBeEqualTo "mention" createdAt shouldBeEqualTo ExactTime(Instant.parse("2019-11-23T07:49:02.064Z")) account.shouldNotBeNull() } @@ -35,12 +55,72 @@ class NotificationMethodsTest { verify { client.get( - path = "/api/v1/notifications", + path = "api/v1/notifications", query = any() ) } } + @Test + fun `When getting all notifications with valid includeTypes, excludeTypes, and accountId, then call endpoint with correct parameters`() { + val client = MockClient.mock("notifications.json") + val notificationMethods = NotificationMethods(client) + + val includeTypes = listOf( + Notification.NotificationType.FOLLOW, + Notification.NotificationType.MENTION + ) + val excludeTypes = listOf( + Notification.NotificationType.FAVOURITE + ) + notificationMethods.getAllNotifications( + includeTypes = includeTypes, + excludeTypes = excludeTypes, + accountId = "1234567" + ).execute() + + val parametersCapturingSlot = slot() + verify { + client.get( + path = "api/v1/notifications", + query = capture(parametersCapturingSlot) + ) + } + with(parametersCapturingSlot.captured) { + parameters["types[]"]?.shouldContainAll(includeTypes.map(Notification.NotificationType::apiName)) + parameters["exclude_types[]"]?.shouldContainAll(excludeTypes.map(Notification.NotificationType::apiName)) + parameters["account_id"]?.shouldContainAll(listOf("1234567")) + + toQuery() shouldBeEqualTo "types[]=follow&types[]=mention&exclude_types[]=favourite&account_id=1234567" + } + } + + @Test + fun `When getting all notifications with empty includeTypes and includeTypes, then call endpoint without types`() { + val client = MockClient.mock("notifications.json") + val notificationMethods = NotificationMethods(client) + + notificationMethods.getAllNotifications( + includeTypes = emptyList(), + excludeTypes = emptyList() + ).execute() + + val parametersCapturingSlot = slot() + verify { + client.get( + path = "api/v1/notifications", + query = capture(parametersCapturingSlot) + ) + } + with(parametersCapturingSlot.captured) { + parameters["types[]"].shouldBeNull() + parameters["exclude_types[]"].shouldBeNull() + parameters["account_id"].shouldBeNull() + + toQuery() shouldBeEqualTo "" + } + } + @Test fun getFavouriteNotification() { val client = MockClient.mock("notifications.json") @@ -48,7 +128,8 @@ class NotificationMethodsTest { val pageable = notificationMethods.getAllNotifications().execute() with(pageable.part[1]) { - type.name.lowercase() shouldBeEqualTo "favourite" + type.shouldNotBeNull() + type!!.name.lowercase() shouldBeEqualTo "favourite" createdAt shouldBeEqualTo ExactTime(Instant.parse("2019-11-23T07:29:18.903Z")) } with(pageable.part[1].status) { @@ -58,7 +139,7 @@ class NotificationMethodsTest { verify { client.get( - path = "/api/v1/notifications", + path = "api/v1/notifications", query = any() ) } @@ -85,7 +166,7 @@ class NotificationMethodsTest { verify { client.get( - path = "/api/v1/notifications/1", + path = "api/v1/notifications/1", query = any() ) } @@ -100,7 +181,7 @@ class NotificationMethodsTest { verify { client.performAction( - endpoint = "/api/v1/notifications/1/dismiss", + endpoint = "api/v1/notifications/1/dismiss", method = MastodonClient.Method.POST ) } @@ -115,7 +196,7 @@ class NotificationMethodsTest { verify { client.performAction( - endpoint = "/api/v1/notifications/clear", + endpoint = "api/v1/notifications/clear", method = MastodonClient.Method.POST ) } diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/SearchMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/SearchMethodsTest.kt index 5ce742cd5..e160deb5f 100644 --- a/bigbone/src/test/kotlin/social/bigbone/api/method/SearchMethodsTest.kt +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/SearchMethodsTest.kt @@ -1,9 +1,14 @@ package social.bigbone.api.method +import io.mockk.slot +import io.mockk.verify import org.amshove.kluent.`should be equal to` import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldContainAll +import org.amshove.kluent.shouldNotContain import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import social.bigbone.Parameters import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.testtool.MockClient @@ -45,6 +50,25 @@ class SearchMethodsTest { result.statuses.all { it.id.toLong() in minId.toLong()..maxId.toLong() } } + @Test + fun searchTypeParameterIsProperlyCapitalized() { + val client = MockClient.mock("search.json") + val searchMethodsMethod = SearchMethods(client) + + searchMethodsMethod.searchContent("query", SearchMethods.SearchType.STATUSES).execute() + val parametersCapturingSlot = slot() + verify { + client.get( + path = "api/v2/search", + query = capture(parametersCapturingSlot) + ) + } + with(parametersCapturingSlot.captured) { + parameters["type"]?.shouldContainAll(listOf("statuses")) + parameters["type"]?.shouldNotContain(listOf("STATUSES")) + } + } + @Test fun searchWithException() { Assertions.assertThrows(BigBoneRequestException::class.java) { diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminMeasureMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminMeasureMethodsTest.kt new file mode 100644 index 000000000..66b3f0781 --- /dev/null +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminMeasureMethodsTest.kt @@ -0,0 +1,126 @@ +package social.bigbone.api.method.admin + +import io.mockk.slot +import io.mockk.verify +import org.amshove.kluent.invoking +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldHaveSize +import org.amshove.kluent.shouldNotBeNull +import org.amshove.kluent.shouldThrow +import org.amshove.kluent.withMessage +import org.junit.jupiter.api.Test +import social.bigbone.Parameters +import social.bigbone.PrecisionDateTime.ValidPrecisionDateTime.ExactTime +import social.bigbone.api.entity.admin.AdminMeasure +import social.bigbone.api.entity.admin.AdminMeasure.Key +import social.bigbone.api.exception.BigBoneRequestException +import social.bigbone.testtool.MockClient +import java.net.URLEncoder +import java.time.Instant + +class AdminMeasureMethodsTest { + + @Test + fun `Given client returning success, when calling getMeasurableDate, then ensure proper deserialisation and correct endpoint and parameter usage`() { + val client = MockClient.mock("admin_measures_get_measurable_data_success.json") + val adminMeasureMethods = AdminMeasureMethods(client) + val measures: List = listOf( + RequestMeasure.ActiveUsers, + RequestMeasure.NewUsers, + RequestMeasure.Interactions, + RequestMeasure.OpenedReports, + RequestMeasure.ResolvedReports, + RequestMeasure.TagAccounts(tagId = "123"), + RequestMeasure.TagUses(tagId = "123"), + RequestMeasure.TagServers(tagId = "123"), + RequestMeasure.InstanceAccounts(remoteDomain = "mastodon.social"), + RequestMeasure.InstanceMediaAttachments(remoteDomain = "mastodon.social"), + RequestMeasure.InstanceReports(remoteDomain = "mastodon.social"), + RequestMeasure.InstanceStatuses(remoteDomain = "mastodon.social"), + RequestMeasure.InstanceFollows(remoteDomain = "mastodon.social"), + RequestMeasure.InstanceFollowers(remoteDomain = "mastodon.social") + ) + val startAt = Instant.now().minusSeconds(600) + val endAt = Instant.now() + + val measurableData: List = adminMeasureMethods.getMeasurableData( + measures = measures, + startAt = startAt, + endAt = endAt + ).execute() + with(measurableData) { + shouldHaveSize(14) + + with(get(0)) { + key shouldBeEqualTo Key.ACTIVE_USERS + unit.shouldBeNull() + total shouldBeEqualTo "2" + previousTotal shouldBeEqualTo "0" + + with(data) { + shouldNotBeNull() + shouldHaveSize(1) + + get(0).date shouldBeEqualTo ExactTime(Instant.parse("2022-09-14T00:00:00Z")) + get(0).value shouldBeEqualTo "0" + } + } + } + + val parametersCapturingSlot = slot() + verify { + client.post( + path = "api/v1/admin/measures", + body = capture(parametersCapturingSlot), + addIdempotencyKey = false + ) + } + with(parametersCapturingSlot.captured) { + val measuresString: String = "keys[]=active_users&" + + "keys[]=new_users&" + + "keys[]=interactions&" + + "keys[]=opened_reports&" + + "keys[]=resolved_reports&" + + "keys[]=tag_accounts&" + + "keys[]=tag_uses&" + + "keys[]=tag_servers&" + + "keys[]=instance_accounts&" + + "keys[]=instance_media_attachments&" + + "keys[]=instance_reports&" + + "keys[]=instance_statuses&" + + "keys[]=instance_follows&" + + "keys[]=instance_followers&" + + "tag_accounts[id]=123&" + + "tag_uses[id]=123&" + + "tag_servers[id]=123&" + + "instance_accounts[domain]=mastodon.social&" + + "instance_media_attachments[domain]=mastodon.social&" + + "instance_reports[domain]=mastodon.social&" + + "instance_statuses[domain]=mastodon.social&" + + "instance_follows[domain]=mastodon.social&" + + "instance_followers[domain]=mastodon.social" + val startString = URLEncoder.encode(startAt.toString(), "utf-8") + val endString = URLEncoder.encode(endAt.toString(), "utf-8") + + toQuery() shouldBeEqualTo "$measuresString&start_at=$startString&end_at=$endString" + } + } + + @Test + fun `Given a client returning forbidden, when getting measurable data, then propagate error`() { + val client = MockClient.failWithResponse( + responseJsonAssetPath = "error_403_forbidden.json", + responseCode = 403, + message = "Forbidden" + ) + + invoking { + AdminMeasureMethods(client).getMeasurableData( + measures = listOf(RequestMeasure.ActiveUsers), + startAt = Instant.now().minusSeconds(600), + endAt = Instant.now() + ).execute() + } shouldThrow BigBoneRequestException::class withMessage "Forbidden" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 88043fd8e..f6b8cca3a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,9 +3,9 @@ bigbone = "2.0.0-SNAPSHOT" # 3rd party dependencies -junit-jupiter = "5.10.0" +junit-jupiter = "5.10.1" junit-platform-launcher = "1.10.1" -junit-platform-suite-engine = "1.10.0" +junit-platform-suite-engine = "1.10.1" kluent = "1.73" kotlin = "1.9.20" kotlin-coroutines = "1.7.3" @@ -17,7 +17,7 @@ rxjava = "3.1.8" # Gradle plugins jacoco = "0.8.9" dependency-analysis = "1.25.0" -detekt = "1.23.1" +detekt = "1.23.3" versions = "0.49.0" ktlint = "11.6.1" diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/GetNotifications.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/GetNotifications.kt new file mode 100644 index 000000000..8e1b65d75 --- /dev/null +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/GetNotifications.kt @@ -0,0 +1,49 @@ +package social.bigbone.sample + +import social.bigbone.MastodonClient +import social.bigbone.api.Pageable +import social.bigbone.api.entity.Notification + +object GetNotifications { + + /** + * Get all notifications for the account having the access token supplied via [args]. + * + * Example call: "mastodon.social" "$TOKEN" "favourite,mention" "poll,reblog" "$ACCOUNT_ID" + */ + @JvmStatic + fun main(args: Array) { + val instance: String = args[0] + val accessToken: String = args[1] + val includeTypes: String = args.getOrElse(2) { "" } // Comma-separated + val excludeTypes: String = args.getOrElse(3) { "" } // Comma-separated + val accountId: String? = args.getOrNull(4) + + // Instantiate client + val client = MastodonClient.Builder(instance) + .accessToken(accessToken) + .build() + + // Get notifications + val notifications: Pageable = client.notifications.getAllNotifications( + includeTypes = includeTypes.explodeToNotificationTypes(), + excludeTypes = excludeTypes.explodeToNotificationTypes(), + accountId = accountId + ).execute() + + notifications.part.forEach(::println) + } + + private fun String.explodeToNotificationTypes(): List? { + return split(",") + .mapNotNull { it.toNotificationType() } + .takeIf { it.isNotEmpty() } + } + + private fun String.toNotificationType(): Notification.NotificationType? { + for (notificationType in Notification.NotificationType.entries) { + if (notificationType.apiName.equals(this.trim(), ignoreCase = true)) return notificationType + } + return null + } +}