From 1c048ec4e509e6b4b82fd805c82ee227f92f21ca Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 00:02:26 +0100 Subject: [PATCH 01/28] Add API entities for streaming methods --- .../bigbone/api/entity/streaming/Event.kt | 31 ++++++ .../bigbone/api/entity/streaming/EventType.kt | 96 ++++++++++++++++ .../api/entity/streaming/StreamType.kt | 105 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/Event.kt create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/Event.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/Event.kt new file mode 100644 index 000000000..ba9643025 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/Event.kt @@ -0,0 +1,31 @@ +package social.bigbone.api.entity.streaming + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Streaming event constantly emitted by the streaming APIs. + * @see Mastodon streaming#events entity docs + */ +@Serializable +data class Event( + + /** + * Types of stream as subscribed to via the streaming API call, represented by [StreamType]. + */ + @SerialName("stream") + val stream: List? = null, + + /** + * Type of event, such as status update, represented by [EventType]. + */ + @SerialName("event") + val event: EventType? = null, + + /** + * Payload sent with the event. Content depends on [event] type. + * See [EventType] for documentation about possible payloads. + */ + @SerialName("payload") + val payload: String? = null +) diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt new file mode 100644 index 000000000..32599f5af --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt @@ -0,0 +1,96 @@ +package social.bigbone.api.entity.streaming + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Event types that can be received as part of [Event] when using streaming APIs. + * @see Mastodon streaming#events entities + */ +@Serializable +enum class EventType { + + /** + * A new Status has appeared. + * Payload contains a Status cast to a string. + * Available since v1.0.0 + */ + @SerialName("update") + UPDATE, + + /** + * A status has been deleted. + * Payload contains the String ID of the deleted Status. + * Available since v1.0.0 + */ + @SerialName("delete") + DELETE, + + /** + * A new notification has appeared. + * Payload contains a Notification cast to a string. + * Available since v1.4.2 + */ + @SerialName("notification") + NOTIFICATION, + + /** + * Keyword filters have been changed. + * Either does not contain a payload (for WebSocket connections), + * or contains an undefined payload (for HTTP connections). + * Available since v2.4.3 + */ + @SerialName("filters_changed") + FILTERS_CHANGED, + + /** + * A direct conversation has been updated. + * Payload contains a Conversation cast to a string. + * Available since v2.6.0 + */ + @SerialName("conversation") + CONVERSATION, + + /** + * An announcement has been published. + * Payload contains an Announcement cast to a string. + * Available since v3.1.0 + */ + @SerialName("announcement") + ANNOUNCEMENT, + + /** + * An announcement has received an emoji reaction. + * Payload contains a Hash (with name, count, and announcement_id) cast to a string. + * Available since v3.1.0 + */ + @SerialName("announcement.reaction") + ANNOUNCEMENT_REACTION, + + /** + * An announcement has been deleted. + * Payload contains the String ID of the deleted Announcement. + * Available since v3.1.0 + */ + @SerialName("announcement.delete") + ANNOUNCEMENT_DELETE, + + /** + * A Status has been edited. + * Payload contains a Status cast to a string. + * Available since v3.5.0 + */ + @SerialName("status.update") + STATUS_UPDATE, + + /** + * An encrypted message has been received. + * Implemented in v3.2.0 but currently unused + */ + @SerialName("encrypted_message") + ENCRYPTED_MESSAGE; + + @OptIn(ExperimentalSerializationApi::class) + val apiName: String get() = EventType.serializer().descriptor.getElementName(ordinal) +} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt new file mode 100644 index 000000000..b391f82e2 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt @@ -0,0 +1,105 @@ +package social.bigbone.api.entity.streaming + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Event types that can be used for streaming specific timelines, + * or received as part of [Event] when using streaming APIs. + * @see Mastodon streaming#events entities + */ +@Serializable +enum class StreamType { + + /** + * All public posts known to the server. + * Analogous to the federated timeline. + * Available since v1.0.0 + */ + @SerialName("public") + PUBLIC, + + /** + * All public posts known to the server, filtered for media attachments. + * Analogous to the federated timeline with “only media” enabled. + * Available since v2.4.0 + */ + @SerialName("public:media") + PUBLIC_MEDIA, + + /** + * All public posts originating from this server. + * Analogous to the local timeline. + * Available since v1.1 + */ + @SerialName("public:local") + PUBLIC_LOCAL, + + /** + * All public posts originating from this server, filtered for media attachments. + * Analogous to the local timeline with “only media” enabled. + * Available since v2.4.0 + */ + @SerialName("public:local:media") + PUBLIC_LOCAL_MEDIA, + + /** + * All public posts originating from other servers. + * Available since v3.1.4 + */ + @SerialName("public:remote") + PUBLIC_REMOTE, + + /** + * All public posts originating from other servers, filtered for media attachments. + * Available since v3.1.4 + */ + @SerialName("public:remote:media") + PUBLIC_REMOTE_MEDIA, + + /** + * All public posts using a certain hashtag. + * Available since v1.0.0 + */ + @SerialName("hashtag") + HASHTAG, + + /** + * All public posts using a certain hashtag, originating from this server. + * Available since v1.1 + */ + @SerialName("hashtag:local") + HASHTAG_LOCAL, + + /** + * Events related to the current user, such as home feed updates and notifications. + * Available since v1.0.0 + */ + @SerialName("user") + USER, + + /** + * Notifications for the current user. + * Available since v1.4.2 + */ + @SerialName("user:notification") + USER_NOTIFICATION, + + /** + * Updates to a specific list. + * Available since v2.1.0 + */ + @SerialName("list") + LIST, + + /** + * Updates to direct conversations. + * Available since v2.4.0 + */ + @SerialName("direct") + DIRECT; + + @OptIn(ExperimentalSerializationApi::class) + val apiName: String get() = StreamType.serializer().descriptor.getElementName(ordinal) +} From 71e0c587ebc44aaded37177ecd99be48e40f4fbe Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 01:24:46 +0100 Subject: [PATCH 02/28] Implement streaming methods using WebSocket connection --- .../social/bigbone/rx/RxStreamingMethods.kt | 113 +++++++++---- .../kotlin/social/bigbone/MastodonClient.kt | 56 +++++++ .../social/bigbone/api/WebSocketCallback.kt | 29 ++++ .../bigbone/api/method/StreamingMethods.kt | 157 ++++++++++++++++++ docs/api-coverage/streaming.md | 41 +++-- .../bigbone/sample/RxStreamPublicTimeline.kt | 26 ++- .../sample/StreamFederatedPublicTimeline.kt | 27 +-- 7 files changed, 365 insertions(+), 84 deletions(-) create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index e5d1cd45e..5003d6f7a 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -2,12 +2,12 @@ package social.bigbone.rx import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.FlowableEmitter import social.bigbone.MastodonClient -import social.bigbone.api.Handler -import social.bigbone.api.Shutdownable -import social.bigbone.api.entity.Notification -import social.bigbone.api.entity.Status +import social.bigbone.api.WebSocketCallback +import social.bigbone.api.WebSocketEvent import social.bigbone.api.method.StreamingMethods +import java.io.Closeable /** * Reactive implementation of [StreamingMethods]. @@ -15,48 +15,89 @@ import social.bigbone.api.method.StreamingMethods * @see Mastodon streaming API methods */ class RxStreamingMethods(client: MastodonClient) { + private val streamingMethods = StreamingMethods(client) - private fun stream(f: (Handler) -> Shutdownable): Flowable { - return Flowable.create({ emitter -> - val shutdownable = f(object : Handler { - override fun onStatus(status: Status) { - emitter.onNext(status) - } - - override fun onNotification(notification: Notification) { - // no op - } - - override fun onDelete(id: String) { - // no op - } - }) - emitter.setCancellable { - shutdownable.shutdown() - } - }, BackpressureStrategy.LATEST) + fun federatedPublic(accessToken: String, onlyMedia: Boolean): Flowable = streamTimeline { + streamingMethods.federatedPublic(accessToken, onlyMedia, it) } - private fun statusStream(f: (Handler) -> Shutdownable): Flowable { - return stream { handler -> - f(handler) - } + fun localPublic(accessToken: String, onlyMedia: Boolean): Flowable = streamTimeline { + streamingMethods.localPublic(accessToken, onlyMedia, it) } - private fun tagStream(tag: String, f: (String, Handler) -> Shutdownable): Flowable { - return stream { handler -> - f(tag, handler) - } + fun remotePublic(accessToken: String, onlyMedia: Boolean): Flowable = streamTimeline { + streamingMethods.remotePublic(accessToken, onlyMedia, it) + } + + fun hashtag( + accessToken: String, + tagName: String, + onlyFromThisServer: Boolean + ): Flowable = streamTag(tagName, onlyFromThisServer) { tag, onlyLocal, callback -> + streamingMethods.hashtag( + accessToken = accessToken, + tagName = tag, + onlyFromThisServer = onlyLocal, + callback = callback + ) + } + + fun user(accessToken: String): Flowable = streamTimeline { + streamingMethods.user(accessToken, it) } - fun localPublic(): Flowable = statusStream(streamingMethods::localPublic) + fun list( + accessToken: String, + listId: String, + ): Flowable = streamList(listId) { list, callback -> + streamingMethods.list( + accessToken = accessToken, + listId = list, + callback = callback + ) + } + + fun directConversations(accessToken: String): Flowable = streamTimeline { + streamingMethods.directConversations(accessToken, it) + } - fun federatedPublic(): Flowable = statusStream(streamingMethods::federatedPublic) + private fun streamTimeline(streamMethod: (WebSocketCallback) -> Closeable): Flowable { + return Flowable.create({ emitter -> + val closeable = streamMethod(emitter.fromWebSocketCallback()) + emitter.setCancellable(closeable::close) + }, BackpressureStrategy.BUFFER) + } - fun localHashtag(tag: String): Flowable = tagStream(tag, streamingMethods::localHashtag) + private fun streamList( + listId: String, + streamMethod: (String, WebSocketCallback) -> Closeable + ): Flowable { + return Flowable.create({ emitter -> + val closeable = streamMethod(listId, emitter.fromWebSocketCallback()) + emitter.setCancellable(closeable::close) + }, BackpressureStrategy.BUFFER) + } - fun federatedHashtag(tag: String): Flowable = tagStream(tag, streamingMethods::federatedHashtag) + private fun streamTag( + tagName: String, + onlyFromThisServer: Boolean, + streamMethod: (String, Boolean, WebSocketCallback) -> Closeable + ): Flowable { + return Flowable.create({ emitter -> + val closeable = streamMethod(tagName, onlyFromThisServer, emitter.fromWebSocketCallback()) + emitter.setCancellable(closeable::close) + }, BackpressureStrategy.BUFFER) + } - // TODO user streaming + private fun FlowableEmitter.fromWebSocketCallback(): (event: WebSocketEvent) -> Unit = + { webSocketEvent -> + when (webSocketEvent) { + is WebSocketEvent.Closed -> onComplete() + is WebSocketEvent.Failure -> tryOnError(webSocketEvent.error) + WebSocketEvent.Open, + is WebSocketEvent.Closing, + is WebSocketEvent.StreamEvent -> onNext(webSocketEvent) + } + } } diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 2b103e86c..f1a91f091 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -8,8 +8,14 @@ import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString import social.bigbone.api.Pageable +import social.bigbone.api.WebSocketCallback +import social.bigbone.api.WebSocketEvent import social.bigbone.api.entity.data.InstanceVersion +import social.bigbone.api.entity.streaming.Event import social.bigbone.api.exception.BigBoneClientInstantiationException import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.api.exception.InstanceVersionRetrievalException @@ -507,6 +513,56 @@ private constructor( } } + fun stream(accessToken: String, path: String, query: Parameters?, callback: WebSocketCallback): WebSocket { + return client.newWebSocket( + request = Request.Builder() + .header("Authorization", "Bearer $accessToken") + .url( + fullUrl( + scheme = scheme, + instanceName = instanceName, + port = port, + path = path, + query = query + + ) + ) + .build(), + listener = object : WebSocketListener() { + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + super.onClosed(webSocket, code, reason) + callback.onEvent(WebSocketEvent.Closed(code, reason)) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + super.onClosing(webSocket, code, reason) + callback.onEvent(WebSocketEvent.Closing(code, reason)) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + super.onFailure(webSocket, t, response) + callback.onEvent(WebSocketEvent.Failure(t)) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + super.onMessage(webSocket, text) + val event = JSON_SERIALIZER.decodeFromString(text) + callback.onEvent(WebSocketEvent.StreamEvent(event = event)) + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + super.onMessage(webSocket, bytes) + println("onMessage with bytes $bytes") + } + + override fun onOpen(webSocket: WebSocket, response: Response) { + super.onOpen(webSocket, response) + callback.onEvent(WebSocketEvent.Open) + } + } + ) + } + /** * Get a response from the Mastodon instance defined for this client using the PATCH method. * @param path an absolute path to the API endpoint to call diff --git a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt new file mode 100644 index 000000000..d4629588b --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt @@ -0,0 +1,29 @@ +package social.bigbone.api + +import social.bigbone.api.entity.streaming.Event + +fun interface WebSocketCallback { + + fun onEvent(event: WebSocketEvent) + +} + +sealed interface WebSocketEvent { + + data object Open : WebSocketEvent + + data class Closing( + val code: Int, + val reason: String + ) : WebSocketEvent + + data class Closed( + val code: Int, + val reason: String + ) : WebSocketEvent + + data class StreamEvent(val event: Event) : WebSocketEvent + + data class Failure(val error: Throwable) : WebSocketEvent +} + diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index 288d53c5e..eb8a0a9b3 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -6,8 +6,23 @@ import social.bigbone.Parameters import social.bigbone.api.Dispatcher import social.bigbone.api.Handler import social.bigbone.api.Shutdownable +import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.Status +import social.bigbone.api.entity.streaming.StreamType +import social.bigbone.api.entity.streaming.StreamType.DIRECT +import social.bigbone.api.entity.streaming.StreamType.HASHTAG +import social.bigbone.api.entity.streaming.StreamType.HASHTAG_LOCAL +import social.bigbone.api.entity.streaming.StreamType.LIST +import social.bigbone.api.entity.streaming.StreamType.PUBLIC +import social.bigbone.api.entity.streaming.StreamType.PUBLIC_LOCAL +import social.bigbone.api.entity.streaming.StreamType.PUBLIC_LOCAL_MEDIA +import social.bigbone.api.entity.streaming.StreamType.PUBLIC_MEDIA +import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE +import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE_MEDIA +import social.bigbone.api.entity.streaming.StreamType.USER +import social.bigbone.api.entity.streaming.StreamType.USER_NOTIFICATION import social.bigbone.api.exception.BigBoneRequestException +import java.io.Closeable /** * Allows access to API methods with endpoints having an "api/vX/streaming" prefix. @@ -276,4 +291,146 @@ class StreamingMethods(private val client: MastodonClient) { throw BigBoneRequestException(response) } } + + private fun stream( + accessToken: String, + streamType: StreamType, + callback: WebSocketCallback, + listId: String? = null, + tagName: String? = null + ): Closeable { + val webSocket = client.stream( + accessToken = accessToken, + path = "api/v1/streaming", + query = Parameters().apply { + append("stream", streamType.apiName) + + if (streamType == LIST) { + requireNotNull(listId) { + "When requesting $streamType for stream, a non-null list ID needs to be specified" + } + append("list", listId) + } + + if (streamType == HASHTAG || streamType == HASHTAG_LOCAL) { + requireNotNull(tagName) { + "When requesting $streamType for stream, a non-null tag name needs to be specified" + } + append("tag", tagName) + } + }, + callback = callback + ) + + return Closeable { + println("Closing websocket…") + val closed = webSocket.close( + /* + 1000 indicates a normal closure, + meaning that the purpose for which the connection was established has been fulfilled. + see: https://datatracker.ietf.org/doc/html/rfc6455#section-7.4 + */ + code = 1000, + reason = null + ) + println("WebSocket closed? $closed") + } + } + + fun federatedPublic( + accessToken: String, + onlyMedia: Boolean, + callback: WebSocketCallback + ): Closeable { + return stream( + accessToken = accessToken, + streamType = if (onlyMedia) PUBLIC_MEDIA else PUBLIC, + callback = callback + ) + } + + fun localPublic( + accessToken: String, + onlyMedia: Boolean, + callback: WebSocketCallback + ): Closeable { + return stream( + accessToken = accessToken, + streamType = if (onlyMedia) PUBLIC_LOCAL_MEDIA else PUBLIC_LOCAL, + callback = callback + ) + } + + fun remotePublic( + accessToken: String, + onlyMedia: Boolean, + callback: WebSocketCallback + ): Closeable { + return stream( + accessToken = accessToken, + streamType = if (onlyMedia) PUBLIC_REMOTE_MEDIA else PUBLIC_REMOTE, + callback = callback + ) + } + + fun hashtag( + accessToken: String, + tagName: String, + onlyFromThisServer: Boolean, + callback: WebSocketCallback + ): Closeable { + return stream( + accessToken = accessToken, + streamType = if (onlyFromThisServer) HASHTAG_LOCAL else HASHTAG, + tagName = tagName, + callback = callback + ) + } + + fun user( + accessToken: String, + callback: WebSocketCallback + ): Closeable { + return stream( + accessToken = accessToken, + streamType = USER, + callback = callback + ) + } + + fun userNotifications( + accessToken: String, + callback: WebSocketCallback + ): Closeable { + return stream( + accessToken = accessToken, + streamType = USER_NOTIFICATION, + callback = callback + ) + } + + fun list( + accessToken: String, + listId: String, + callback: WebSocketCallback + ): Closeable { + return stream( + accessToken = accessToken, + streamType = LIST, + listId = listId, + callback = callback + ) + } + + fun directConversations( + accessToken: String, + callback: WebSocketCallback + ): Closeable { + return stream( + accessToken = accessToken, + streamType = DIRECT, + callback = callback + ) + } + } diff --git a/docs/api-coverage/streaming.md b/docs/api-coverage/streaming.md index e4ffc7bbb..0cbc2c89d 100644 --- a/docs/api-coverage/streaming.md +++ b/docs/api-coverage/streaming.md @@ -26,47 +26,52 @@ Subscribe to server-sent events for real-time updates via a long-lived HTTP conn GET /api/v1/streaming/user
Watch your home timeline and notifications - - filters_changed, announcement, announcement.reaction, announcement.delete, status.update event types not supported. + + Implemented via WebSocket endpoint. GET /api/v1/streaming/user/notification
Watch your notifications - - Not implemented yet. + + Implemented via WebSocket endpoint. GET /api/v1/streaming/public
Watch the federated timeline - - only_media query parameter not supported. deletecode>, status.update event types not supported. + + Implemented via WebSocket endpoint. GET /api/v1/streaming/public/local
Watch the local timeline - - only_media query parameter not supported. delete, status.update event types not supported. + + Implemented via WebSocket endpoint. GET /api/v1/streaming/public/remote
Watch for remote statuses - - Not implemented yet. + + Implemented via WebSocket endpoint. GET /api/v1/streaming/hashtag
Watch the public timeline for a hashtag - - delete, status.update event types not supported. + + Implemented via WebSocket endpoint. GET /api/v1/streaming/hashtag/local
Watch the local timeline for a hashtag - - delete, status.update event types not supported. + + Implemented via WebSocket endpoint. GET /api/v1/streaming/list
Watch for list updates - - status.update event types not supported. + + Implemented via WebSocket endpoint. GET /api/v1/streaming/direct
Watch for direct messages - - Not implemented yet. + + Implemented via WebSocket endpoint. + + + GET wss://$instance/api/v1/streaming
Establishing a WebSocket connection + + Exposed as individual methods as seen above. diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt index 902df5f7c..3e3d88b96 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt @@ -1,28 +1,36 @@ package social.bigbone.sample import io.reactivex.rxjava3.schedulers.Schedulers +import social.bigbone.MastodonClient import social.bigbone.rx.RxStreamingMethods +import java.time.Duration object RxStreamPublicTimeline { - private const val TEN_SECONDS = 10_000L @JvmStatic fun main(args: Array) { val instanceName = args[0] - val credentialFilePath = args[1] + val accessToken = args[1] // require authentication even if public streaming - val client = Authenticator.appRegistrationIfNeeded(instanceName, credentialFilePath, true) - + val client = MastodonClient.Builder(instanceName) + .accessToken(accessToken) + .build() val streaming = RxStreamingMethods(client) println("init") - val disposable = streaming.localPublic() + val disposable = streaming.federatedPublic( + accessToken = accessToken, + onlyMedia = false + ) .subscribeOn(Schedulers.io()) - .subscribe { - println("${it.createdAt}: ${it.account?.acct} < ${it.content.replace("<.*?>".toRegex(), "")}") - } - Thread.sleep(TEN_SECONDS) + .subscribe( + /* onNext = */ ::println, + /* onError = */ ::println, + /* onComplete = */ { println("onComplete") } + ) + + Thread.sleep(Duration.ofSeconds(10).toSeconds()) disposable.dispose() } } diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt index 5f9256df1..cd65d82ff 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt @@ -1,9 +1,6 @@ package social.bigbone.sample import social.bigbone.MastodonClient -import social.bigbone.api.Handler -import social.bigbone.api.entity.Notification -import social.bigbone.api.entity.Status object StreamFederatedPublicTimeline { @JvmStatic @@ -17,24 +14,12 @@ object StreamFederatedPublicTimeline { .useStreamingApi() .build() - // Configure status handler - val handler: Handler = object : Handler { - override fun onStatus(status: Status) { - println(status.content) - } - - override fun onNotification(notification: Notification) { - // No op - } - - override fun onDelete(id: String) { - // No op - } + client.streaming.federatedPublic( + accessToken = accessToken, + onlyMedia = false, + callback = ::println + ).use { + Thread.sleep(15_000L) } - - // Start federated timeline streaming and stop after 20 seconds - val shutdownable = client.streaming.federatedPublic(handler) - Thread.sleep(20_000L) - shutdownable.shutdown() } } From 55d122f19df5a3e234d564346900b6f77d6f879f Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 01:39:14 +0100 Subject: [PATCH 03/28] Reuse access token from client initialisation --- .../social/bigbone/rx/RxStreamingMethods.kt | 87 +++++++++++-------- .../kotlin/social/bigbone/MastodonClient.kt | 9 +- .../bigbone/api/method/StreamingMethods.kt | 30 +------ .../bigbone/sample/RxStreamPublicTimeline.kt | 1 - .../sample/StreamFederatedPublicTimeline.kt | 1 - 5 files changed, 63 insertions(+), 65 deletions(-) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index 5003d6f7a..90d5590d5 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -12,55 +12,79 @@ import java.io.Closeable /** * Reactive implementation of [StreamingMethods]. * Allows access to API methods with endpoints having an "api/vX/streaming" prefix. - * @see Mastodon streaming API methods + * @see Mastodon streaming API methods */ class RxStreamingMethods(client: MastodonClient) { private val streamingMethods = StreamingMethods(client) - fun federatedPublic(accessToken: String, onlyMedia: Boolean): Flowable = streamTimeline { - streamingMethods.federatedPublic(accessToken, onlyMedia, it) + /** + * Stream all public posts known to this server. Analogous to the federated timeline. + * + * @param onlyMedia Filter for media attachments. Analogous to the federated timeline with “only media” enabled. + */ + fun federatedPublic(onlyMedia: Boolean): Flowable = streamTimeline { + streamingMethods.federatedPublic(onlyMedia, it) } - fun localPublic(accessToken: String, onlyMedia: Boolean): Flowable = streamTimeline { - streamingMethods.localPublic(accessToken, onlyMedia, it) + /** + * Stream all public posts originating from this server. Analogous to the local timeline. + * + * @param onlyMedia Filter for media attachments. Analogous to the local timeline with “only media” enabled. + */ + fun localPublic(onlyMedia: Boolean): Flowable = streamTimeline { + streamingMethods.localPublic(onlyMedia, it) } - fun remotePublic(accessToken: String, onlyMedia: Boolean): Flowable = streamTimeline { - streamingMethods.remotePublic(accessToken, onlyMedia, it) + /** + * Stream all public posts originating from other servers. + * + * @param onlyMedia Filter for media attachments. + */ + fun remotePublic(onlyMedia: Boolean): Flowable = streamTimeline { + streamingMethods.remotePublic(onlyMedia, it) } + /** + * Stream all public posts using the hashtag [tagName]. + * + * @param onlyFromThisServer Filter for public posts originating from this server. + */ fun hashtag( - accessToken: String, tagName: String, onlyFromThisServer: Boolean - ): Flowable = streamTag(tagName, onlyFromThisServer) { tag, onlyLocal, callback -> + ): Flowable = streamTag { callback -> streamingMethods.hashtag( - accessToken = accessToken, - tagName = tag, - onlyFromThisServer = onlyLocal, + tagName = tagName, + onlyFromThisServer = onlyFromThisServer, callback = callback ) } - fun user(accessToken: String): Flowable = streamTimeline { - streamingMethods.user(accessToken, it) - } + /** + * Stream all events related to the current user, such as home feed updates and notifications. + */ + fun user(): Flowable = streamTimeline(streamingMethods::user) + + /** + * Stream all notifications for the current user. + */ + fun userNotifications(): Flowable = streamTimeline(streamingMethods::userNotifications) - fun list( - accessToken: String, - listId: String, - ): Flowable = streamList(listId) { list, callback -> + /** + * Stream updates to the list with [listId]. + */ + fun list(listId: String): Flowable = streamList { callback -> streamingMethods.list( - accessToken = accessToken, - listId = list, + listId = listId, callback = callback ) } - fun directConversations(accessToken: String): Flowable = streamTimeline { - streamingMethods.directConversations(accessToken, it) - } + /** + * Stream all updates to direct conversations. + */ + fun directConversations(): Flowable = streamTimeline(streamingMethods::directConversations) private fun streamTimeline(streamMethod: (WebSocketCallback) -> Closeable): Flowable { return Flowable.create({ emitter -> @@ -69,23 +93,16 @@ class RxStreamingMethods(client: MastodonClient) { }, BackpressureStrategy.BUFFER) } - private fun streamList( - listId: String, - streamMethod: (String, WebSocketCallback) -> Closeable - ): Flowable { + private fun streamList(streamMethod: (WebSocketCallback) -> Closeable): Flowable { return Flowable.create({ emitter -> - val closeable = streamMethod(listId, emitter.fromWebSocketCallback()) + val closeable = streamMethod(emitter.fromWebSocketCallback()) emitter.setCancellable(closeable::close) }, BackpressureStrategy.BUFFER) } - private fun streamTag( - tagName: String, - onlyFromThisServer: Boolean, - streamMethod: (String, Boolean, WebSocketCallback) -> Closeable - ): Flowable { + private fun streamTag(streamMethod: (WebSocketCallback) -> Closeable): Flowable { return Flowable.create({ emitter -> - val closeable = streamMethod(tagName, onlyFromThisServer, emitter.fromWebSocketCallback()) + val closeable = streamMethod(emitter.fromWebSocketCallback()) emitter.setCancellable(closeable::close) }, BackpressureStrategy.BUFFER) } diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index f1a91f091..61c2db130 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -78,6 +78,7 @@ class MastodonClient private constructor( private val instanceName: String, private val client: OkHttpClient, + private var accessToken: String? = null, private var debug: Boolean = false, private var instanceVersion: String? = null, private var scheme: String = "https", @@ -513,9 +514,14 @@ private constructor( } } - fun stream(accessToken: String, path: String, query: Parameters?, callback: WebSocketCallback): WebSocket { + fun stream(path: String, query: Parameters?, callback: WebSocketCallback): WebSocket { return client.newWebSocket( request = Request.Builder() + /* + OKHTTP doesn’t currently (at least when checked in 4.12.0) use the [AuthorizationInterceptor] for + WebSocket connections, so we need to set it in the header ourselves again here. + See also: https://github.com/square/okhttp/issues/6454 + */ .header("Authorization", "Bearer $accessToken") .url( fullUrl( @@ -917,6 +923,7 @@ private constructor( .writeTimeout(writeTimeoutSeconds, TimeUnit.SECONDS) .connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS) .build(), + accessToken = accessToken, debug = debug, instanceVersion = getInstanceVersion(), scheme = scheme, diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index eb8a0a9b3..151705a67 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -293,14 +293,12 @@ class StreamingMethods(private val client: MastodonClient) { } private fun stream( - accessToken: String, streamType: StreamType, callback: WebSocketCallback, listId: String? = null, tagName: String? = null ): Closeable { val webSocket = client.stream( - accessToken = accessToken, path = "api/v1/streaming", query = Parameters().apply { append("stream", streamType.apiName) @@ -338,96 +336,74 @@ class StreamingMethods(private val client: MastodonClient) { } fun federatedPublic( - accessToken: String, onlyMedia: Boolean, callback: WebSocketCallback ): Closeable { return stream( - accessToken = accessToken, streamType = if (onlyMedia) PUBLIC_MEDIA else PUBLIC, callback = callback ) } fun localPublic( - accessToken: String, onlyMedia: Boolean, callback: WebSocketCallback ): Closeable { return stream( - accessToken = accessToken, streamType = if (onlyMedia) PUBLIC_LOCAL_MEDIA else PUBLIC_LOCAL, callback = callback ) } fun remotePublic( - accessToken: String, onlyMedia: Boolean, callback: WebSocketCallback ): Closeable { return stream( - accessToken = accessToken, streamType = if (onlyMedia) PUBLIC_REMOTE_MEDIA else PUBLIC_REMOTE, callback = callback ) } fun hashtag( - accessToken: String, tagName: String, onlyFromThisServer: Boolean, callback: WebSocketCallback ): Closeable { return stream( - accessToken = accessToken, streamType = if (onlyFromThisServer) HASHTAG_LOCAL else HASHTAG, tagName = tagName, callback = callback ) } - fun user( - accessToken: String, - callback: WebSocketCallback - ): Closeable { + fun user(callback: WebSocketCallback): Closeable { return stream( - accessToken = accessToken, streamType = USER, callback = callback ) } - fun userNotifications( - accessToken: String, - callback: WebSocketCallback - ): Closeable { + fun userNotifications(callback: WebSocketCallback): Closeable { return stream( - accessToken = accessToken, streamType = USER_NOTIFICATION, callback = callback ) } fun list( - accessToken: String, listId: String, callback: WebSocketCallback ): Closeable { return stream( - accessToken = accessToken, streamType = LIST, listId = listId, callback = callback ) } - fun directConversations( - accessToken: String, - callback: WebSocketCallback - ): Closeable { + fun directConversations(callback: WebSocketCallback): Closeable { return stream( - accessToken = accessToken, streamType = DIRECT, callback = callback ) diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt index 3e3d88b96..b7ce1529b 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt @@ -20,7 +20,6 @@ object RxStreamPublicTimeline { println("init") val disposable = streaming.federatedPublic( - accessToken = accessToken, onlyMedia = false ) .subscribeOn(Schedulers.io()) diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt index cd65d82ff..a982bd3dc 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt @@ -15,7 +15,6 @@ object StreamFederatedPublicTimeline { .build() client.streaming.federatedPublic( - accessToken = accessToken, onlyMedia = false, callback = ::println ).use { From c40d291c2ee02b06bafba080e841334f3c51cf0d Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 01:57:28 +0100 Subject: [PATCH 04/28] Add remaining documentation to public methods --- .../social/bigbone/rx/RxStreamingMethods.kt | 3 + .../social/bigbone/api/WebSocketCallback.kt | 37 +++++++++++- .../bigbone/api/method/StreamingMethods.kt | 56 ++++++++++++++++++- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index 90d5590d5..c23bae38f 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -48,6 +48,7 @@ class RxStreamingMethods(client: MastodonClient) { /** * Stream all public posts using the hashtag [tagName]. * + * @param tagName Hashtag the public posts you want to stream should have. * @param onlyFromThisServer Filter for public posts originating from this server. */ fun hashtag( @@ -73,6 +74,8 @@ class RxStreamingMethods(client: MastodonClient) { /** * Stream updates to the list with [listId]. + * + * @param listId List you want to receive updates for. */ fun list(listId: String): Flowable = streamList { callback -> streamingMethods.list( diff --git a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt index d4629588b..72b842783 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt @@ -1,29 +1,62 @@ package social.bigbone.api +import social.bigbone.api.WebSocketEvent.StreamEvent import social.bigbone.api.entity.streaming.Event +import social.bigbone.api.method.StreamingMethods +/** + * Callback interface you need to implement to receive [WebSocketEvent]s that are sent + * when using streaming functions in [StreamingMethods]. + */ fun interface WebSocketCallback { + /** + * Callback function that is called for every event received during websocket connection. + * + * @param event Change events for the websocket connection established during streaming. + */ fun onEvent(event: WebSocketEvent) - } +/** + * Events received during websocket connection. + * + * [StreamEvent] is likely the one you’ll be most interested in as it wraps the return type returned + * by the Mastodon API when streaming via a websocket connection. + * All others are more technical events pertaining to the websocket connection itself. + */ sealed interface WebSocketEvent { + /** + * The websocket has been opened. A connection is established. + */ data object Open : WebSocketEvent + /** + * The websocket is about to close. + */ data class Closing( val code: Int, val reason: String ) : WebSocketEvent + /** + * The websocket is now closed. + */ data class Closed( val code: Int, val reason: String ) : WebSocketEvent + /** + * An event from the Mastodon API has been received via the websocket. + */ data class StreamEvent(val event: Event) : WebSocketEvent + /** + * An error occurred during the websocket connection. + * This is a final event: No further calls to the [WebSocketCallback] emitting these events + * will be made. + */ data class Failure(val error: Throwable) : WebSocketEvent } - diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index 151705a67..f48016ca1 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -7,6 +7,7 @@ import social.bigbone.api.Dispatcher import social.bigbone.api.Handler import social.bigbone.api.Shutdownable import social.bigbone.api.WebSocketCallback +import social.bigbone.api.WebSocketEvent import social.bigbone.api.entity.Status import social.bigbone.api.entity.streaming.StreamType import social.bigbone.api.entity.streaming.StreamType.DIRECT @@ -335,6 +336,13 @@ class StreamingMethods(private val client: MastodonClient) { } } + /** + * Stream all public posts known to this server. Analogous to the federated timeline. + * + * @param onlyMedia Filter for media attachments. Analogous to the federated timeline with “only media” enabled. + * @param callback Your implementation of [WebSocketCallback] to receive stream of [WebSocketEvent]s. + * @return [Closeable] for you to close the websocket once you’re done with streaming. + */ fun federatedPublic( onlyMedia: Boolean, callback: WebSocketCallback @@ -345,6 +353,13 @@ class StreamingMethods(private val client: MastodonClient) { ) } + /** + * Stream all public posts originating from this server. Analogous to the local timeline. + * + * @param onlyMedia Filter for media attachments. Analogous to the local timeline with “only media” enabled. + * @param callback Your implementation of [WebSocketCallback] to receive stream of [WebSocketEvent]s. + * @return [Closeable] for you to close the websocket once you’re done with streaming. + */ fun localPublic( onlyMedia: Boolean, callback: WebSocketCallback @@ -355,6 +370,13 @@ class StreamingMethods(private val client: MastodonClient) { ) } + /** + * Stream all public posts originating from other servers. + * + * @param onlyMedia Filter for media attachments. + * @param callback Your implementation of [WebSocketCallback] to receive stream of [WebSocketEvent]s. + * @return [Closeable] for you to close the websocket once you’re done with streaming. + */ fun remotePublic( onlyMedia: Boolean, callback: WebSocketCallback @@ -365,6 +387,14 @@ class StreamingMethods(private val client: MastodonClient) { ) } + /** + * Stream all public posts using the hashtag [tagName]. + * + * @param tagName Hashtag the public posts you want to stream should have. + * @param onlyFromThisServer Filter for public posts originating from this server. + * @param callback Your implementation of [WebSocketCallback] to receive stream of [WebSocketEvent]s. + * @return [Closeable] for you to close the websocket once you’re done with streaming. + */ fun hashtag( tagName: String, onlyFromThisServer: Boolean, @@ -377,6 +407,12 @@ class StreamingMethods(private val client: MastodonClient) { ) } + /** + * Stream all events related to the current user, such as home feed updates and notifications. + * + * @param callback Your implementation of [WebSocketCallback] to receive stream of [WebSocketEvent]s. + * @return [Closeable] for you to close the websocket once you’re done with streaming. + */ fun user(callback: WebSocketCallback): Closeable { return stream( streamType = USER, @@ -384,6 +420,12 @@ class StreamingMethods(private val client: MastodonClient) { ) } + /** + * Stream all notifications for the current user. + * + * @param callback Your implementation of [WebSocketCallback] to receive stream of [WebSocketEvent]s. + * @return [Closeable] for you to close the websocket once you’re done with streaming. + */ fun userNotifications(callback: WebSocketCallback): Closeable { return stream( streamType = USER_NOTIFICATION, @@ -391,6 +433,13 @@ class StreamingMethods(private val client: MastodonClient) { ) } + /** + * Stream updates to the list with [listId]. + * + * @param listId List you want to receive updates for. + * @param callback Your implementation of [WebSocketCallback] to receive stream of [WebSocketEvent]s. + * @return [Closeable] for you to close the websocket once you’re done with streaming. + */ fun list( listId: String, callback: WebSocketCallback @@ -402,11 +451,16 @@ class StreamingMethods(private val client: MastodonClient) { ) } + /** + * Stream all updates to direct conversations. + * + * @param callback Your implementation of [WebSocketCallback] to receive stream of [WebSocketEvent]s. + * @return [Closeable] for you to close the websocket once you’re done with streaming. + */ fun directConversations(callback: WebSocketCallback): Closeable { return stream( streamType = DIRECT, callback = callback ) } - } From a1825f7a6e521d16b940f426fcf37c41b3170562 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 02:08:07 +0100 Subject: [PATCH 05/28] Remove obsolete streaming methods --- .../kotlin/social/bigbone/api/Dispatcher.kt | 33 --- .../main/kotlin/social/bigbone/api/Handler.kt | 18 -- .../kotlin/social/bigbone/api/Shutdownable.kt | 21 -- .../bigbone/api/method/StreamingMethods.kt | 268 ------------------ .../sample/StreamFederatedPublicTimeline.java | 38 +-- 5 files changed, 12 insertions(+), 366 deletions(-) delete mode 100644 bigbone/src/main/kotlin/social/bigbone/api/Dispatcher.kt delete mode 100644 bigbone/src/main/kotlin/social/bigbone/api/Handler.kt delete mode 100644 bigbone/src/main/kotlin/social/bigbone/api/Shutdownable.kt diff --git a/bigbone/src/main/kotlin/social/bigbone/api/Dispatcher.kt b/bigbone/src/main/kotlin/social/bigbone/api/Dispatcher.kt deleted file mode 100644 index 4d8204fef..000000000 --- a/bigbone/src/main/kotlin/social/bigbone/api/Dispatcher.kt +++ /dev/null @@ -1,33 +0,0 @@ -package social.bigbone.api - -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -/** - * Dispatcher is used by the streaming methods (e.g. [social.bigbone.api.method.StreamingMethods.federatedPublic]. - * It maintains an executor service that is used to process incoming data. - */ -class Dispatcher { - private val executorService: ExecutorService = Executors.newFixedThreadPool(1) { r -> - val thread = Thread(r) - thread.isDaemon = true - return@newFixedThreadPool thread - } - - private val lock = ReentrantLock() - private val shutdownTime = 1000L - - fun invokeLater(task: Runnable) = executorService.execute(task) - - fun shutdown() { - lock.withLock { - executorService.shutdown() - if (!executorService.awaitTermination(shutdownTime, TimeUnit.MILLISECONDS)) { - executorService.shutdownNow() - } - } - } -} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/Handler.kt b/bigbone/src/main/kotlin/social/bigbone/api/Handler.kt deleted file mode 100644 index 76828a513..000000000 --- a/bigbone/src/main/kotlin/social/bigbone/api/Handler.kt +++ /dev/null @@ -1,18 +0,0 @@ -package social.bigbone.api - -import social.bigbone.api.entity.Notification -import social.bigbone.api.entity.Status - -/** - * Used to implement a handler for streaming endpoints (e.g. [social.bigbone.api.method.StreamingMethods.federatedPublic]). - */ -interface Handler { - - fun onStatus(status: Status) - - // ignore if public streaming - fun onNotification(notification: Notification) - - // ignore if public streaming - fun onDelete(id: String) -} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/Shutdownable.kt b/bigbone/src/main/kotlin/social/bigbone/api/Shutdownable.kt deleted file mode 100644 index 0a2f8090e..000000000 --- a/bigbone/src/main/kotlin/social/bigbone/api/Shutdownable.kt +++ /dev/null @@ -1,21 +0,0 @@ -package social.bigbone.api - -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -/** - * Shutdownable is used by streaming endpoints, such as [social.bigbone.api.method.StreamingMethods.federatedPublic]. It - * gives the caller a way of closing the stream, by calling the [shutdown] method. - */ -class Shutdownable(private val dispatcher: Dispatcher) { - private val lock = ReentrantLock() - - /** - * Close the current stream. - */ - fun shutdown() { - lock.withLock { - dispatcher.shutdown() - } - } -} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index f48016ca1..15ecdb39a 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -1,14 +1,9 @@ package social.bigbone.api.method -import social.bigbone.JSON_SERIALIZER import social.bigbone.MastodonClient import social.bigbone.Parameters -import social.bigbone.api.Dispatcher -import social.bigbone.api.Handler -import social.bigbone.api.Shutdownable import social.bigbone.api.WebSocketCallback import social.bigbone.api.WebSocketEvent -import social.bigbone.api.entity.Status import social.bigbone.api.entity.streaming.StreamType import social.bigbone.api.entity.streaming.StreamType.DIRECT import social.bigbone.api.entity.streaming.StreamType.HASHTAG @@ -22,7 +17,6 @@ import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE_MEDIA import social.bigbone.api.entity.streaming.StreamType.USER import social.bigbone.api.entity.streaming.StreamType.USER_NOTIFICATION -import social.bigbone.api.exception.BigBoneRequestException import java.io.Closeable /** @@ -30,268 +24,6 @@ import java.io.Closeable * @see Mastodon streaming API methods */ class StreamingMethods(private val client: MastodonClient) { - @Throws(BigBoneRequestException::class) - fun federatedPublic(handler: Handler): Shutdownable { - val response = client.get("api/v1/streaming/public") - if (response.isSuccessful) { - val reader = response.body?.byteStream()?.bufferedReader() - val dispatcher = Dispatcher() - dispatcher.invokeLater { - while (true) { - try { - val line = reader?.readLine() - if (line == null || line.isEmpty()) { - continue - } - val type = line.split(":")[0].trim() - if (type != "event") { - continue - } - val event = line.split(":")[1].trim() - val payload = reader.readLine() - val payloadType = payload.split(":")[0].trim() - if (payloadType != "data") { - continue - } - if (event == "update") { - val json = payload.substringAfter(':').trim() - handler.onStatus(status = JSON_SERIALIZER.decodeFromString(json)) - } - } catch (e: java.io.InterruptedIOException) { - break - } - } - reader.close() - } - return Shutdownable(dispatcher) - } else { - throw BigBoneRequestException(response) - } - } - - @Throws(BigBoneRequestException::class) - fun localPublic(handler: Handler): Shutdownable { - val response = client.get("api/v1/streaming/public/local") - if (response.isSuccessful) { - val reader = response.body?.byteStream()?.bufferedReader() - val dispatcher = Dispatcher() - dispatcher.invokeLater { - while (true) { - try { - val line = reader?.readLine() - if (line == null || line.isEmpty()) { - continue - } - val type = line.split(":")[0].trim() - if (type != "event") { - continue - } - val event = line.split(":")[1].trim() - val payload = reader.readLine() - val payloadType = payload.split(":")[0].trim() - if (payloadType != "data") { - continue - } - if (event == "update") { - val json = payload.substringAfter(':').trim() - handler.onStatus(status = JSON_SERIALIZER.decodeFromString(json)) - } - } catch (e: java.io.InterruptedIOException) { - break - } - } - reader.close() - } - return Shutdownable(dispatcher) - } else { - throw BigBoneRequestException(response) - } - } - - @Throws(BigBoneRequestException::class) - fun federatedHashtag(tag: String, handler: Handler): Shutdownable { - val response = client.get( - "api/v1/streaming/hashtag", - Parameters().append("tag", tag) - ) - if (response.isSuccessful) { - val reader = response.body?.byteStream()?.bufferedReader() - val dispatcher = Dispatcher() - dispatcher.invokeLater { - while (true) { - try { - val line = reader?.readLine() - if (line == null || line.isEmpty()) { - continue - } - val type = line.split(":")[0].trim() - if (type != "event") { - continue - } - val event = line.split(":")[1].trim() - val payload = reader.readLine() - val payloadType = payload.split(":")[0].trim() - if (payloadType != "data") { - continue - } - if (event == "update") { - val json = payload.substringAfter(':').trim() - val status: Status = JSON_SERIALIZER.decodeFromString(json) - handler.onStatus(status) - } - } catch (e: java.io.InterruptedIOException) { - break - } - } - reader.close() - } - return Shutdownable(dispatcher) - } else { - throw BigBoneRequestException(response) - } - } - - @Throws(BigBoneRequestException::class) - fun localHashtag(tag: String, handler: Handler): Shutdownable { - val response = client.get( - "api/v1/streaming/hashtag/local", - Parameters().append("tag", tag) - ) - if (response.isSuccessful) { - val reader = response.body?.byteStream()?.bufferedReader() - val dispatcher = Dispatcher() - dispatcher.invokeLater { - while (true) { - try { - val line = reader?.readLine() - if (line == null || line.isEmpty()) { - continue - } - val type = line.split(":")[0].trim() - if (type != "event") { - continue - } - val event = line.split(":")[1].trim() - val payload = reader.readLine() - val payloadType = payload.split(":")[0].trim() - if (payloadType != "data") { - continue - } - if (event == "update") { - val json = payload.substringAfter(':').trim() - handler.onStatus(status = JSON_SERIALIZER.decodeFromString(json)) - } - } catch (e: java.io.InterruptedIOException) { - break - } - } - reader.close() - } - return Shutdownable(dispatcher) - } else { - throw BigBoneRequestException(response) - } - } - - @Throws(BigBoneRequestException::class) - fun user(handler: Handler): Shutdownable { - val response = client.get( - "api/v1/streaming/user" - ) - if (response.isSuccessful) { - val reader = response.body?.byteStream()?.bufferedReader() - val dispatcher = Dispatcher() - dispatcher.invokeLater { - while (true) { - try { - val line = reader?.readLine() - if (line == null || line.isEmpty()) { - continue - } - val type = line.split(":")[0].trim() - if (type != "event") { - continue - } - val event = line.split(":")[1].trim() - val payload = reader.readLine() - val payloadType = payload.split(":")[0].trim() - if (payloadType != "data") { - continue - } - - val json = payload.substringAfter(':').trim() - if (event == "update") { - handler.onStatus(status = JSON_SERIALIZER.decodeFromString(json)) - } - if (event == "notification") { - handler.onNotification(notification = JSON_SERIALIZER.decodeFromString(json)) - } - if (event == "delete") { - handler.onDelete(id = JSON_SERIALIZER.decodeFromString(json)) - } - } catch (e: java.io.InterruptedIOException) { - break - } - } - reader.close() - } - return Shutdownable(dispatcher) - } else { - throw BigBoneRequestException(response) - } - } - - @Throws(BigBoneRequestException::class) - fun userList(handler: Handler, listID: String): Shutdownable { - val response = client.get( - "api/v1/streaming/list", - Parameters().apply { - append("list", listID) - } - ) - if (response.isSuccessful) { - val reader = response.body?.byteStream()?.bufferedReader() - val dispatcher = Dispatcher() - dispatcher.invokeLater { - while (true) { - try { - val line = reader?.readLine() - if (line == null || line.isEmpty()) { - continue - } - val type = line.split(":")[0].trim() - if (type != "event") { - continue - } - val event = line.split(":")[1].trim() - val payload = reader.readLine() - val payloadType = payload.split(":")[0].trim() - if (payloadType != "data") { - continue - } - - val start = payload.indexOf(":") + 1 - val json = payload.substring(start).trim() - if (event == "update") { - handler.onStatus(status = JSON_SERIALIZER.decodeFromString(json)) - } - if (event == "notification") { - handler.onNotification(notification = JSON_SERIALIZER.decodeFromString(json)) - } - if (event == "delete") { - handler.onDelete(id = JSON_SERIALIZER.decodeFromString(json)) - } - } catch (e: java.io.InterruptedIOException) { - break - } - } - reader.close() - } - return Shutdownable(dispatcher) - } else { - throw BigBoneRequestException(response) - } - } private fun stream( streamType: StreamType, diff --git a/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java b/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java index 1011110bd..24ced62b3 100644 --- a/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java +++ b/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java @@ -1,15 +1,13 @@ package social.bigbone.sample; -import org.jetbrains.annotations.NotNull; import social.bigbone.MastodonClient; -import social.bigbone.api.Handler; -import social.bigbone.api.Shutdownable; -import social.bigbone.api.entity.Notification; -import social.bigbone.api.entity.Status; import social.bigbone.api.exception.BigBoneRequestException; +import java.io.Closeable; +import java.io.IOException; + public class StreamFederatedPublicTimeline { - public static void main(final String[] args) throws BigBoneRequestException, InterruptedException { + public static void main(final String[] args) throws BigBoneRequestException, InterruptedException, IOException { final String instance = args[0]; final String accessToken = args[1]; @@ -19,27 +17,15 @@ public static void main(final String[] args) throws BigBoneRequestException, Int .useStreamingApi() .build(); - // Configure status handler - final Handler handler = new Handler() { - @Override - public void onStatus(@NotNull final Status status) { - System.out.println(status.getContent()); - } - - @Override - public void onNotification(@NotNull final Notification notification) { - // No op - } - - @Override - public void onDelete(@NotNull final String id) { - // No op - } - }; // Start federated timeline streaming and stop after 20 seconds - final Shutdownable shutdownable = client.streaming().federatedPublic(handler); - Thread.sleep(20_000L); - shutdownable.shutdown(); + try (Closeable ignored = client + .streaming() + .federatedPublic( + false, + System.out::println + )) { + Thread.sleep(20_000L); + } } } From b85ffe20f92ba66e904d8536511f1eaf06a237ea Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 02:12:53 +0100 Subject: [PATCH 06/28] Update documentation --- USAGE.md | 45 +++++-------------- .../sample/StreamFederatedPublicTimeline.java | 8 +--- 2 files changed, 12 insertions(+), 41 deletions(-) diff --git a/USAGE.md b/USAGE.md index 17cac3e07..0be3b2c0b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -208,8 +208,6 @@ public class GetRawJson { ## Streaming API -v1.0.0 or later - __Kotlin__ ```kotlin @@ -218,47 +216,26 @@ val client: MastodonClient = MastodonClient.Builder(instanceHostname) .useStreamingApi() .build() -val handler = object : Handler { - override fun onStatus(status: Status) { - println(status.content) - } - override fun onNotification(notification: Notification) {/* no op */} - override fun onDelete(id: String) {/* no op */} -} - -try { - val shutdownable = client.streaming.localPublic(handler) - Thread.sleep(10000L) - shutdownable.shutdown() -} catch(e: BigBoneRequestException) { - e.printStackTrace() +client.streaming.federatedPublic( + onlyMedia = false, + callback = { event: WebSocketEvent -> + println(event) + } +).use { + Thread.sleep(15_000L) } ``` __Java__ ```java -MastodonClient client = new MastodonClient.Builder(instanceHostname) +final MastodonClient client = new MastodonClient.Builder(instanceHostname) .accessToken(accessToken) .useStreamingApi() .build(); -Handler handler = new Handler() { - @Override - public void onStatus(@NotNull Status status) { - System.out.println(status.getContent()); - } - @Override - public void onNotification(@NotNull Notification notification) {/* no op */} - @Override - public void onDelete(String id) {/* no op */} -}; - -try { - Shutdownable shutdownable = client.streaming().localPublic(handler); - Thread.sleep(10000L); - shutdownable.shutdown(); -} catch (Exception e) { - e.printStackTrace(); +// Start federated timeline streaming and stop after 20 seconds +try (Closeable ignored = client.streaming().federatedPublic(false, System.out::println)) { + Thread.sleep(20_000L); } ``` diff --git a/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java b/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java index 24ced62b3..3eb7b688a 100644 --- a/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java +++ b/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java @@ -17,14 +17,8 @@ public static void main(final String[] args) throws BigBoneRequestException, Int .useStreamingApi() .build(); - // Start federated timeline streaming and stop after 20 seconds - try (Closeable ignored = client - .streaming() - .federatedPublic( - false, - System.out::println - )) { + try (Closeable ignored = client.streaming().federatedPublic(false, System.out::println)) { Thread.sleep(20_000L); } } From 2711183911a87dd27a97b45f70256f8b1a29ca50 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 13:53:37 +0100 Subject: [PATCH 07/28] Propagate messages with binary string and those that cannot be parsed --- .../social/bigbone/rx/RxStreamingMethods.kt | 3 ++- .../kotlin/social/bigbone/MastodonClient.kt | 21 +++++++++++++++---- .../social/bigbone/api/WebSocketCallback.kt | 6 ++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index c23bae38f..da62c6677 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -117,7 +117,8 @@ class RxStreamingMethods(client: MastodonClient) { is WebSocketEvent.Failure -> tryOnError(webSocketEvent.error) WebSocketEvent.Open, is WebSocketEvent.Closing, - is WebSocketEvent.StreamEvent -> onNext(webSocketEvent) + is WebSocketEvent.StreamEvent, + is WebSocketEvent.GenericMessage -> onNext(webSocketEvent) } } } diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 61c2db130..24c78677c 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -530,7 +530,6 @@ private constructor( port = port, path = path, query = query - ) ) .build(), @@ -552,13 +551,27 @@ private constructor( override fun onMessage(webSocket: WebSocket, text: String) { super.onMessage(webSocket, text) - val event = JSON_SERIALIZER.decodeFromString(text) - callback.onEvent(WebSocketEvent.StreamEvent(event = event)) + // We should usually be able to decode WebSocket messages as an [Event] type but if that fails, + // we return the text received in this message verbatim via the [GenericMessage] type. + try { + val event = JSON_SERIALIZER.decodeFromString(text) + callback.onEvent(WebSocketEvent.StreamEvent(event = event)) + } catch (e: IllegalArgumentException) { + callback.onEvent(WebSocketEvent.GenericMessage(text)) + } } override fun onMessage(webSocket: WebSocket, bytes: ByteString) { super.onMessage(webSocket, bytes) - println("onMessage with bytes $bytes") + // We should usually be able to decode WebSocket messages as an [Event] type but if that fails, + // we return the text received in this message verbatim via the [GenericMessage] type. + val bytesAsString: String = bytes.utf8() + try { + val event = JSON_SERIALIZER.decodeFromString(bytesAsString) + callback.onEvent(WebSocketEvent.StreamEvent(event = event)) + } catch (e: IllegalArgumentException) { + callback.onEvent(WebSocketEvent.GenericMessage(bytesAsString)) + } } override fun onOpen(webSocket: WebSocket, response: Response) { diff --git a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt index 72b842783..98d637436 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt @@ -53,6 +53,12 @@ sealed interface WebSocketEvent { */ data class StreamEvent(val event: Event) : WebSocketEvent + /** + * A message received via the websocket that could not be parsed to an [Event]. + * Instead of [StreamEvent], an object of this type with the [text] received verbatim is returned. + */ + data class GenericMessage(val text: String) : WebSocketEvent + /** * An error occurred during the websocket connection. * This is a final event: No further calls to the [WebSocketCallback] emitting these events From 35cefa533901b87c3121a6ccdebb8fb3c22e716a Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 14:14:46 +0100 Subject: [PATCH 08/28] Remove unnecessarily duplicated code --- .../social/bigbone/rx/RxStreamingMethods.kt | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index da62c6677..8bf71c27c 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -23,7 +23,7 @@ class RxStreamingMethods(client: MastodonClient) { * * @param onlyMedia Filter for media attachments. Analogous to the federated timeline with “only media” enabled. */ - fun federatedPublic(onlyMedia: Boolean): Flowable = streamTimeline { + fun federatedPublic(onlyMedia: Boolean): Flowable = stream { streamingMethods.federatedPublic(onlyMedia, it) } @@ -32,7 +32,7 @@ class RxStreamingMethods(client: MastodonClient) { * * @param onlyMedia Filter for media attachments. Analogous to the local timeline with “only media” enabled. */ - fun localPublic(onlyMedia: Boolean): Flowable = streamTimeline { + fun localPublic(onlyMedia: Boolean): Flowable = stream { streamingMethods.localPublic(onlyMedia, it) } @@ -41,7 +41,7 @@ class RxStreamingMethods(client: MastodonClient) { * * @param onlyMedia Filter for media attachments. */ - fun remotePublic(onlyMedia: Boolean): Flowable = streamTimeline { + fun remotePublic(onlyMedia: Boolean): Flowable = stream { streamingMethods.remotePublic(onlyMedia, it) } @@ -54,7 +54,7 @@ class RxStreamingMethods(client: MastodonClient) { fun hashtag( tagName: String, onlyFromThisServer: Boolean - ): Flowable = streamTag { callback -> + ): Flowable = stream { callback -> streamingMethods.hashtag( tagName = tagName, onlyFromThisServer = onlyFromThisServer, @@ -65,19 +65,19 @@ class RxStreamingMethods(client: MastodonClient) { /** * Stream all events related to the current user, such as home feed updates and notifications. */ - fun user(): Flowable = streamTimeline(streamingMethods::user) + fun user(): Flowable = stream(streamingMethods::user) /** * Stream all notifications for the current user. */ - fun userNotifications(): Flowable = streamTimeline(streamingMethods::userNotifications) + fun userNotifications(): Flowable = stream(streamingMethods::userNotifications) /** * Stream updates to the list with [listId]. * * @param listId List you want to receive updates for. */ - fun list(listId: String): Flowable = streamList { callback -> + fun list(listId: String): Flowable = stream { callback -> streamingMethods.list( listId = listId, callback = callback @@ -87,28 +87,13 @@ class RxStreamingMethods(client: MastodonClient) { /** * Stream all updates to direct conversations. */ - fun directConversations(): Flowable = streamTimeline(streamingMethods::directConversations) + fun directConversations(): Flowable = stream(streamingMethods::directConversations) - private fun streamTimeline(streamMethod: (WebSocketCallback) -> Closeable): Flowable { - return Flowable.create({ emitter -> + private fun stream(streamMethod: (WebSocketCallback) -> Closeable): Flowable = + Flowable.create({ emitter -> val closeable = streamMethod(emitter.fromWebSocketCallback()) emitter.setCancellable(closeable::close) }, BackpressureStrategy.BUFFER) - } - - private fun streamList(streamMethod: (WebSocketCallback) -> Closeable): Flowable { - return Flowable.create({ emitter -> - val closeable = streamMethod(emitter.fromWebSocketCallback()) - emitter.setCancellable(closeable::close) - }, BackpressureStrategy.BUFFER) - } - - private fun streamTag(streamMethod: (WebSocketCallback) -> Closeable): Flowable { - return Flowable.create({ emitter -> - val closeable = streamMethod(emitter.fromWebSocketCallback()) - emitter.setCancellable(closeable::close) - }, BackpressureStrategy.BUFFER) - } private fun FlowableEmitter.fromWebSocketCallback(): (event: WebSocketEvent) -> Unit = { webSocketEvent -> From afad08a59eecf9d0cd7c2b619bbd54ca6a91ee41 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 15:56:15 +0100 Subject: [PATCH 09/28] Differentiate between technical and API events --- .../social/bigbone/rx/RxStreamingMethods.kt | 31 +++---- .../kotlin/social/bigbone/MastodonClient.kt | 23 +++--- .../social/bigbone/api/WebSocketCallback.kt | 81 ++++++++++--------- .../bigbone/sample/RxStreamPublicTimeline.kt | 9 ++- 4 files changed, 84 insertions(+), 60 deletions(-) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index 8bf71c27c..4c609c9f3 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -2,8 +2,13 @@ package social.bigbone.rx import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.FlowableEmitter import social.bigbone.MastodonClient +import social.bigbone.api.Closed +import social.bigbone.api.Closing +import social.bigbone.api.Failure +import social.bigbone.api.GenericMessage +import social.bigbone.api.Open +import social.bigbone.api.StreamEvent import social.bigbone.api.WebSocketCallback import social.bigbone.api.WebSocketEvent import social.bigbone.api.method.StreamingMethods @@ -91,19 +96,17 @@ class RxStreamingMethods(client: MastodonClient) { private fun stream(streamMethod: (WebSocketCallback) -> Closeable): Flowable = Flowable.create({ emitter -> - val closeable = streamMethod(emitter.fromWebSocketCallback()) - emitter.setCancellable(closeable::close) + val closeable = streamMethod { webSocketEvent -> + when (webSocketEvent) { + is Closed -> emitter.onComplete() + is Failure -> emitter.tryOnError(webSocketEvent.error) + Open, + is Closing, + is StreamEvent, + is GenericMessage -> emitter.onNext(webSocketEvent) + } + } + emitter.setCancellable { closeable.close() } }, BackpressureStrategy.BUFFER) - private fun FlowableEmitter.fromWebSocketCallback(): (event: WebSocketEvent) -> Unit = - { webSocketEvent -> - when (webSocketEvent) { - is WebSocketEvent.Closed -> onComplete() - is WebSocketEvent.Failure -> tryOnError(webSocketEvent.error) - WebSocketEvent.Open, - is WebSocketEvent.Closing, - is WebSocketEvent.StreamEvent, - is WebSocketEvent.GenericMessage -> onNext(webSocketEvent) - } - } } diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 24c78677c..65fc75be9 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -11,9 +11,14 @@ import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.ByteString +import social.bigbone.api.Closed +import social.bigbone.api.Closing +import social.bigbone.api.Failure +import social.bigbone.api.GenericMessage +import social.bigbone.api.Open import social.bigbone.api.Pageable +import social.bigbone.api.StreamEvent import social.bigbone.api.WebSocketCallback -import social.bigbone.api.WebSocketEvent import social.bigbone.api.entity.data.InstanceVersion import social.bigbone.api.entity.streaming.Event import social.bigbone.api.exception.BigBoneClientInstantiationException @@ -536,17 +541,17 @@ private constructor( listener = object : WebSocketListener() { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { super.onClosed(webSocket, code, reason) - callback.onEvent(WebSocketEvent.Closed(code, reason)) + callback.onEvent(Closed(code, reason)) } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { super.onClosing(webSocket, code, reason) - callback.onEvent(WebSocketEvent.Closing(code, reason)) + callback.onEvent(Closing(code, reason)) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { super.onFailure(webSocket, t, response) - callback.onEvent(WebSocketEvent.Failure(t)) + callback.onEvent(Failure(t)) } override fun onMessage(webSocket: WebSocket, text: String) { @@ -555,9 +560,9 @@ private constructor( // we return the text received in this message verbatim via the [GenericMessage] type. try { val event = JSON_SERIALIZER.decodeFromString(text) - callback.onEvent(WebSocketEvent.StreamEvent(event = event)) + callback.onEvent(StreamEvent(event = event)) } catch (e: IllegalArgumentException) { - callback.onEvent(WebSocketEvent.GenericMessage(text)) + callback.onEvent(GenericMessage(text)) } } @@ -568,15 +573,15 @@ private constructor( val bytesAsString: String = bytes.utf8() try { val event = JSON_SERIALIZER.decodeFromString(bytesAsString) - callback.onEvent(WebSocketEvent.StreamEvent(event = event)) + callback.onEvent(StreamEvent(event = event)) } catch (e: IllegalArgumentException) { - callback.onEvent(WebSocketEvent.GenericMessage(bytesAsString)) + callback.onEvent(GenericMessage(bytesAsString)) } } override fun onOpen(webSocket: WebSocket, response: Response) { super.onOpen(webSocket, response) - callback.onEvent(WebSocketEvent.Open) + callback.onEvent(Open) } } ) diff --git a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt index 98d637436..44ec02e07 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt @@ -1,6 +1,5 @@ package social.bigbone.api -import social.bigbone.api.WebSocketEvent.StreamEvent import social.bigbone.api.entity.streaming.Event import social.bigbone.api.method.StreamingMethods @@ -25,44 +24,54 @@ fun interface WebSocketCallback { * by the Mastodon API when streaming via a websocket connection. * All others are more technical events pertaining to the websocket connection itself. */ -sealed interface WebSocketEvent { +sealed interface WebSocketEvent - /** - * The websocket has been opened. A connection is established. - */ - data object Open : WebSocketEvent +/** + * Technical events that occurred during websocket connection, such as [Open] or [Closed]. + */ +sealed interface TechnicalEvent : WebSocketEvent - /** - * The websocket is about to close. - */ - data class Closing( - val code: Int, - val reason: String - ) : WebSocketEvent +/** + * (Ideally parsed) event received through the websocket from the Mastodon server. + * In most situations, this—and specifically [StreamEvent]—is likely what you’re looking for when streaming. + */ +sealed interface MastodonApiEvent : WebSocketEvent - /** - * The websocket is now closed. - */ - data class Closed( - val code: Int, - val reason: String - ) : WebSocketEvent +/** + * The websocket has been opened. A connection is established. + */ +data object Open : TechnicalEvent - /** - * An event from the Mastodon API has been received via the websocket. - */ - data class StreamEvent(val event: Event) : WebSocketEvent +/** + * The websocket is about to close. + */ +data class Closing( + val code: Int, + val reason: String +) : TechnicalEvent - /** - * A message received via the websocket that could not be parsed to an [Event]. - * Instead of [StreamEvent], an object of this type with the [text] received verbatim is returned. - */ - data class GenericMessage(val text: String) : WebSocketEvent +/** + * The websocket is now closed. + */ +data class Closed( + val code: Int, + val reason: String +) : TechnicalEvent - /** - * An error occurred during the websocket connection. - * This is a final event: No further calls to the [WebSocketCallback] emitting these events - * will be made. - */ - data class Failure(val error: Throwable) : WebSocketEvent -} +/** + * An event from the Mastodon API has been received via the websocket. + */ +data class StreamEvent(val event: Event) : MastodonApiEvent + +/** + * A message received via the websocket that could not be parsed to an [Event]. + * Instead of [StreamEvent], an object of this type with the [text] received verbatim is returned. + */ +data class GenericMessage(val text: String) : MastodonApiEvent + +/** + * An error occurred during the websocket connection. + * This is a final event: + * No further calls to the [WebSocketCallback] emitting these events will be made. + */ +data class Failure(val error: Throwable) : TechnicalEvent diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt index b7ce1529b..20b7c4810 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt @@ -2,6 +2,8 @@ package social.bigbone.sample import io.reactivex.rxjava3.schedulers.Schedulers import social.bigbone.MastodonClient +import social.bigbone.api.MastodonApiEvent +import social.bigbone.api.TechnicalEvent import social.bigbone.rx.RxStreamingMethods import java.time.Duration @@ -24,7 +26,12 @@ object RxStreamPublicTimeline { ) .subscribeOn(Schedulers.io()) .subscribe( - /* onNext = */ ::println, + /* onNext = */ { + when (it) { + is TechnicalEvent -> println("Technical event: $it") + is MastodonApiEvent -> println("Mastodon API event: $it") + } + }, /* onError = */ ::println, /* onComplete = */ { println("onComplete") } ) From 264041d3df4b3567fecc2c530d59493a9801fe39 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 16:55:30 +0100 Subject: [PATCH 10/28] Try to parse EventType-specific data classes from raw stream events --- .../social/bigbone/rx/RxStreamingMethods.kt | 1 - .../kotlin/social/bigbone/MastodonClient.kt | 13 +- .../social/bigbone/api/WebSocketCallback.kt | 9 +- .../bigbone/api/entity/streaming/EventType.kt | 2 +- .../api/entity/streaming/ParsedStreamEvent.kt | 138 ++++++++++++++++++ .../streaming/{Event.kt => RawStreamEvent.kt} | 10 +- .../api/entity/streaming/StreamType.kt | 2 +- .../sample/StreamFederatedPublicTimeline.kt | 18 ++- 8 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt rename bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/{Event.kt => RawStreamEvent.kt} (66%) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index 4c609c9f3..bf6fe57fa 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -108,5 +108,4 @@ class RxStreamingMethods(client: MastodonClient) { } emitter.setCancellable { closeable.close() } }, BackpressureStrategy.BUFFER) - } diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 65fc75be9..1a4e4305f 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -20,7 +20,8 @@ import social.bigbone.api.Pageable import social.bigbone.api.StreamEvent import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.data.InstanceVersion -import social.bigbone.api.entity.streaming.Event +import social.bigbone.api.entity.streaming.ParsedStreamEvent.Companion.toStreamEvent +import social.bigbone.api.entity.streaming.RawStreamEvent import social.bigbone.api.exception.BigBoneClientInstantiationException import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.api.exception.InstanceVersionRetrievalException @@ -559,8 +560,9 @@ private constructor( // We should usually be able to decode WebSocket messages as an [Event] type but if that fails, // we return the text received in this message verbatim via the [GenericMessage] type. try { - val event = JSON_SERIALIZER.decodeFromString(text) - callback.onEvent(StreamEvent(event = event)) + val rawEvent: RawStreamEvent = JSON_SERIALIZER.decodeFromString(text) + val streamEvent = rawEvent.toStreamEvent() + callback.onEvent(StreamEvent(event = streamEvent)) } catch (e: IllegalArgumentException) { callback.onEvent(GenericMessage(text)) } @@ -572,8 +574,9 @@ private constructor( // we return the text received in this message verbatim via the [GenericMessage] type. val bytesAsString: String = bytes.utf8() try { - val event = JSON_SERIALIZER.decodeFromString(bytesAsString) - callback.onEvent(StreamEvent(event = event)) + val rawEvent = JSON_SERIALIZER.decodeFromString(bytesAsString) + val streamEvent = rawEvent.toStreamEvent() + callback.onEvent(StreamEvent(event = streamEvent)) } catch (e: IllegalArgumentException) { callback.onEvent(GenericMessage(bytesAsString)) } diff --git a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt index 44ec02e07..29a029c4b 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt @@ -1,6 +1,7 @@ package social.bigbone.api -import social.bigbone.api.entity.streaming.Event +import social.bigbone.api.entity.streaming.EventType +import social.bigbone.api.entity.streaming.ParsedStreamEvent import social.bigbone.api.method.StreamingMethods /** @@ -60,11 +61,13 @@ data class Closed( /** * An event from the Mastodon API has been received via the websocket. + * + * @property event The parsed stream event. May be null if we got an [EventType] we don’t know yet. */ -data class StreamEvent(val event: Event) : MastodonApiEvent +data class StreamEvent(val event: ParsedStreamEvent?) : MastodonApiEvent /** - * A message received via the websocket that could not be parsed to an [Event]. + * A message received via the websocket that could not be parsed to a [ParsedStreamEvent]. * Instead of [StreamEvent], an object of this type with the [text] received verbatim is returned. */ data class GenericMessage(val text: String) : MastodonApiEvent diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt index 32599f5af..0a9ca294e 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Event types that can be received as part of [Event] when using streaming APIs. + * Event types that can be received as part of [RawStreamEvent] when using streaming APIs. * @see Mastodon streaming#events entities */ @Serializable diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt new file mode 100644 index 000000000..715057db7 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt @@ -0,0 +1,138 @@ +package social.bigbone.api.entity.streaming + +import kotlinx.serialization.json.Json +import social.bigbone.JSON_SERIALIZER +import social.bigbone.api.entity.Announcement +import social.bigbone.api.entity.Conversation +import social.bigbone.api.entity.Notification +import social.bigbone.api.entity.Status +import social.bigbone.api.entity.streaming.EventType.ANNOUNCEMENT +import social.bigbone.api.entity.streaming.EventType.ANNOUNCEMENT_DELETE +import social.bigbone.api.entity.streaming.EventType.ANNOUNCEMENT_REACTION +import social.bigbone.api.entity.streaming.EventType.CONVERSATION +import social.bigbone.api.entity.streaming.EventType.DELETE +import social.bigbone.api.entity.streaming.EventType.ENCRYPTED_MESSAGE +import social.bigbone.api.entity.streaming.EventType.FILTERS_CHANGED +import social.bigbone.api.entity.streaming.EventType.NOTIFICATION +import social.bigbone.api.entity.streaming.EventType.STATUS_UPDATE +import social.bigbone.api.entity.streaming.EventType.UPDATE + +/** + * Stream events emitted by the Mastodon API websocket stream for each [EventType] that can potentially occur. + * This is the parsed variant of the [RawStreamEvent.eventType] and [RawStreamEvent.payload] + * turned into easily usable data classes and objects for type-specific consumption. + */ +sealed interface ParsedStreamEvent { + + /** + * A new Status has appeared. + * Payload contains a Status cast to a string. + * Available since v1.0.0 + */ + data class StatusCreated(val createdStatus: Status) : ParsedStreamEvent + + /** + * A Status has been edited. + * Available since v3.5.0 + */ + data class StatusEdited(val editedStatus: Status) : ParsedStreamEvent + + /** + * A status has been deleted. + * Available since v1.0.0 + */ + data class StatusDeleted(val deletedStatusId: String) : ParsedStreamEvent + + /** + * A new notification has appeared. + * Available since v1.4.2 + */ + data class NewNotification(val newNotification: Notification) : ParsedStreamEvent + + /** + * Keyword filters have been changed. + * Does not contain a payload for WebSocket connections. + * Available since v2.4.3 + */ + data object FiltersChanged : ParsedStreamEvent + + /** + * A direct conversation has been updated. + * Available since v2.6.0 + */ + data class ConversationUpdated(val updatedConversation: Conversation) : ParsedStreamEvent + + /** + * An announcement has been published. + * Available since v3.1.0 + */ + data class AnnouncementPublished(val publishedAnnouncement: Announcement) : ParsedStreamEvent + + /** + * An announcement has received an emoji reaction. + * Available since v3.1.0 + */ + data class AnnouncementReactionReceived(val reactionPayload: String) : ParsedStreamEvent + + /** + * An announcement has been deleted. + * Available since v3.1.0 + */ + data class AnnouncementDeleted(val deletedAnnouncementId: String) : ParsedStreamEvent + + /** + * An encrypted message has been received. + * Implemented in v3.2.0 but currently unused + */ + data object EncryptedMessageReceived : ParsedStreamEvent + + companion object { + internal fun RawStreamEvent.toStreamEvent(json: Json = JSON_SERIALIZER): ParsedStreamEvent? { + return when (eventType) { + UPDATE -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + StatusCreated(createdStatus = json.decodeFromString(payload)) + } + + DELETE -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + StatusDeleted(deletedStatusId = payload) + } + + NOTIFICATION -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + NewNotification(newNotification = json.decodeFromString(payload)) + } + + CONVERSATION -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + ConversationUpdated(updatedConversation = json.decodeFromString(payload)) + } + + ANNOUNCEMENT -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + AnnouncementPublished(publishedAnnouncement = json.decodeFromString(payload)) + } + + ANNOUNCEMENT_REACTION -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + AnnouncementReactionReceived(reactionPayload = json.decodeFromString(payload)) + } + + ANNOUNCEMENT_DELETE -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + AnnouncementDeleted(deletedAnnouncementId = payload) + } + + STATUS_UPDATE -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + StatusEdited(editedStatus = json.decodeFromString(payload)) + } + + FILTERS_CHANGED -> FiltersChanged + ENCRYPTED_MESSAGE -> EncryptedMessageReceived + else -> null + } + } + } +} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/Event.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/RawStreamEvent.kt similarity index 66% rename from bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/Event.kt rename to bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/RawStreamEvent.kt index ba9643025..ca01e9e03 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/Event.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/RawStreamEvent.kt @@ -4,11 +4,13 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Streaming event constantly emitted by the streaming APIs. + * Raw streaming event constantly emitted by the streaming APIs. + * Can be parsed to a [ParsedStreamEvent] which then contains specific data classes and objects for the different + * [EventType]s that can occur here. * @see Mastodon streaming#events entity docs */ @Serializable -data class Event( +internal data class RawStreamEvent( /** * Types of stream as subscribed to via the streaming API call, represented by [StreamType]. @@ -20,10 +22,10 @@ data class Event( * Type of event, such as status update, represented by [EventType]. */ @SerialName("event") - val event: EventType? = null, + val eventType: EventType? = null, /** - * Payload sent with the event. Content depends on [event] type. + * Payload sent with the event. Content depends on [eventType] type. * See [EventType] for documentation about possible payloads. */ @SerialName("payload") diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt index b391f82e2..d994e5d85 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable /** * Event types that can be used for streaming specific timelines, - * or received as part of [Event] when using streaming APIs. + * or received as part of [RawStreamEvent] when using streaming APIs. * @see Mastodon streaming#events entities */ @Serializable diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt index a982bd3dc..adae42242 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt @@ -1,6 +1,10 @@ package social.bigbone.sample import social.bigbone.MastodonClient +import social.bigbone.api.GenericMessage +import social.bigbone.api.MastodonApiEvent +import social.bigbone.api.StreamEvent +import social.bigbone.api.TechnicalEvent object StreamFederatedPublicTimeline { @JvmStatic @@ -16,9 +20,15 @@ object StreamFederatedPublicTimeline { client.streaming.federatedPublic( onlyMedia = false, - callback = ::println - ).use { - Thread.sleep(15_000L) - } + callback = { + when (it) { + is TechnicalEvent -> println("Technical event: $it") + is MastodonApiEvent -> when (it) { + is GenericMessage -> println("Generic message: $it") + is StreamEvent -> println("API event: ${it.event!!::class.java.simpleName}") + } + } + } + ) } } From 70d5e094b170f2f1ebc55716fb91c12c2929d34c Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 17:05:57 +0100 Subject: [PATCH 11/28] Limit visibility of EventType and StreamType to library only --- .../kotlin/social/bigbone/api/entity/streaming/EventType.kt | 2 +- .../kotlin/social/bigbone/api/entity/streaming/StreamType.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt index 0a9ca294e..6832a8ec4 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable * @see Mastodon streaming#events entities */ @Serializable -enum class EventType { +internal enum class EventType { /** * A new Status has appeared. diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt index d994e5d85..8b0f46028 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable * @see Mastodon streaming#events entities */ @Serializable -enum class StreamType { +internal enum class StreamType { /** * All public posts known to the server. From d0615908b1d30433f654bfc953310fa62a2441d1 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 17:49:03 +0100 Subject: [PATCH 12/28] Move WebSocketEvent to entity/streaming package --- .../social/bigbone/rx/RxStreamingMethods.kt | 14 ++-- .../kotlin/social/bigbone/MastodonClient.kt | 12 ++-- .../social/bigbone/api/WebSocketCallback.kt | 64 +---------------- .../api/entity/streaming/WebSocketEvent.kt | 69 +++++++++++++++++++ .../bigbone/api/method/StreamingMethods.kt | 2 +- .../bigbone/sample/RxStreamPublicTimeline.kt | 22 +++--- .../sample/StreamFederatedPublicTimeline.kt | 8 +-- 7 files changed, 97 insertions(+), 94 deletions(-) create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index bf6fe57fa..6a4b32a30 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -3,14 +3,14 @@ package social.bigbone.rx import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.Flowable import social.bigbone.MastodonClient -import social.bigbone.api.Closed -import social.bigbone.api.Closing -import social.bigbone.api.Failure -import social.bigbone.api.GenericMessage -import social.bigbone.api.Open -import social.bigbone.api.StreamEvent import social.bigbone.api.WebSocketCallback -import social.bigbone.api.WebSocketEvent +import social.bigbone.api.entity.streaming.MastodonApiEvent.GenericMessage +import social.bigbone.api.entity.streaming.MastodonApiEvent.StreamEvent +import social.bigbone.api.entity.streaming.TechnicalEvent.Closed +import social.bigbone.api.entity.streaming.TechnicalEvent.Closing +import social.bigbone.api.entity.streaming.TechnicalEvent.Failure +import social.bigbone.api.entity.streaming.TechnicalEvent.Open +import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.method.StreamingMethods import java.io.Closeable diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 1a4e4305f..49c186841 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -11,17 +11,17 @@ import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.ByteString -import social.bigbone.api.Closed -import social.bigbone.api.Closing -import social.bigbone.api.Failure -import social.bigbone.api.GenericMessage -import social.bigbone.api.Open import social.bigbone.api.Pageable -import social.bigbone.api.StreamEvent import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.data.InstanceVersion +import social.bigbone.api.entity.streaming.MastodonApiEvent.GenericMessage +import social.bigbone.api.entity.streaming.MastodonApiEvent.StreamEvent import social.bigbone.api.entity.streaming.ParsedStreamEvent.Companion.toStreamEvent import social.bigbone.api.entity.streaming.RawStreamEvent +import social.bigbone.api.entity.streaming.TechnicalEvent.Closed +import social.bigbone.api.entity.streaming.TechnicalEvent.Closing +import social.bigbone.api.entity.streaming.TechnicalEvent.Failure +import social.bigbone.api.entity.streaming.TechnicalEvent.Open import social.bigbone.api.exception.BigBoneClientInstantiationException import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.api.exception.InstanceVersionRetrievalException diff --git a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt index 29a029c4b..7edf763b7 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt @@ -1,7 +1,6 @@ package social.bigbone.api -import social.bigbone.api.entity.streaming.EventType -import social.bigbone.api.entity.streaming.ParsedStreamEvent +import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.method.StreamingMethods /** @@ -17,64 +16,3 @@ fun interface WebSocketCallback { */ fun onEvent(event: WebSocketEvent) } - -/** - * Events received during websocket connection. - * - * [StreamEvent] is likely the one you’ll be most interested in as it wraps the return type returned - * by the Mastodon API when streaming via a websocket connection. - * All others are more technical events pertaining to the websocket connection itself. - */ -sealed interface WebSocketEvent - -/** - * Technical events that occurred during websocket connection, such as [Open] or [Closed]. - */ -sealed interface TechnicalEvent : WebSocketEvent - -/** - * (Ideally parsed) event received through the websocket from the Mastodon server. - * In most situations, this—and specifically [StreamEvent]—is likely what you’re looking for when streaming. - */ -sealed interface MastodonApiEvent : WebSocketEvent - -/** - * The websocket has been opened. A connection is established. - */ -data object Open : TechnicalEvent - -/** - * The websocket is about to close. - */ -data class Closing( - val code: Int, - val reason: String -) : TechnicalEvent - -/** - * The websocket is now closed. - */ -data class Closed( - val code: Int, - val reason: String -) : TechnicalEvent - -/** - * An event from the Mastodon API has been received via the websocket. - * - * @property event The parsed stream event. May be null if we got an [EventType] we don’t know yet. - */ -data class StreamEvent(val event: ParsedStreamEvent?) : MastodonApiEvent - -/** - * A message received via the websocket that could not be parsed to a [ParsedStreamEvent]. - * Instead of [StreamEvent], an object of this type with the [text] received verbatim is returned. - */ -data class GenericMessage(val text: String) : MastodonApiEvent - -/** - * An error occurred during the websocket connection. - * This is a final event: - * No further calls to the [WebSocketCallback] emitting these events will be made. - */ -data class Failure(val error: Throwable) : TechnicalEvent diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt new file mode 100644 index 000000000..e0e7b51f3 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt @@ -0,0 +1,69 @@ +package social.bigbone.api.entity.streaming + +import social.bigbone.api.WebSocketCallback +import social.bigbone.api.entity.streaming.MastodonApiEvent.StreamEvent +import social.bigbone.api.entity.streaming.TechnicalEvent.Closed +import social.bigbone.api.entity.streaming.TechnicalEvent.Open + +/** + * Events received during websocket connection. + * + * [StreamEvent] is likely the one you’ll be most interested in as it wraps the return type returned + * by the Mastodon API when streaming via a websocket connection. + * All others are more technical events pertaining to the websocket connection itself. + */ +sealed interface WebSocketEvent + +/** + * Technical events that occurred during websocket connection, such as [Open] or [Closed]. + */ +sealed interface TechnicalEvent : WebSocketEvent { + + /** + * The websocket has been opened. A connection is established. + */ + data object Open : TechnicalEvent + + /** + * The websocket is about to close. + */ + data class Closing( + val code: Int, + val reason: String + ) : TechnicalEvent + + /** + * The websocket is now closed. + */ + data class Closed( + val code: Int, + val reason: String + ) : TechnicalEvent + + /** + * An error occurred during the websocket connection. + * This is a final event: + * No further calls to the [WebSocketCallback] emitting these events will be made. + */ + data class Failure(val error: Throwable) : TechnicalEvent +} + +/** + * (Ideally parsed) event received through the websocket from the Mastodon server. + * In most situations, this—and specifically [StreamEvent]—is likely what you’re looking for when streaming. + */ +sealed interface MastodonApiEvent : WebSocketEvent { + + /** + * An event from the Mastodon API has been received via the websocket. + * + * @property event The parsed stream event. May be null if we got an [EventType] we don’t know yet. + */ + data class StreamEvent(val event: ParsedStreamEvent?) : MastodonApiEvent + + /** + * A message received via the websocket that could not be parsed to a [ParsedStreamEvent]. + * Instead of [StreamEvent], an object of this type with the [text] received verbatim is returned. + */ + data class GenericMessage(val text: String) : MastodonApiEvent +} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index 15ecdb39a..36bfb6c72 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -3,7 +3,6 @@ package social.bigbone.api.method import social.bigbone.MastodonClient import social.bigbone.Parameters import social.bigbone.api.WebSocketCallback -import social.bigbone.api.WebSocketEvent import social.bigbone.api.entity.streaming.StreamType import social.bigbone.api.entity.streaming.StreamType.DIRECT import social.bigbone.api.entity.streaming.StreamType.HASHTAG @@ -17,6 +16,7 @@ import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE_MEDIA import social.bigbone.api.entity.streaming.StreamType.USER import social.bigbone.api.entity.streaming.StreamType.USER_NOTIFICATION +import social.bigbone.api.entity.streaming.WebSocketEvent import java.io.Closeable /** diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt index 20b7c4810..5cd36319b 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/RxStreamPublicTimeline.kt @@ -1,11 +1,10 @@ package social.bigbone.sample -import io.reactivex.rxjava3.schedulers.Schedulers import social.bigbone.MastodonClient -import social.bigbone.api.MastodonApiEvent -import social.bigbone.api.TechnicalEvent +import social.bigbone.api.entity.streaming.MastodonApiEvent import social.bigbone.rx.RxStreamingMethods -import java.time.Duration +import java.util.Timer +import kotlin.concurrent.schedule object RxStreamPublicTimeline { @@ -24,19 +23,16 @@ object RxStreamPublicTimeline { val disposable = streaming.federatedPublic( onlyMedia = false ) - .subscribeOn(Schedulers.io()) + .filter { it is MastodonApiEvent } + .map { it as MastodonApiEvent } .subscribe( - /* onNext = */ { - when (it) { - is TechnicalEvent -> println("Technical event: $it") - is MastodonApiEvent -> println("Mastodon API event: $it") - } - }, + /* onNext = */ { println("Mastodon API event: $it") }, /* onError = */ ::println, /* onComplete = */ { println("onComplete") } ) - Thread.sleep(Duration.ofSeconds(10).toSeconds()) - disposable.dispose() + Timer().schedule(15_000L) { + disposable.dispose() + } } } diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt index adae42242..b42cf0ef2 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt @@ -1,10 +1,10 @@ package social.bigbone.sample import social.bigbone.MastodonClient -import social.bigbone.api.GenericMessage -import social.bigbone.api.MastodonApiEvent -import social.bigbone.api.StreamEvent -import social.bigbone.api.TechnicalEvent +import social.bigbone.api.entity.streaming.MastodonApiEvent +import social.bigbone.api.entity.streaming.MastodonApiEvent.GenericMessage +import social.bigbone.api.entity.streaming.MastodonApiEvent.StreamEvent +import social.bigbone.api.entity.streaming.TechnicalEvent object StreamFederatedPublicTimeline { @JvmStatic From f7be5953b8fc4313eed0c2cc3faa918befb5d8af Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 19:35:44 +0100 Subject: [PATCH 13/28] Test Rx web socket implementation --- .../bigbone/rx/RxStreamingMethodsTest.kt | 70 +++++++++++++++++++ .../social/bigbone/rx/testtool/MockClient.kt | 20 ++++++ 2 files changed, 90 insertions(+) create mode 100644 bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt diff --git a/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt b/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt new file mode 100644 index 000000000..727a05727 --- /dev/null +++ b/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt @@ -0,0 +1,70 @@ +package social.bigbone.rx + +import io.mockk.mockk +import io.reactivex.rxjava3.schedulers.TestScheduler +import io.reactivex.rxjava3.subscribers.TestSubscriber +import okio.IOException +import org.junit.jupiter.api.Test +import social.bigbone.api.entity.streaming.MastodonApiEvent +import social.bigbone.api.entity.streaming.ParsedStreamEvent +import social.bigbone.api.entity.streaming.TechnicalEvent +import social.bigbone.api.entity.streaming.WebSocketEvent +import social.bigbone.rx.testtool.MockClient + +class RxStreamingMethodsTest { + + private val testScheduler = TestScheduler() + + @Test + fun `Given websocket with 6 events lined up, when streaming federated public timeline, then expect emissions and no errors`() { + val mockedEvents: List = listOf( + TechnicalEvent.Open, + MastodonApiEvent.StreamEvent(ParsedStreamEvent.FiltersChanged), + MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusDeleted(deletedStatusId = "12345")), + MastodonApiEvent.StreamEvent(ParsedStreamEvent.AnnouncementDeleted(deletedAnnouncementId = "54321")), + MastodonApiEvent.StreamEvent(ParsedStreamEvent.FiltersChanged), + MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusCreated(createdStatus = mockk())) + ) + val client = MockClient.mockWebSocket(events = mockedEvents) + val streamingMethods = RxStreamingMethods(client) + + val testSubscriber: TestSubscriber = streamingMethods + .federatedPublic(onlyMedia = false) + .subscribeOn(testScheduler) + .observeOn(testScheduler) + .test() + testScheduler.triggerActions() + + with(testSubscriber) { + assertValueCount(mockedEvents.size) + assertValueSequence(mockedEvents) + assertNotComplete() + assertNoErrors() + cancel() + } + } + + @Test + fun `Given websocket with failure, when streaming federated public timeline, then expect error is propagated`() { + val expectedError = IOException("Expected") + val mockedEvents: List = listOf( + TechnicalEvent.Open, + TechnicalEvent.Failure(expectedError) + ) + val client = MockClient.mockWebSocket(events = mockedEvents) + val streamingMethods = RxStreamingMethods(client) + + val testSubscriber: TestSubscriber = streamingMethods + .federatedPublic(onlyMedia = false) + .subscribeOn(testScheduler) + .observeOn(testScheduler) + .test() + testScheduler.triggerActions() + + with(testSubscriber) { + assertNotComplete() + assertError { it == expectedError } + cancel() + } + } +} diff --git a/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt b/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt index 5090b6b6c..e4aec0039 100644 --- a/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt +++ b/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt @@ -9,13 +9,33 @@ import okhttp3.RequestBody import okhttp3.Response import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.WebSocket import social.bigbone.MastodonClient import social.bigbone.Parameters +import social.bigbone.api.WebSocketCallback +import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.exception.BigBoneRequestException import java.net.SocketTimeoutException object MockClient { + /** + * Mocks a [MastodonClient] for functions testing the websocket streaming APIs. + * + * @param events [WebSocketEvent]s that should be lined up to be returned by the [WebSocketCallback] + */ + fun mockWebSocket(events: Collection): MastodonClient { + val webSocket = mockk { + every { close(any(), any()) } returns true + } + return mockk { + every { stream(any(), any(), any()) } answers { + events.forEach { event: WebSocketEvent -> thirdArg().onEvent(event) } + webSocket + } + } + } + fun mock( jsonName: String, maxId: String? = null, From ce0ed57a3b636a10405bfc81767678ca6df4449b Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 9 Dec 2023 19:49:45 +0100 Subject: [PATCH 14/28] Remove WebSocket close logging --- .../main/kotlin/social/bigbone/api/method/StreamingMethods.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index 36bfb6c72..f425d9643 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -54,8 +54,7 @@ class StreamingMethods(private val client: MastodonClient) { ) return Closeable { - println("Closing websocket…") - val closed = webSocket.close( + webSocket.close( /* 1000 indicates a normal closure, meaning that the purpose for which the connection was established has been fulfilled. @@ -64,7 +63,6 @@ class StreamingMethods(private val client: MastodonClient) { code = 1000, reason = null ) - println("WebSocket closed? $closed") } } From c392b7acbed203813aee6714d519a94578759666 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sun, 10 Dec 2023 13:03:29 +0100 Subject: [PATCH 15/28] Add unit tests for non-Rx websocket streaming method --- .../api/method/StreamingMethodsTest.kt | 160 ++++++++++++++++++ .../social/bigbone/testtool/MockClient.kt | 20 +++ .../social/bigbone/testtool/TestUtil.kt | 7 +- 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt new file mode 100644 index 000000000..8438ef974 --- /dev/null +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt @@ -0,0 +1,160 @@ +package social.bigbone.api.method + +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import okio.IOException +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldContainSame +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import social.bigbone.Parameters +import social.bigbone.api.WebSocketCallback +import social.bigbone.api.entity.streaming.MastodonApiEvent +import social.bigbone.api.entity.streaming.ParsedStreamEvent +import social.bigbone.api.entity.streaming.StreamType +import social.bigbone.api.entity.streaming.TechnicalEvent +import social.bigbone.api.entity.streaming.WebSocketEvent +import social.bigbone.testtool.MockClient +import social.bigbone.testtool.TestUtil.urlEncode + + +class StreamingMethodsTest { + + @Test + fun `Given websocket with 6 events lined up, when streaming federated public timeline, then expect emissions and no errors`() { + val sentEvents = listOf( + TechnicalEvent.Open, + MastodonApiEvent.StreamEvent(ParsedStreamEvent.FiltersChanged), + MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusCreated(mockk())), + MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusDeleted(deletedStatusId = "12345")), + MastodonApiEvent.StreamEvent(ParsedStreamEvent.AnnouncementDeleted(deletedAnnouncementId = "54321")), + MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusCreated(createdStatus = mockk())) + ) + val client = MockClient.mockWebSocket(sentEvents) + val streamingMethods = StreamingMethods(client) + + val receivedEvents: MutableList = mutableListOf() + val callback = WebSocketCallback(receivedEvents::add) + val closeable = streamingMethods.federatedPublic( + onlyMedia = false, + callback = callback + ) + closeable.close() + + val parametersCapturingSlot = slot() + verify { + client.stream( + "api/v1/streaming", + query = capture(parametersCapturingSlot), + callback = callback + ) + } + with(parametersCapturingSlot.captured) { + toQuery() shouldBeEqualTo "stream=${StreamType.PUBLIC.apiName.urlEncode()}" + } + + sentEvents shouldContainSame receivedEvents + } + + @Test + fun `Given working websocket, when streaming local public timeline with media only, then verify client is called correctly`() { + val client = MockClient.mockWebSocket(listOf(TechnicalEvent.Open)) + val streamingMethods = StreamingMethods(client) + + val callback = WebSocketCallback {} + val closeable = streamingMethods.localPublic( + onlyMedia = true, + callback = callback + ) + closeable.close() + + val parametersCapturingSlot = slot() + verify { + client.stream( + "api/v1/streaming", + query = capture(parametersCapturingSlot), + callback = callback + ) + } + with(parametersCapturingSlot.captured) { + toQuery() shouldBeEqualTo "stream=${StreamType.PUBLIC_LOCAL_MEDIA.apiName.urlEncode()}" + } + } + + @Test + fun `Given working websocket, when streaming private hashtag timeline, then verify client is called correctly`() { + val client = MockClient.mockWebSocket(listOf(TechnicalEvent.Open)) + val streamingMethods = StreamingMethods(client) + val hashTagName = "bigbone" + + val callback = WebSocketCallback {} + val closeable = streamingMethods.hashtag( + tagName = hashTagName, + onlyFromThisServer = true, + callback = callback + ) + closeable.close() + + val parametersCapturingSlot = slot() + verify { + client.stream( + "api/v1/streaming", + query = capture(parametersCapturingSlot), + callback = callback + ) + } + with(parametersCapturingSlot.captured) { + toQuery() shouldBeEqualTo "stream=${StreamType.HASHTAG_LOCAL.apiName.urlEncode()}" + + "&tag=$hashTagName" + } + } + + @Test + fun `Given working websocket, when streaming list, then verify client is called correctly`() { + val client = MockClient.mockWebSocket(listOf(TechnicalEvent.Open)) + val streamingMethods = StreamingMethods(client) + val listName = "bigbone-developers" + + val callback = WebSocketCallback {} + val closeable = streamingMethods.list( + listId = listName, + callback = callback + ) + closeable.close() + + val parametersCapturingSlot = slot() + verify { + client.stream( + "api/v1/streaming", + query = capture(parametersCapturingSlot), + callback = callback + ) + } + with(parametersCapturingSlot.captured) { + toQuery() shouldBeEqualTo "stream=${StreamType.LIST.apiName.urlEncode()}" + + "&list=${listName.urlEncode()}" + } + } + + @Test + fun `Given websocket with failure, when streaming federated public timeline, then expect error is propagated`() { + val expectedError = IOException("expected") + val sentEvents = listOf( + TechnicalEvent.Open, + TechnicalEvent.Failure(expectedError) + ) + val client = MockClient.mockWebSocket(sentEvents) + val streamingMethods = StreamingMethods(client) + + val receivedEvents: MutableList = mutableListOf() + val callback = WebSocketCallback(receivedEvents::add) + val closeable = streamingMethods.federatedPublic( + onlyMedia = false, + callback = callback + ) + closeable.close() + + Assertions.assertIterableEquals(sentEvents, receivedEvents) + } +} diff --git a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt index f97f908fd..be9ea7eae 100644 --- a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt +++ b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt @@ -9,13 +9,33 @@ import okhttp3.RequestBody import okhttp3.Response import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.WebSocket import social.bigbone.MastodonClient import social.bigbone.Parameters +import social.bigbone.api.WebSocketCallback +import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.exception.BigBoneRequestException import java.net.SocketTimeoutException object MockClient { + /** + * Mocks a [MastodonClient] for functions testing the websocket streaming APIs. + * + * @param events [WebSocketEvent]s that should be lined up to be returned by the [WebSocketCallback] + */ + fun mockWebSocket(events: Collection): MastodonClient { + val webSocket = mockk { + every { close(any(), any()) } returns true + } + return mockk { + every { stream(any(), any(), any()) } answers { + events.forEach { event: WebSocketEvent -> thirdArg().onEvent(event) } + webSocket + } + } + } + fun mock( jsonName: String, maxId: String? = null, diff --git a/bigbone/src/test/kotlin/social/bigbone/testtool/TestUtil.kt b/bigbone/src/test/kotlin/social/bigbone/testtool/TestUtil.kt index 708379c17..fffbf82c4 100644 --- a/bigbone/src/test/kotlin/social/bigbone/testtool/TestUtil.kt +++ b/bigbone/src/test/kotlin/social/bigbone/testtool/TestUtil.kt @@ -1,6 +1,9 @@ package social.bigbone.testtool +import java.net.URLEncoder + object TestUtil { - fun normalizeLineBreaks(content: String): String = - content.replace("\r\n", "\n") + fun normalizeLineBreaks(content: String): String = content.replace("\r\n", "\n") + + fun String.urlEncode(encoding: String = "utf-8"): String = URLEncoder.encode(this, encoding) } From f9ef845040fcdc261c4e88bdb494fa2391603713 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Mon, 11 Dec 2023 22:07:43 +0100 Subject: [PATCH 16/28] Add checkServerHealth method --- .../social/bigbone/rx/RxStreamingMethods.kt | 10 ++++++++ .../bigbone/rx/RxStreamingMethodsTest.kt | 13 ++++++++++ .../social/bigbone/rx/testtool/MockClient.kt | 24 +++++++++++++++++++ .../bigbone/api/method/StreamingMethods.kt | 12 ++++++++++ .../api/method/StreamingMethodsTest.kt | 20 ++++++++++++++++ .../social/bigbone/testtool/MockClient.kt | 24 +++++++++++++++++++ docs/api-coverage/streaming.md | 4 ++-- 7 files changed, 105 insertions(+), 2 deletions(-) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index 6a4b32a30..d57f68332 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -1,6 +1,7 @@ package social.bigbone.rx import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import social.bigbone.MastodonClient import social.bigbone.api.WebSocketCallback @@ -23,6 +24,15 @@ class RxStreamingMethods(client: MastodonClient) { private val streamingMethods = StreamingMethods(client) + /** + * Verify that the streaming service is alive before connecting to it. + * + * @see streaming/#health API documentation + */ + fun checkServerHealth(): Completable = Completable.fromAction { + streamingMethods.checkServerHealth() + } + /** * Stream all public posts known to this server. Analogous to the federated timeline. * diff --git a/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt b/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt index 727a05727..e7547c0f0 100644 --- a/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt +++ b/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt @@ -15,6 +15,19 @@ class RxStreamingMethodsTest { private val testScheduler = TestScheduler() + @Test + fun `Given client returning OK, when checking server health, then complete without errors`() { + val client = MockClient.mockClearText(clearTextResponse = "OK") + val streamingMethods = RxStreamingMethods(client) + + val serverHealth = streamingMethods.checkServerHealth().test() + + with(serverHealth) { + assertComplete() + assertNoErrors() + } + } + @Test fun `Given websocket with 6 events lined up, when streaming federated public timeline, then expect emissions and no errors`() { val mockedEvents: List = listOf( diff --git a/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt b/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt index e4aec0039..ded3e0224 100644 --- a/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt +++ b/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt @@ -36,6 +36,30 @@ object MockClient { } } + fun mockClearText( + clearTextResponse: String, + requestUrl: String = "https://example.com", + ): MastodonClient { + val response: Response = Response.Builder() + .code(200) + .message("OK") + .request(Request.Builder().url(requestUrl).build()) + .protocol(Protocol.HTTP_1_1) + .body(clearTextResponse.toResponseBody()) + .build() + + return mockk { + every { get(any(), any()) } returns response + every { + this@mockk["performAction"]( + any(), + any(), + any() + ) + } returns response + } + } + fun mock( jsonName: String, maxId: String? = null, diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index f425d9643..1b2a92798 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -66,6 +66,18 @@ class StreamingMethods(private val client: MastodonClient) { } } + /** + * Verify that the streaming service is alive before connecting to it. + * Returns "OK" if the streaming service is alive. + * @see streaming/#health API documentation + */ + fun checkServerHealth() { + client.performAction( + endpoint = "api/v1/streaming/health", + method = MastodonClient.Method.GET + ) + } + /** * Stream all public posts known to this server. Analogous to the federated timeline. * diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt index 8438ef974..d162ac508 100644 --- a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt @@ -4,10 +4,13 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import okio.IOException +import org.amshove.kluent.invoking import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldContainSame +import org.amshove.kluent.shouldNotThrow import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import social.bigbone.MastodonClient import social.bigbone.Parameters import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.streaming.MastodonApiEvent @@ -15,12 +18,29 @@ import social.bigbone.api.entity.streaming.ParsedStreamEvent import social.bigbone.api.entity.streaming.StreamType import social.bigbone.api.entity.streaming.TechnicalEvent import social.bigbone.api.entity.streaming.WebSocketEvent +import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.testtool.MockClient import social.bigbone.testtool.TestUtil.urlEncode class StreamingMethodsTest { + @Test + fun `Given a client returning success, when checking server health, then expect no errors`() { + val client = MockClient.mockClearText(clearTextResponse = "OK") + val streamingMethods = StreamingMethods(client) + + invoking { streamingMethods.checkServerHealth() } shouldNotThrow BigBoneRequestException::class + + verify { + client.performAction( + endpoint = "api/v1/streaming/health", + method = MastodonClient.Method.GET, + parameters = null + ) + } + } + @Test fun `Given websocket with 6 events lined up, when streaming federated public timeline, then expect emissions and no errors`() { val sentEvents = listOf( diff --git a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt index be9ea7eae..ac3c747c6 100644 --- a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt +++ b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt @@ -36,6 +36,30 @@ object MockClient { } } + fun mockClearText( + clearTextResponse: String, + requestUrl: String = "https://example.com", + ): MastodonClient { + val response: Response = Response.Builder() + .code(200) + .message("OK") + .request(Request.Builder().url(requestUrl).build()) + .protocol(Protocol.HTTP_1_1) + .body(clearTextResponse.toResponseBody()) + .build() + + return mockk { + every { get(any(), any()) } returns response + every { + this@mockk["performAction"]( + any(), + any(), + any() + ) + } returns response + } + } + fun mock( jsonName: String, maxId: String? = null, diff --git a/docs/api-coverage/streaming.md b/docs/api-coverage/streaming.md index 0cbc2c89d..54e705aa3 100644 --- a/docs/api-coverage/streaming.md +++ b/docs/api-coverage/streaming.md @@ -21,8 +21,8 @@ Subscribe to server-sent events for real-time updates via a long-lived HTTP conn GET /api/v1/streaming/health
Check if the server is alive - - Not implemented yet. + + Fully supported. GET /api/v1/streaming/user
Watch your home timeline and notifications From 7aea5482233b1cdc3598c44eba17da666e380ba9 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 16 Dec 2023 01:15:47 +0100 Subject: [PATCH 17/28] Use instance-specific streaming URL from API-retrieved Instance info --- .../social/bigbone/rx/testtool/MockClient.kt | 6 +- .../kotlin/social/bigbone/MastodonClient.kt | 100 +++++++++++++++--- .../bigbone/api/method/StreamingMethods.kt | 3 +- .../api/method/StreamingMethodsTest.kt | 13 +-- .../social/bigbone/testtool/MockClient.kt | 6 +- 5 files changed, 98 insertions(+), 30 deletions(-) diff --git a/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt b/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt index ded3e0224..4db81dc8b 100644 --- a/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt +++ b/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt @@ -29,8 +29,8 @@ object MockClient { every { close(any(), any()) } returns true } return mockk { - every { stream(any(), any(), any()) } answers { - events.forEach { event: WebSocketEvent -> thirdArg().onEvent(event) } + every { stream(any(), any()) } answers { + events.forEach { event: WebSocketEvent -> secondArg().onEvent(event) } webSocket } } @@ -38,7 +38,7 @@ object MockClient { fun mockClearText( clearTextResponse: String, - requestUrl: String = "https://example.com", + requestUrl: String = "https://example.com" ): MastodonClient { val response: Response = Response.Builder() .code(200) diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 49c186841..7c28957ec 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -1,5 +1,8 @@ package social.bigbone +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -88,7 +91,8 @@ private constructor( private var debug: Boolean = false, private var instanceVersion: String? = null, private var scheme: String = "https", - private var port: Int = 443 + private var port: Int = 443, + private var streamingUrl: String? = null ) { /** @@ -520,24 +524,40 @@ private constructor( } } - fun stream(path: String, query: Parameters?, callback: WebSocketCallback): WebSocket { + fun stream(parameters: Parameters?, callback: WebSocketCallback): WebSocket { + val url = streamingUrl + val streamingUrl: HttpUrl = if (url != null) { + // okhttp doesn't support ws/wss scheme, so we need to convert ws to http, wss to https + val isSecureScheme: Boolean = url.startsWith("https") + val scheme: String = if (isSecureScheme) "https" else "http" + // Remove the scheme portion ( https:// or http:// ) from the full url + val instanceName: String = url.substring(if (isSecureScheme) 8 else 7) + fullUrl( + scheme = scheme, + instanceName = instanceName, + port = port, + path = "api/v1/streaming", + query = parameters + ) + } else { + fullUrl( + scheme = scheme, + instanceName = instanceName, + port = port, + path = "api/v1/streaming", + query = parameters + ) + } + return client.newWebSocket( request = Request.Builder() /* - OKHTTP doesn’t currently (at least when checked in 4.12.0) use the [AuthorizationInterceptor] for + OKHTTP doesn't currently (at least when checked in 4.12.0) use the [AuthorizationInterceptor] for WebSocket connections, so we need to set it in the header ourselves again here. See also: https://github.com/square/okhttp/issues/6454 */ .header("Authorization", "Bearer $accessToken") - .url( - fullUrl( - scheme = scheme, - instanceName = instanceName, - port = port, - path = path, - query = query - ) - ) + .url(streamingUrl) .build(), listener = object : WebSocketListener() { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { @@ -735,6 +755,7 @@ private constructor( private var readTimeoutSeconds = 10L private var writeTimeoutSeconds = 10L private var connectTimeoutSeconds = 10L + private var useStreamingApi = false /** * Sets the access token required for calling authenticated endpoints. @@ -779,6 +800,7 @@ private constructor( * Enables this client to support Streaming API methods. */ fun useStreamingApi() = apply { + this.useStreamingApi = true if (readTimeoutSeconds != 0L) { // a value of 0L means that read timeout is disabled already readTimeoutSeconds.coerceAtLeast(60L) } @@ -815,6 +837,47 @@ private constructor( this.debug = true } + private fun getStreamingApiUrl(instanceVersion: String?, fallbackUrl: String): String { + val majorVersion = instanceVersion?.substringBefore('.') + val version: Int = if (majorVersion == null) { + 2 + } else { + try { + val majorVersionInt = majorVersion.toInt() + if (majorVersionInt < 4) 1 else 2 + } catch (e: NumberFormatException) { + 2 + } + } + + versionedInstanceRequest(version).use { response: Response -> + if (!response.isSuccessful) return fallbackUrl + + val streamingUrl: String? = response.body?.string()?.let { responseBody: String -> + val rawJsonObject = JSON_SERIALIZER + .parseToJsonElement(responseBody) + .jsonObject + + if (version == 2) { + rawJsonObject["configuration"] + ?.jsonObject + ?.get("urls") + ?.jsonObject + ?.get("streaming") as? JsonPrimitive + } else { + rawJsonObject["urls"] + ?.jsonObject + ?.get("streaming_api") as? JsonPrimitive + }?.contentOrNull + // okhttp’s HttpUrl doesn't allow anything other than http(s) so we need to replace ws(s) first + ?.replace("ws:", "http:") + ?.replace("wss:", "https:") + } + + return streamingUrl ?: fallbackUrl + } + } + /** * Get the version string for this Mastodon instance. * @return a string corresponding to the version of this Mastodon instance @@ -936,7 +999,7 @@ private constructor( * connection are _not_ caught by this library. */ fun build(): MastodonClient { - return MastodonClient( + val mastodonClient = MastodonClient( instanceName = instanceName, client = okHttpClientBuilder .addNetworkInterceptor(AuthorizationInterceptor(accessToken)) @@ -950,6 +1013,17 @@ private constructor( scheme = scheme, port = port ) + + if (useStreamingApi) { + with(mastodonClient) { + streamingUrl = getStreamingApiUrl( + instanceVersion = instanceVersion, + fallbackUrl = scheme + instanceName + ) + } + } + + return mastodonClient } } diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index 1b2a92798..22a461382 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -32,8 +32,7 @@ class StreamingMethods(private val client: MastodonClient) { tagName: String? = null ): Closeable { val webSocket = client.stream( - path = "api/v1/streaming", - query = Parameters().apply { + parameters = Parameters().apply { append("stream", streamType.apiName) if (streamType == LIST) { diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt index d162ac508..5325d3fd1 100644 --- a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt @@ -22,7 +22,6 @@ import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.testtool.MockClient import social.bigbone.testtool.TestUtil.urlEncode - class StreamingMethodsTest { @Test @@ -65,8 +64,7 @@ class StreamingMethodsTest { val parametersCapturingSlot = slot() verify { client.stream( - "api/v1/streaming", - query = capture(parametersCapturingSlot), + parameters = capture(parametersCapturingSlot), callback = callback ) } @@ -92,8 +90,7 @@ class StreamingMethodsTest { val parametersCapturingSlot = slot() verify { client.stream( - "api/v1/streaming", - query = capture(parametersCapturingSlot), + parameters = capture(parametersCapturingSlot), callback = callback ) } @@ -119,8 +116,7 @@ class StreamingMethodsTest { val parametersCapturingSlot = slot() verify { client.stream( - "api/v1/streaming", - query = capture(parametersCapturingSlot), + parameters = capture(parametersCapturingSlot), callback = callback ) } @@ -146,8 +142,7 @@ class StreamingMethodsTest { val parametersCapturingSlot = slot() verify { client.stream( - "api/v1/streaming", - query = capture(parametersCapturingSlot), + parameters = capture(parametersCapturingSlot), callback = callback ) } diff --git a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt index ac3c747c6..83e1e5007 100644 --- a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt +++ b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt @@ -29,8 +29,8 @@ object MockClient { every { close(any(), any()) } returns true } return mockk { - every { stream(any(), any(), any()) } answers { - events.forEach { event: WebSocketEvent -> thirdArg().onEvent(event) } + every { stream(any(), any()) } answers { + events.forEach { event: WebSocketEvent -> secondArg().onEvent(event) } webSocket } } @@ -38,7 +38,7 @@ object MockClient { fun mockClearText( clearTextResponse: String, - requestUrl: String = "https://example.com", + requestUrl: String = "https://example.com" ): MastodonClient { val response: Response = Response.Builder() .code(200) From 12ba6c170f1a7e5569d96ccbe8168ed9d5c2fcbb Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 16 Dec 2023 02:18:51 +0100 Subject: [PATCH 18/28] Turn MastodonClient properties to val if they won't change after build --- .../kotlin/social/bigbone/MastodonClient.kt | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 7c28957ec..1a863a217 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -87,12 +87,12 @@ class MastodonClient private constructor( private val instanceName: String, private val client: OkHttpClient, - private var accessToken: String? = null, - private var debug: Boolean = false, - private var instanceVersion: String? = null, - private var scheme: String = "https", - private var port: Int = 443, - private var streamingUrl: String? = null + private val accessToken: String? = null, + private val debug: Boolean = false, + private val instanceVersion: String? = null, + private val scheme: String = "https", + private val port: Int = 443, + private val streamingUrl: String? = null ) { /** @@ -380,6 +380,7 @@ private constructor( fun getPort() = port + /** * Returns a MastodonRequest for the defined action, allowing to retrieve returned data. * @param T @@ -525,13 +526,12 @@ private constructor( } fun stream(parameters: Parameters?, callback: WebSocketCallback): WebSocket { - val url = streamingUrl - val streamingUrl: HttpUrl = if (url != null) { + val streamingUrl: HttpUrl = if (streamingUrl != null) { // okhttp doesn't support ws/wss scheme, so we need to convert ws to http, wss to https - val isSecureScheme: Boolean = url.startsWith("https") + val isSecureScheme: Boolean = streamingUrl.startsWith("https") val scheme: String = if (isSecureScheme) "https" else "http" // Remove the scheme portion ( https:// or http:// ) from the full url - val instanceName: String = url.substring(if (isSecureScheme) 8 else 7) + val instanceName: String = streamingUrl.substring(if (isSecureScheme) 8 else 7) fullUrl( scheme = scheme, instanceName = instanceName, @@ -999,7 +999,9 @@ private constructor( * connection are _not_ caught by this library. */ fun build(): MastodonClient { - val mastodonClient = MastodonClient( + val instanceVersion = getInstanceVersion() + + return MastodonClient( instanceName = instanceName, client = okHttpClientBuilder .addNetworkInterceptor(AuthorizationInterceptor(accessToken)) @@ -1009,21 +1011,15 @@ private constructor( .build(), accessToken = accessToken, debug = debug, - instanceVersion = getInstanceVersion(), + instanceVersion = instanceVersion, scheme = scheme, - port = port - ) - - if (useStreamingApi) { - with(mastodonClient) { - streamingUrl = getStreamingApiUrl( - instanceVersion = instanceVersion, - fallbackUrl = scheme + instanceName - ) + port = port, + streamingUrl = if (useStreamingApi) { + getStreamingApiUrl(instanceVersion = instanceVersion, fallbackUrl = scheme + instanceName) + } else { + null } - } - - return mastodonClient + ) } } From 99089974120ed8d0c9a26ee38cd9e8e7c754969e Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sat, 16 Dec 2023 02:44:27 +0100 Subject: [PATCH 19/28] (unrelated) Add code folding suggestion to be able to fold methods --- bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 1a863a217..ff7c1133d 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -95,6 +95,7 @@ private constructor( private val streamingUrl: String? = null ) { + //region API methods /** * Access API methods under the "accounts" endpoint. */ @@ -360,6 +361,7 @@ private constructor( @Suppress("unused") // public API @get:JvmName("trends") val trends: TrendMethods by lazy { TrendMethods(this) } + //endregion API methods /** * Specifies the HTTP methods / HTTP verb that can be used by this class. @@ -380,7 +382,6 @@ private constructor( fun getPort() = port - /** * Returns a MastodonRequest for the defined action, allowing to retrieve returned data. * @param T From 27af088dcf586a53cffda82b87e013a53992dbb9 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sun, 17 Dec 2023 02:20:48 +0100 Subject: [PATCH 20/28] Parse EventType directly and turn it non-nullable --- .../bigbone/rx/RxStreamingMethodsTest.kt | 28 ++++-- .../kotlin/social/bigbone/MastodonClient.kt | 4 +- .../bigbone/api/entity/streaming/EventType.kt | 96 ------------------- .../api/entity/streaming/ParsedStreamEvent.kt | 57 +++++------ .../api/entity/streaming/RawStreamEvent.kt | 7 +- .../api/entity/streaming/StreamType.kt | 2 +- .../api/entity/streaming/WebSocketEvent.kt | 5 +- .../api/method/StreamingMethodsTest.kt | 25 ++++- .../sample/StreamFederatedPublicTimeline.kt | 2 +- 9 files changed, 78 insertions(+), 148 deletions(-) delete mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt diff --git a/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt b/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt index e7547c0f0..5b37d528d 100644 --- a/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt +++ b/bigbone-rx/src/test/kotlin/social/bigbone/rx/RxStreamingMethodsTest.kt @@ -7,6 +7,7 @@ import okio.IOException import org.junit.jupiter.api.Test import social.bigbone.api.entity.streaming.MastodonApiEvent import social.bigbone.api.entity.streaming.ParsedStreamEvent +import social.bigbone.api.entity.streaming.StreamType import social.bigbone.api.entity.streaming.TechnicalEvent import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.rx.testtool.MockClient @@ -32,11 +33,26 @@ class RxStreamingMethodsTest { fun `Given websocket with 6 events lined up, when streaming federated public timeline, then expect emissions and no errors`() { val mockedEvents: List = listOf( TechnicalEvent.Open, - MastodonApiEvent.StreamEvent(ParsedStreamEvent.FiltersChanged), - MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusDeleted(deletedStatusId = "12345")), - MastodonApiEvent.StreamEvent(ParsedStreamEvent.AnnouncementDeleted(deletedAnnouncementId = "54321")), - MastodonApiEvent.StreamEvent(ParsedStreamEvent.FiltersChanged), - MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusCreated(createdStatus = mockk())) + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.FiltersChanged, + listOf(StreamType.PUBLIC) + ), + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.StatusCreated(mockk()), + listOf(StreamType.PUBLIC) + ), + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.StatusDeleted(deletedStatusId = "12345"), + listOf(StreamType.PUBLIC) + ), + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.AnnouncementDeleted(deletedAnnouncementId = "54321"), + listOf(StreamType.PUBLIC) + ), + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.StatusCreated(createdStatus = mockk()), + listOf(StreamType.PUBLIC) + ) ) val client = MockClient.mockWebSocket(events = mockedEvents) val streamingMethods = RxStreamingMethods(client) @@ -76,7 +92,7 @@ class RxStreamingMethodsTest { with(testSubscriber) { assertNotComplete() - assertError { it == expectedError } + assertError(expectedError) cancel() } } diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index ff7c1133d..a48befffb 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -583,7 +583,7 @@ private constructor( try { val rawEvent: RawStreamEvent = JSON_SERIALIZER.decodeFromString(text) val streamEvent = rawEvent.toStreamEvent() - callback.onEvent(StreamEvent(event = streamEvent)) + callback.onEvent(StreamEvent(event = streamEvent, streamTypes = rawEvent.stream)) } catch (e: IllegalArgumentException) { callback.onEvent(GenericMessage(text)) } @@ -597,7 +597,7 @@ private constructor( try { val rawEvent = JSON_SERIALIZER.decodeFromString(bytesAsString) val streamEvent = rawEvent.toStreamEvent() - callback.onEvent(StreamEvent(event = streamEvent)) + callback.onEvent(StreamEvent(event = streamEvent, streamTypes = rawEvent.stream)) } catch (e: IllegalArgumentException) { callback.onEvent(GenericMessage(bytesAsString)) } diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt deleted file mode 100644 index 6832a8ec4..000000000 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/EventType.kt +++ /dev/null @@ -1,96 +0,0 @@ -package social.bigbone.api.entity.streaming - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Event types that can be received as part of [RawStreamEvent] when using streaming APIs. - * @see Mastodon streaming#events entities - */ -@Serializable -internal enum class EventType { - - /** - * A new Status has appeared. - * Payload contains a Status cast to a string. - * Available since v1.0.0 - */ - @SerialName("update") - UPDATE, - - /** - * A status has been deleted. - * Payload contains the String ID of the deleted Status. - * Available since v1.0.0 - */ - @SerialName("delete") - DELETE, - - /** - * A new notification has appeared. - * Payload contains a Notification cast to a string. - * Available since v1.4.2 - */ - @SerialName("notification") - NOTIFICATION, - - /** - * Keyword filters have been changed. - * Either does not contain a payload (for WebSocket connections), - * or contains an undefined payload (for HTTP connections). - * Available since v2.4.3 - */ - @SerialName("filters_changed") - FILTERS_CHANGED, - - /** - * A direct conversation has been updated. - * Payload contains a Conversation cast to a string. - * Available since v2.6.0 - */ - @SerialName("conversation") - CONVERSATION, - - /** - * An announcement has been published. - * Payload contains an Announcement cast to a string. - * Available since v3.1.0 - */ - @SerialName("announcement") - ANNOUNCEMENT, - - /** - * An announcement has received an emoji reaction. - * Payload contains a Hash (with name, count, and announcement_id) cast to a string. - * Available since v3.1.0 - */ - @SerialName("announcement.reaction") - ANNOUNCEMENT_REACTION, - - /** - * An announcement has been deleted. - * Payload contains the String ID of the deleted Announcement. - * Available since v3.1.0 - */ - @SerialName("announcement.delete") - ANNOUNCEMENT_DELETE, - - /** - * A Status has been edited. - * Payload contains a Status cast to a string. - * Available since v3.5.0 - */ - @SerialName("status.update") - STATUS_UPDATE, - - /** - * An encrypted message has been received. - * Implemented in v3.2.0 but currently unused - */ - @SerialName("encrypted_message") - ENCRYPTED_MESSAGE; - - @OptIn(ExperimentalSerializationApi::class) - val apiName: String get() = EventType.serializer().descriptor.getElementName(ordinal) -} diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt index 715057db7..1c6754ec5 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt @@ -6,19 +6,9 @@ import social.bigbone.api.entity.Announcement import social.bigbone.api.entity.Conversation import social.bigbone.api.entity.Notification import social.bigbone.api.entity.Status -import social.bigbone.api.entity.streaming.EventType.ANNOUNCEMENT -import social.bigbone.api.entity.streaming.EventType.ANNOUNCEMENT_DELETE -import social.bigbone.api.entity.streaming.EventType.ANNOUNCEMENT_REACTION -import social.bigbone.api.entity.streaming.EventType.CONVERSATION -import social.bigbone.api.entity.streaming.EventType.DELETE -import social.bigbone.api.entity.streaming.EventType.ENCRYPTED_MESSAGE -import social.bigbone.api.entity.streaming.EventType.FILTERS_CHANGED -import social.bigbone.api.entity.streaming.EventType.NOTIFICATION -import social.bigbone.api.entity.streaming.EventType.STATUS_UPDATE -import social.bigbone.api.entity.streaming.EventType.UPDATE /** - * Stream events emitted by the Mastodon API websocket stream for each [EventType] that can potentially occur. + * Stream events emitted by the Mastodon API websocket stream for each event type that can occur. * This is the parsed variant of the [RawStreamEvent.eventType] and [RawStreamEvent.payload] * turned into easily usable data classes and objects for type-specific consumption. */ @@ -86,52 +76,57 @@ sealed interface ParsedStreamEvent { */ data object EncryptedMessageReceived : ParsedStreamEvent + /** + * Type received via the Mastodon API that is not (yet) available in BigBone. + */ + data class UnknownType(val eventType: String, val payload: String?) : ParsedStreamEvent + companion object { - internal fun RawStreamEvent.toStreamEvent(json: Json = JSON_SERIALIZER): ParsedStreamEvent? { + internal fun RawStreamEvent.toStreamEvent(json: Json = JSON_SERIALIZER): ParsedStreamEvent { return when (eventType) { - UPDATE -> { - requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + "update" -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } StatusCreated(createdStatus = json.decodeFromString(payload)) } - DELETE -> { - requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + "delete" -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } StatusDeleted(deletedStatusId = payload) } - NOTIFICATION -> { - requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + "notification" -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } NewNotification(newNotification = json.decodeFromString(payload)) } - CONVERSATION -> { - requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + "conversation" -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } ConversationUpdated(updatedConversation = json.decodeFromString(payload)) } - ANNOUNCEMENT -> { - requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + "announcement" -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } AnnouncementPublished(publishedAnnouncement = json.decodeFromString(payload)) } - ANNOUNCEMENT_REACTION -> { - requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + "announcement.reaction" -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } AnnouncementReactionReceived(reactionPayload = json.decodeFromString(payload)) } - ANNOUNCEMENT_DELETE -> { - requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + "announcement.delete" -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } AnnouncementDeleted(deletedAnnouncementId = payload) } - STATUS_UPDATE -> { - requireNotNull(payload) { "Payload was null for update $eventType but mustn’t be." } + "status.update" -> { + requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } StatusEdited(editedStatus = json.decodeFromString(payload)) } - FILTERS_CHANGED -> FiltersChanged - ENCRYPTED_MESSAGE -> EncryptedMessageReceived - else -> null + "filters_changed" -> FiltersChanged + "encrypted_message" -> EncryptedMessageReceived + else -> UnknownType(eventType, payload) } } } diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/RawStreamEvent.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/RawStreamEvent.kt index ca01e9e03..d4ced26e1 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/RawStreamEvent.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/RawStreamEvent.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable /** * Raw streaming event constantly emitted by the streaming APIs. * Can be parsed to a [ParsedStreamEvent] which then contains specific data classes and objects for the different - * [EventType]s that can occur here. + * [eventType]s that can occur here. * @see Mastodon streaming#events entity docs */ @Serializable @@ -19,14 +19,13 @@ internal data class RawStreamEvent( val stream: List? = null, /** - * Type of event, such as status update, represented by [EventType]. + * Type of event, such as "status.update". */ @SerialName("event") - val eventType: EventType? = null, + val eventType: String, /** * Payload sent with the event. Content depends on [eventType] type. - * See [EventType] for documentation about possible payloads. */ @SerialName("payload") val payload: String? = null diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt index 8b0f46028..d994e5d85 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable * @see Mastodon streaming#events entities */ @Serializable -internal enum class StreamType { +enum class StreamType { /** * All public posts known to the server. diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt index e0e7b51f3..3ed921b9b 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt @@ -57,9 +57,10 @@ sealed interface MastodonApiEvent : WebSocketEvent { /** * An event from the Mastodon API has been received via the websocket. * - * @property event The parsed stream event. May be null if we got an [EventType] we don’t know yet. + * @property event The parsed stream event. May be [ParsedStreamEvent.UnknownType] if it's unknown to BigBone. + * @property streamTypes The [StreamType]s received as part of the event. */ - data class StreamEvent(val event: ParsedStreamEvent?) : MastodonApiEvent + data class StreamEvent(val event: ParsedStreamEvent, val streamTypes: List?) : MastodonApiEvent /** * A message received via the websocket that could not be parsed to a [ParsedStreamEvent]. diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt index 5325d3fd1..be40219ff 100644 --- a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt @@ -44,11 +44,26 @@ class StreamingMethodsTest { fun `Given websocket with 6 events lined up, when streaming federated public timeline, then expect emissions and no errors`() { val sentEvents = listOf( TechnicalEvent.Open, - MastodonApiEvent.StreamEvent(ParsedStreamEvent.FiltersChanged), - MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusCreated(mockk())), - MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusDeleted(deletedStatusId = "12345")), - MastodonApiEvent.StreamEvent(ParsedStreamEvent.AnnouncementDeleted(deletedAnnouncementId = "54321")), - MastodonApiEvent.StreamEvent(ParsedStreamEvent.StatusCreated(createdStatus = mockk())) + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.FiltersChanged, + listOf(StreamType.PUBLIC) + ), + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.StatusCreated(mockk()), + listOf(StreamType.PUBLIC) + ), + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.StatusDeleted(deletedStatusId = "12345"), + listOf(StreamType.PUBLIC) + ), + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.AnnouncementDeleted(deletedAnnouncementId = "54321"), + listOf(StreamType.PUBLIC) + ), + MastodonApiEvent.StreamEvent( + ParsedStreamEvent.StatusCreated(createdStatus = mockk()), + listOf(StreamType.PUBLIC) + ) ) val client = MockClient.mockWebSocket(sentEvents) val streamingMethods = StreamingMethods(client) diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt index b42cf0ef2..40a0b335f 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt @@ -25,7 +25,7 @@ object StreamFederatedPublicTimeline { is TechnicalEvent -> println("Technical event: $it") is MastodonApiEvent -> when (it) { is GenericMessage -> println("Generic message: $it") - is StreamEvent -> println("API event: ${it.event!!::class.java.simpleName}") + is StreamEvent -> println("API event: ${it.event::class.java.simpleName}") } } } From 664ba8a47121cdea10f2f9164fa5dc3568740f3c Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sun, 17 Dec 2023 03:31:20 +0100 Subject: [PATCH 21/28] Add data class for AnnouncementReactionReceived payload --- .../social/bigbone/api/entity/Reaction.kt | 3 +- .../api/entity/streaming/ParsedStreamEvent.kt | 4 +-- .../StreamingAnnouncementReaction.kt | 31 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamingAnnouncementReaction.kt diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/Reaction.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/Reaction.kt index 4457851f2..641b0cd43 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/Reaction.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/Reaction.kt @@ -7,7 +7,6 @@ import kotlinx.serialization.Serializable * Represents an emoji reaction to an Announcement. * @see Mastodon API Reaction */ - @Serializable data class Reaction( /** @@ -17,7 +16,7 @@ data class Reaction( val name: String = "", /** - * The total number of users who have added this reaction. + * The total number of users who have added this reaction. */ @SerialName("count") val count: Int = 0, diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt index 1c6754ec5..ffe7c8b61 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt @@ -62,7 +62,7 @@ sealed interface ParsedStreamEvent { * An announcement has received an emoji reaction. * Available since v3.1.0 */ - data class AnnouncementReactionReceived(val reactionPayload: String) : ParsedStreamEvent + data class AnnouncementReactionReceived(val reaction: StreamingAnnouncementReaction) : ParsedStreamEvent /** * An announcement has been deleted. @@ -111,7 +111,7 @@ sealed interface ParsedStreamEvent { "announcement.reaction" -> { requireNotNull(payload) { "Payload was null for update $eventType but mustn't be." } - AnnouncementReactionReceived(reactionPayload = json.decodeFromString(payload)) + AnnouncementReactionReceived(reaction = json.decodeFromString(payload)) } "announcement.delete" -> { diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamingAnnouncementReaction.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamingAnnouncementReaction.kt new file mode 100644 index 000000000..c6074add8 --- /dev/null +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamingAnnouncementReaction.kt @@ -0,0 +1,31 @@ +package social.bigbone.api.entity.streaming + +import kotlinx.serialization.SerialName +import social.bigbone.api.entity.Reaction + +/** + * "Hash" returned as part of [ParsedStreamEvent.AnnouncementReactionReceived] containing information on the reaction. + * + * This is slightly different from [Reaction] and thus is in its own class here. + * + * @see Mastodon API streaming events + */ +data class StreamingAnnouncementReaction( + /** + * The emoji used for the reaction. Either a unicode emoji, or a custom emoji’s shortcode. + */ + @SerialName("name") + val name: String, + + /** + * The total number of users who have added this reaction. + */ + @SerialName("count") + val count: Int, + + /** + * The ID of the announcement in the database. + */ + @SerialName("announcement_id") + val announcementId: String +) From 47eef955871aecf1737e8f6c180c4b9561f5d26a Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Sun, 17 Dec 2023 03:45:24 +0100 Subject: [PATCH 22/28] Close web socket in case of failure --- .../src/main/kotlin/social/bigbone/MastodonClient.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index a48befffb..6bca6c0b2 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -574,6 +574,17 @@ private constructor( override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { super.onFailure(webSocket, t, response) callback.onEvent(Failure(t)) + + /* + onFailure represents a terminal event and no further events would be received by this web socket, + so we close it. + 1002 indicates that an endpoint is terminating the connection due to a protocol error. + see: https://datatracker.ietf.org/doc/html/rfc6455#section-7.4 + */ + webSocket.close( + code = 1002, + reason = null + ) } override fun onMessage(webSocket: WebSocket, text: String) { From e3debd1e993b776d1821fa609f1022ea8e83036c Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Mon, 18 Dec 2023 13:19:09 +0100 Subject: [PATCH 23/28] =?UTF-8?q?Review:=20Clarify=20=E2=80=9CAvailable=20?= =?UTF-8?q?since=E2=80=9D=20comments=E2=80=94Mastodon=20server,=20not=20Bi?= =?UTF-8?q?gBone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/entity/streaming/ParsedStreamEvent.kt | 18 +++++++------- .../api/entity/streaming/StreamType.kt | 24 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt index ffe7c8b61..a78d3d897 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/ParsedStreamEvent.kt @@ -17,56 +17,56 @@ sealed interface ParsedStreamEvent { /** * A new Status has appeared. * Payload contains a Status cast to a string. - * Available since v1.0.0 + * Available since Mastodon server version v1.0.0 */ data class StatusCreated(val createdStatus: Status) : ParsedStreamEvent /** * A Status has been edited. - * Available since v3.5.0 + * Available since Mastodon server version v3.5.0 */ data class StatusEdited(val editedStatus: Status) : ParsedStreamEvent /** * A status has been deleted. - * Available since v1.0.0 + * Available since Mastodon server version v1.0.0 */ data class StatusDeleted(val deletedStatusId: String) : ParsedStreamEvent /** * A new notification has appeared. - * Available since v1.4.2 + * Available since Mastodon server version v1.4.2 */ data class NewNotification(val newNotification: Notification) : ParsedStreamEvent /** * Keyword filters have been changed. * Does not contain a payload for WebSocket connections. - * Available since v2.4.3 + * Available since Mastodon server version v2.4.3 */ data object FiltersChanged : ParsedStreamEvent /** * A direct conversation has been updated. - * Available since v2.6.0 + * Available since Mastodon server version v2.6.0 */ data class ConversationUpdated(val updatedConversation: Conversation) : ParsedStreamEvent /** * An announcement has been published. - * Available since v3.1.0 + * Available since Mastodon server version v3.1.0 */ data class AnnouncementPublished(val publishedAnnouncement: Announcement) : ParsedStreamEvent /** * An announcement has received an emoji reaction. - * Available since v3.1.0 + * Available since Mastodon server version v3.1.0 */ data class AnnouncementReactionReceived(val reaction: StreamingAnnouncementReaction) : ParsedStreamEvent /** * An announcement has been deleted. - * Available since v3.1.0 + * Available since Mastodon server version v3.1.0 */ data class AnnouncementDeleted(val deletedAnnouncementId: String) : ParsedStreamEvent diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt index d994e5d85..606671510 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/StreamType.kt @@ -15,7 +15,7 @@ enum class StreamType { /** * All public posts known to the server. * Analogous to the federated timeline. - * Available since v1.0.0 + * Available since Mastodon server version v1.0.0 */ @SerialName("public") PUBLIC, @@ -23,7 +23,7 @@ enum class StreamType { /** * All public posts known to the server, filtered for media attachments. * Analogous to the federated timeline with “only media” enabled. - * Available since v2.4.0 + * Available since Mastodon server version v2.4.0 */ @SerialName("public:media") PUBLIC_MEDIA, @@ -31,7 +31,7 @@ enum class StreamType { /** * All public posts originating from this server. * Analogous to the local timeline. - * Available since v1.1 + * Available since Mastodon server version v1.1 */ @SerialName("public:local") PUBLIC_LOCAL, @@ -39,63 +39,63 @@ enum class StreamType { /** * All public posts originating from this server, filtered for media attachments. * Analogous to the local timeline with “only media” enabled. - * Available since v2.4.0 + * Available since Mastodon server version v2.4.0 */ @SerialName("public:local:media") PUBLIC_LOCAL_MEDIA, /** * All public posts originating from other servers. - * Available since v3.1.4 + * Available since Mastodon server version v3.1.4 */ @SerialName("public:remote") PUBLIC_REMOTE, /** * All public posts originating from other servers, filtered for media attachments. - * Available since v3.1.4 + * Available since Mastodon server version v3.1.4 */ @SerialName("public:remote:media") PUBLIC_REMOTE_MEDIA, /** * All public posts using a certain hashtag. - * Available since v1.0.0 + * Available since Mastodon server version v1.0.0 */ @SerialName("hashtag") HASHTAG, /** * All public posts using a certain hashtag, originating from this server. - * Available since v1.1 + * Available since Mastodon server version v1.1 */ @SerialName("hashtag:local") HASHTAG_LOCAL, /** * Events related to the current user, such as home feed updates and notifications. - * Available since v1.0.0 + * Available since Mastodon server version v1.0.0 */ @SerialName("user") USER, /** * Notifications for the current user. - * Available since v1.4.2 + * Available since Mastodon server version v1.4.2 */ @SerialName("user:notification") USER_NOTIFICATION, /** * Updates to a specific list. - * Available since v2.1.0 + * Available since Mastodon server version v2.1.0 */ @SerialName("list") LIST, /** * Updates to direct conversations. - * Available since v2.4.0 + * Available since Mastodon server version v2.4.0 */ @SerialName("direct") DIRECT; From fd47aa14d9b45edae1ea080accb78c9a2601bef1 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Mon, 18 Dec 2023 13:23:27 +0100 Subject: [PATCH 24/28] Review: Clarify server health method returns --- .../kotlin/social/bigbone/api/method/StreamingMethods.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index 22a461382..e704cc2a9 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -17,6 +17,7 @@ import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE_MEDIA import social.bigbone.api.entity.streaming.StreamType.USER import social.bigbone.api.entity.streaming.StreamType.USER_NOTIFICATION import social.bigbone.api.entity.streaming.WebSocketEvent +import social.bigbone.api.exception.BigBoneRequestException import java.io.Closeable /** @@ -67,9 +68,13 @@ class StreamingMethods(private val client: MastodonClient) { /** * Verify that the streaming service is alive before connecting to it. - * Returns "OK" if the streaming service is alive. + * + * Will not return anything in case the server is healthy and will throw [BigBoneRequestException] in case of issues. + * * @see streaming/#health API documentation + * @throws [BigBoneRequestException] if the request was not successful */ + @Throws(BigBoneRequestException::class) fun checkServerHealth() { client.performAction( endpoint = "api/v1/streaming/health", From 75be1583e693501828a964e9675f17fa67df6678 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Mon, 18 Dec 2023 13:39:32 +0100 Subject: [PATCH 25/28] Review: Replace builder for streaming URL --- .../kotlin/social/bigbone/MastodonClient.kt | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 6bca6c0b2..d83205540 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient @@ -528,17 +529,10 @@ private constructor( fun stream(parameters: Parameters?, callback: WebSocketCallback): WebSocket { val streamingUrl: HttpUrl = if (streamingUrl != null) { - // okhttp doesn't support ws/wss scheme, so we need to convert ws to http, wss to https - val isSecureScheme: Boolean = streamingUrl.startsWith("https") - val scheme: String = if (isSecureScheme) "https" else "http" - // Remove the scheme portion ( https:// or http:// ) from the full url - val instanceName: String = streamingUrl.substring(if (isSecureScheme) 8 else 7) fullUrl( - scheme = scheme, - instanceName = instanceName, - port = port, + existingUrl = streamingUrl.toHttpUrl(), path = "api/v1/streaming", - query = parameters + queryParameters = parameters ) } else { fullUrl( @@ -727,6 +721,7 @@ private constructor( * @param path Mastodon API endpoint to be called * @param query query part of the URL to build; may be null */ + @JvmOverloads fun fullUrl(scheme: String, instanceName: String, port: Int, path: String, query: Parameters? = null): HttpUrl { val urlBuilder = HttpUrl.Builder() .scheme(scheme) @@ -739,6 +734,27 @@ private constructor( return urlBuilder.build() } + /** + * Adds [path] and optional [queryParameters] parameters to the [existingUrl] to create a new [HttpUrl]. + * + * @param existingUrl HttpUrl to add [path] and [queryParameters] to + * @param path Mastodon API endpoint to be called + * @param queryParameters query part of the URL to build; may be null + */ + @JvmOverloads + fun fullUrl( + existingUrl: HttpUrl, + path: String, + queryParameters: Parameters? = null + ): HttpUrl { + with(existingUrl.newBuilder()) { + addEncodedPathSegments(path) + queryParameters?.let { encodedQuery(queryParameters.toQuery()) } + + return build() + } + } + /** * Returns a RequestBody matching the given parameters. * @param parameters list of parameters to use; may be null, in which case the returned RequestBody will be empty @@ -881,7 +897,8 @@ private constructor( ?.jsonObject ?.get("streaming_api") as? JsonPrimitive }?.contentOrNull - // okhttp’s HttpUrl doesn't allow anything other than http(s) so we need to replace ws(s) first + // okhttp’s HttpUrl which is used later to parse this result only allows http(s) + // so we need to replace ws(s) first ?.replace("ws:", "http:") ?.replace("wss:", "https:") } From 858b841c4729fbc63d3da88279ae08971652706b Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Mon, 18 Dec 2023 14:01:51 +0100 Subject: [PATCH 26/28] Review: Clarify server health method returns in Rx function --- .../src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index d57f68332..4e037c29a 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -27,6 +27,7 @@ class RxStreamingMethods(client: MastodonClient) { /** * Verify that the streaming service is alive before connecting to it. * + * @return Completable that will complete if the server is “healthy” or emit an error via onError otherwise. * @see streaming/#health API documentation */ fun checkServerHealth(): Completable = Completable.fromAction { From f6d5c11464407c5c113a42040cc596f3d652f3b4 Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Tue, 19 Dec 2023 23:26:48 +0100 Subject: [PATCH 27/28] Review: Move WebSocketCallback to api.entity.streaming package --- .../src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt | 2 +- .../src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt | 2 +- bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt | 2 +- .../bigbone/api/{ => entity/streaming}/WebSocketCallback.kt | 3 +-- .../social/bigbone/api/entity/streaming/WebSocketEvent.kt | 1 - .../main/kotlin/social/bigbone/api/method/StreamingMethods.kt | 2 +- .../kotlin/social/bigbone/api/method/StreamingMethodsTest.kt | 2 +- bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt | 2 +- 8 files changed, 7 insertions(+), 9 deletions(-) rename bigbone/src/main/kotlin/social/bigbone/api/{ => entity/streaming}/WebSocketCallback.kt (85%) diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt index 4e037c29a..a05efc32c 100644 --- a/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt +++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/RxStreamingMethods.kt @@ -4,13 +4,13 @@ import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import social.bigbone.MastodonClient -import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.streaming.MastodonApiEvent.GenericMessage import social.bigbone.api.entity.streaming.MastodonApiEvent.StreamEvent import social.bigbone.api.entity.streaming.TechnicalEvent.Closed import social.bigbone.api.entity.streaming.TechnicalEvent.Closing import social.bigbone.api.entity.streaming.TechnicalEvent.Failure import social.bigbone.api.entity.streaming.TechnicalEvent.Open +import social.bigbone.api.entity.streaming.WebSocketCallback import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.method.StreamingMethods import java.io.Closeable diff --git a/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt b/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt index 4db81dc8b..14c3d1db2 100644 --- a/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt +++ b/bigbone-rx/src/test/kotlin/social/bigbone/rx/testtool/MockClient.kt @@ -12,7 +12,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.WebSocket import social.bigbone.MastodonClient import social.bigbone.Parameters -import social.bigbone.api.WebSocketCallback +import social.bigbone.api.entity.streaming.WebSocketCallback import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.exception.BigBoneRequestException import java.net.SocketTimeoutException diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index d83205540..6a7d26c18 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -16,7 +16,6 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.ByteString import social.bigbone.api.Pageable -import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.data.InstanceVersion import social.bigbone.api.entity.streaming.MastodonApiEvent.GenericMessage import social.bigbone.api.entity.streaming.MastodonApiEvent.StreamEvent @@ -26,6 +25,7 @@ import social.bigbone.api.entity.streaming.TechnicalEvent.Closed import social.bigbone.api.entity.streaming.TechnicalEvent.Closing import social.bigbone.api.entity.streaming.TechnicalEvent.Failure import social.bigbone.api.entity.streaming.TechnicalEvent.Open +import social.bigbone.api.entity.streaming.WebSocketCallback import social.bigbone.api.exception.BigBoneClientInstantiationException import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.api.exception.InstanceVersionRetrievalException diff --git a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketCallback.kt similarity index 85% rename from bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt rename to bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketCallback.kt index 7edf763b7..e78a6339e 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/WebSocketCallback.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketCallback.kt @@ -1,6 +1,5 @@ -package social.bigbone.api +package social.bigbone.api.entity.streaming -import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.method.StreamingMethods /** diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt index 3ed921b9b..8b96084d5 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/streaming/WebSocketEvent.kt @@ -1,6 +1,5 @@ package social.bigbone.api.entity.streaming -import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.streaming.MastodonApiEvent.StreamEvent import social.bigbone.api.entity.streaming.TechnicalEvent.Closed import social.bigbone.api.entity.streaming.TechnicalEvent.Open diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt index e704cc2a9..bc4c28ddd 100644 --- a/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt +++ b/bigbone/src/main/kotlin/social/bigbone/api/method/StreamingMethods.kt @@ -2,7 +2,6 @@ package social.bigbone.api.method import social.bigbone.MastodonClient import social.bigbone.Parameters -import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.streaming.StreamType import social.bigbone.api.entity.streaming.StreamType.DIRECT import social.bigbone.api.entity.streaming.StreamType.HASHTAG @@ -16,6 +15,7 @@ import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE import social.bigbone.api.entity.streaming.StreamType.PUBLIC_REMOTE_MEDIA import social.bigbone.api.entity.streaming.StreamType.USER import social.bigbone.api.entity.streaming.StreamType.USER_NOTIFICATION +import social.bigbone.api.entity.streaming.WebSocketCallback import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.exception.BigBoneRequestException import java.io.Closeable diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt index be40219ff..c52ca89f9 100644 --- a/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt +++ b/bigbone/src/test/kotlin/social/bigbone/api/method/StreamingMethodsTest.kt @@ -12,11 +12,11 @@ import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import social.bigbone.MastodonClient import social.bigbone.Parameters -import social.bigbone.api.WebSocketCallback import social.bigbone.api.entity.streaming.MastodonApiEvent import social.bigbone.api.entity.streaming.ParsedStreamEvent import social.bigbone.api.entity.streaming.StreamType import social.bigbone.api.entity.streaming.TechnicalEvent +import social.bigbone.api.entity.streaming.WebSocketCallback import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.testtool.MockClient diff --git a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt index 83e1e5007..2f93f807f 100644 --- a/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt +++ b/bigbone/src/test/kotlin/social/bigbone/testtool/MockClient.kt @@ -12,7 +12,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.WebSocket import social.bigbone.MastodonClient import social.bigbone.Parameters -import social.bigbone.api.WebSocketCallback +import social.bigbone.api.entity.streaming.WebSocketCallback import social.bigbone.api.entity.streaming.WebSocketEvent import social.bigbone.api.exception.BigBoneRequestException import java.net.SocketTimeoutException From 91ba533c5a8751ffba124a9b5eb495ea1400a1fd Mon Sep 17 00:00:00 2001 From: Patrick Geselbracht Date: Tue, 19 Dec 2023 23:35:01 +0100 Subject: [PATCH 28/28] Review: Remove need for useStreamingApi call - Gets the streaming URL for every MastodonClient instance - Sets the pingInterval instead of a timeout, but only for stream calls --- USAGE.md | 2 - .../kotlin/social/bigbone/MastodonClient.kt | 53 +++++++------------ .../social/bigbone/sample/Authenticator.java | 5 +- .../sample/StreamFederatedPublicTimeline.java | 1 - .../social/bigbone/sample/Authenticator.kt | 7 +-- .../sample/StreamFederatedPublicTimeline.kt | 1 - 6 files changed, 20 insertions(+), 49 deletions(-) diff --git a/USAGE.md b/USAGE.md index 0be3b2c0b..d3ebfc387 100644 --- a/USAGE.md +++ b/USAGE.md @@ -213,7 +213,6 @@ __Kotlin__ ```kotlin val client: MastodonClient = MastodonClient.Builder(instanceHostname) .accessToken(accessToken) - .useStreamingApi() .build() client.streaming.federatedPublic( @@ -231,7 +230,6 @@ __Java__ ```java final MastodonClient client = new MastodonClient.Builder(instanceHostname) .accessToken(accessToken) - .useStreamingApi() .build(); // Start federated timeline streaming and stop after 20 seconds diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt index 6a7d26c18..5d4127639 100644 --- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt +++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt @@ -87,13 +87,13 @@ import javax.net.ssl.X509TrustManager class MastodonClient private constructor( private val instanceName: String, + private val streamingUrl: String, private val client: OkHttpClient, private val accessToken: String? = null, private val debug: Boolean = false, private val instanceVersion: String? = null, private val scheme: String = "https", - private val port: Int = 443, - private val streamingUrl: String? = null + private val port: Int = 443 ) { //region API methods @@ -528,23 +528,12 @@ private constructor( } fun stream(parameters: Parameters?, callback: WebSocketCallback): WebSocket { - val streamingUrl: HttpUrl = if (streamingUrl != null) { - fullUrl( - existingUrl = streamingUrl.toHttpUrl(), - path = "api/v1/streaming", - queryParameters = parameters - ) - } else { - fullUrl( - scheme = scheme, - instanceName = instanceName, - port = port, - path = "api/v1/streaming", - query = parameters - ) - } + val webSocketClient: OkHttpClient = client + .newBuilder() + .pingInterval(60L, TimeUnit.SECONDS) + .build() - return client.newWebSocket( + return webSocketClient.newWebSocket( request = Request.Builder() /* OKHTTP doesn't currently (at least when checked in 4.12.0) use the [AuthorizationInterceptor] for @@ -552,7 +541,13 @@ private constructor( See also: https://github.com/square/okhttp/issues/6454 */ .header("Authorization", "Bearer $accessToken") - .url(streamingUrl) + .url( + fullUrl( + existingUrl = streamingUrl.toHttpUrl(), + path = "api/v1/streaming", + queryParameters = parameters + ) + ) .build(), listener = object : WebSocketListener() { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { @@ -783,7 +778,6 @@ private constructor( private var readTimeoutSeconds = 10L private var writeTimeoutSeconds = 10L private var connectTimeoutSeconds = 10L - private var useStreamingApi = false /** * Sets the access token required for calling authenticated endpoints. @@ -824,16 +818,6 @@ private constructor( this.port = port } - /** - * Enables this client to support Streaming API methods. - */ - fun useStreamingApi() = apply { - this.useStreamingApi = true - if (readTimeoutSeconds != 0L) { // a value of 0L means that read timeout is disabled already - readTimeoutSeconds.coerceAtLeast(60L) - } - } - /** * Sets the read timeout for connections of this client. * @param timeoutSeconds the new timeout value in seconds; default value is 10 seconds, setting this to 0 @@ -1043,11 +1027,10 @@ private constructor( instanceVersion = instanceVersion, scheme = scheme, port = port, - streamingUrl = if (useStreamingApi) { - getStreamingApiUrl(instanceVersion = instanceVersion, fallbackUrl = scheme + instanceName) - } else { - null - } + streamingUrl = getStreamingApiUrl( + instanceVersion = instanceVersion, + fallbackUrl = scheme + instanceName + ) ) } } diff --git a/sample-java/src/main/java/social/bigbone/sample/Authenticator.java b/sample-java/src/main/java/social/bigbone/sample/Authenticator.java index 46df3ff1b..e16701135 100644 --- a/sample-java/src/main/java/social/bigbone/sample/Authenticator.java +++ b/sample-java/src/main/java/social/bigbone/sample/Authenticator.java @@ -24,7 +24,7 @@ final class Authenticator { private Authenticator() { } - static MastodonClient appRegistrationIfNeeded(final String instanceName, final String credentialFilePath, final boolean useStreaming) throws IOException, BigBoneRequestException { + static MastodonClient appRegistrationIfNeeded(final String instanceName, final String credentialFilePath) throws IOException, BigBoneRequestException { final File file = new File(credentialFilePath); if (!file.exists()) { System.out.println("create $credentialFilePath."); @@ -58,9 +58,6 @@ static MastodonClient appRegistrationIfNeeded(final String instanceName, final S } final MastodonClient.Builder builder = new MastodonClient.Builder(instanceName) .accessToken(properties.get(ACCESS_TOKEN).toString()); - if (useStreaming) { - builder.useStreamingApi(); - } return builder.build(); } diff --git a/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java b/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java index 3eb7b688a..f36b7d5fd 100644 --- a/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java +++ b/sample-java/src/main/java/social/bigbone/sample/StreamFederatedPublicTimeline.java @@ -14,7 +14,6 @@ public static void main(final String[] args) throws BigBoneRequestException, Int // Instantiate client final MastodonClient client = new MastodonClient.Builder(instance) .accessToken(accessToken) - .useStreamingApi() .build(); // Start federated timeline streaming and stop after 20 seconds diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/Authenticator.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/Authenticator.kt index 457101f00..597d479b7 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/Authenticator.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/Authenticator.kt @@ -15,7 +15,7 @@ object Authenticator { private const val REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" private val fullScope = Scope(Scope.READ.ALL, Scope.WRITE.ALL, Scope.PUSH.ALL) - fun appRegistrationIfNeeded(instanceName: String, credentialFilePath: String, useStreaming: Boolean = false): MastodonClient { + fun appRegistrationIfNeeded(instanceName: String, credentialFilePath: String): MastodonClient { val file = File(credentialFilePath) if (!file.exists()) { println("create $credentialFilePath.") @@ -56,11 +56,6 @@ object Authenticator { } return MastodonClient.Builder(instanceName) .accessToken(properties[ACCESS_TOKEN].toString()) - .apply { - if (useStreaming) { - useStreamingApi() - } - } .build() } diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt index 40a0b335f..33cdaeea3 100644 --- a/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt +++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/StreamFederatedPublicTimeline.kt @@ -15,7 +15,6 @@ object StreamFederatedPublicTimeline { // Instantiate client val client = MastodonClient.Builder(instance) .accessToken(accessToken) - .useStreamingApi() .build() client.streaming.federatedPublic(