From 727b8d270e0df265f4f6576fd014fa55a16af83c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 3 Sep 2024 10:58:06 +0100 Subject: [PATCH] [ECO-4962] feat: add `ChatApi` implementation Created basic ChatApi implementation to perform REST request to the Chat Backend --- chat-android/build.gradle.kts | 3 +- .../src/main/java/com/ably/chat/ChatApi.kt | 149 ++++++++++++++++++ .../src/main/java/com/ably/chat/ErrorCodes.kt | 25 +++ .../src/main/java/com/ably/chat/Message.kt | 4 +- .../java/com/ably/chat/PaginatedResult.kt | 66 ++++++++ gradle/libs.versions.toml | 3 + 6 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 chat-android/src/main/java/com/ably/chat/ChatApi.kt create mode 100644 chat-android/src/main/java/com/ably/chat/PaginatedResult.kt diff --git a/chat-android/build.gradle.kts b/chat-android/build.gradle.kts index da9bd211..ef4a0efa 100644 --- a/chat-android/build.gradle.kts +++ b/chat-android/build.gradle.kts @@ -34,7 +34,8 @@ android { } dependencies { - implementation(libs.ably.android) + api(libs.ably.android) + implementation(libs.gson) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.core) diff --git a/chat-android/src/main/java/com/ably/chat/ChatApi.kt b/chat-android/src/main/java/com/ably/chat/ChatApi.kt new file mode 100644 index 00000000..5b2ec0bd --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/ChatApi.kt @@ -0,0 +1,149 @@ +package com.ably.chat + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.http.HttpCore +import io.ably.lib.http.HttpUtils +import io.ably.lib.types.AblyException +import io.ably.lib.types.AsyncHttpPaginatedResponse +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.Param +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +private const val API_PROTOCOL_VERSION = 3 +private const val PROTOCOL_VERSION_PARAM_NAME = "v" +private val apiProtocolParam = Param(PROTOCOL_VERSION_PARAM_NAME, API_PROTOCOL_VERSION.toString()) + +// TODO make this class internal +class ChatApi(private val realtimeClient: RealtimeClient) { + + suspend fun getMessages(roomId: String, params: QueryOptions): PaginatedResult { + return makeAuthorizedPaginatedRequest( + url = "/chat/v1/rooms/$roomId/messages", + method = "GET", + params = params.toParams(), + ) { + Message( + timeserial = it.asJsonObject.get("timeserial").asString, + clientId = it.asJsonObject.get("clientId").asString, + roomId = it.asJsonObject.get("roomId").asString, + text = it.asJsonObject.get("text").asString, + createdAt = it.asJsonObject.get("createdAt").asLong, + metadata = it.asJsonObject.get("metadata")?.asJsonObject?.toMap() ?: mapOf(), + headers = it.asJsonObject.get("headers")?.asJsonObject?.toMap() ?: mapOf(), + ) + } + } + + suspend fun sendMessage(roomId: String, params: SendMessageParams): CreateMessageResponse { + val body = JsonObject().apply { + addProperty("text", params.text) + params.headers?.let { + add("headers", it.toJson()) + } + params.metadata?.let { + add("metadata", it.toJson()) + } + } + + return makeAuthorizedRequest( + "/chat/v1/rooms/$roomId/messages", + "POST", + body, + )?.let { + CreateMessageResponse( + timeserial = it.asJsonObject.get("timeserial").asString, + createdAt = it.asJsonObject.get("createdAt").asLong, + ) + } ?: throw AblyException.fromErrorInfo(ErrorInfo("Send message endpoint returned empty value", HttpStatusCodes.InternalServerError)) + } + + suspend fun getOccupancy(roomId: String): OccupancyEvent { + return this.makeAuthorizedRequest("/chat/v1/rooms/$roomId/occupancy", "GET")?.let { + OccupancyEvent( + connections = it.asJsonObject.get("connections").asInt, + presenceMembers = it.asJsonObject.get("presenceMembers").asInt, + ) + } ?: throw AblyException.fromErrorInfo(ErrorInfo("Occupancy endpoint returned empty value", HttpStatusCodes.InternalServerError)) + } + + private suspend fun makeAuthorizedRequest( + url: String, + method: String, + body: JsonElement? = null, + ): JsonElement? = suspendCoroutine { continuation -> + val requestBody = body.toRequestBody() + realtimeClient.requestAsync( + method, + url, + arrayOf(apiProtocolParam), + requestBody, + arrayOf(), + object : + AsyncHttpPaginatedResponse.Callback { + override fun onResponse(response: AsyncHttpPaginatedResponse?) { + continuation.resume(response?.items()?.firstOrNull()) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) + } + + private suspend fun makeAuthorizedPaginatedRequest( + url: String, + method: String, + params: List = listOf(), + transform: (JsonElement) -> T, + ): PaginatedResult = suspendCoroutine { continuation -> + realtimeClient.requestAsync( + method, + url, + (params + apiProtocolParam).toTypedArray(), + null, + arrayOf(), + object : + AsyncHttpPaginatedResponse.Callback { + override fun onResponse(response: AsyncHttpPaginatedResponse?) { + continuation.resume(response.toPaginatedResult(transform)) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) + } +} + +data class CreateMessageResponse(val timeserial: String, val createdAt: Long) + +private fun JsonElement?.toRequestBody(useBinaryProtocol: Boolean = false): HttpCore.RequestBody = + HttpUtils.requestBodyFromGson(this, useBinaryProtocol) + +private fun Map.toJson() = JsonObject().apply { + forEach { (key, value) -> addProperty(key, value) } +} + +private fun JsonObject.toMap() = buildMap { + entrySet().filter { (_, value) -> value.isJsonPrimitive }.forEach { (key, value) -> put(key, value.asString) } +} + +private fun QueryOptions.toParams() = buildList { + start?.let { add(Param("start", it)) } + end?.let { add(Param("end", it)) } + add(Param("limit", limit)) + add( + Param( + "direction", + when (orderBy) { + QueryOptions.MessageOrder.NewestFirst -> "backwards" + QueryOptions.MessageOrder.OldestFirst -> "forwards" + }, + ), + ) +} diff --git a/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt b/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt index 39b5f130..7b893ad4 100644 --- a/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt +++ b/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt @@ -87,4 +87,29 @@ object ErrorCodes { * An unknown error has happened in the room lifecycle. */ const val RoomLifecycleError = 102_105 + + /** + * The request cannot be understood + */ + const val BadRequest = 40_000 +} + +/** + * Http Status Codes + */ +object HttpStatusCodes { + + const val BadRequest = 400 + + const val Unauthorized = 401 + + const val InternalServerError = 500 + + const val NotImplemented = 501 + + const val ServiceUnavailable = 502 + + const val GatewayTimeout = 503 + + const val Timeout = 504 } diff --git a/chat-android/src/main/java/com/ably/chat/Message.kt b/chat-android/src/main/java/com/ably/chat/Message.kt index 6edee6ac..224f55ee 100644 --- a/chat-android/src/main/java/com/ably/chat/Message.kt +++ b/chat-android/src/main/java/com/ably/chat/Message.kt @@ -32,7 +32,7 @@ data class Message( /** * The text of the message. */ - val textval: String, + val text: String, /** * The timestamp at which the message was created. @@ -66,5 +66,5 @@ data class Message( * Do not use the headers for authoritative information. There is no server-side * validation. When reading the headers treat them like user input. */ - val headersval: MessageHeaders, + val headers: MessageHeaders, ) diff --git a/chat-android/src/main/java/com/ably/chat/PaginatedResult.kt b/chat-android/src/main/java/com/ably/chat/PaginatedResult.kt new file mode 100644 index 00000000..e32e4c3f --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/PaginatedResult.kt @@ -0,0 +1,66 @@ +package com.ably.chat + +import com.google.gson.JsonElement +import io.ably.lib.types.AblyException +import io.ably.lib.types.AsyncHttpPaginatedResponse +import io.ably.lib.types.ErrorInfo +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * Represents the result of a paginated query. + */ +interface PaginatedResult { + + /** + * The items returned by the query. + */ + val items: List + + /** + * Fetches the next page of items. + */ + suspend fun next(): PaginatedResult + + /** + * Whether there are more items to query. + * + * @returns `true` if there are more items to query, `false` otherwise. + */ + fun hasNext(): Boolean +} + +fun AsyncHttpPaginatedResponse?.toPaginatedResult(transform: (JsonElement) -> T): PaginatedResult = + this?.let { AsyncPaginatedResultWrapper(it, transform) } ?: EmptyPaginatedResult() + +private class EmptyPaginatedResult : PaginatedResult { + override val items: List + get() = emptyList() + + override suspend fun next(): PaginatedResult = this + + override fun hasNext(): Boolean = false +} + +private class AsyncPaginatedResultWrapper( + val asyncPaginatedResult: AsyncHttpPaginatedResponse, + val transform: (JsonElement) -> T, +) : PaginatedResult { + override val items: List + get() = asyncPaginatedResult.items()?.map(transform) ?: emptyList() + + override suspend fun next(): PaginatedResult = suspendCoroutine { continuation -> + asyncPaginatedResult.next(object : AsyncHttpPaginatedResponse.Callback { + override fun onResponse(response: AsyncHttpPaginatedResponse?) { + continuation.resume(response.toPaginatedResult(transform)) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }) + } + + override fun hasNext(): Boolean = asyncPaginatedResult.hasNext() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57ae9ceb..74c2c4ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ espresso-core = "3.6.1" lifecycle-runtime-ktx = "2.8.4" activity-compose = "1.9.1" compose-bom = "2024.06.00" +gson = "2.11.0" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -36,6 +37,8 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } + [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"} android-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }