diff --git a/chat-android/build.gradle.kts b/chat-android/build.gradle.kts index da9bd21..9fdde4f 100644 --- a/chat-android/build.gradle.kts +++ b/chat-android/build.gradle.kts @@ -34,9 +34,12 @@ android { } dependencies { - implementation(libs.ably.android) + api(libs.ably.android) + implementation(libs.gson) testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.coroutine.test) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.junit) 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 0000000..cbd9733 --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/ChatApi.kt @@ -0,0 +1,228 @@ +package com.ably.chat + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +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, private val clientId: String) { + + /** + * Get messages from the Chat Backend + * + * @return paginated result with messages + */ + suspend fun getMessages(roomId: String, params: QueryOptions): PaginatedResult { + return makeAuthorizedPaginatedRequest( + url = "/chat/v1/rooms/$roomId/messages", + method = "GET", + params = params.toParams(), + ) { + Message( + timeserial = it.requireString("timeserial"), + clientId = it.requireString("clientId"), + roomId = it.requireString("roomId"), + text = it.requireString("text"), + createdAt = it.requireLong("createdAt"), + metadata = it.asJsonObject.get("metadata")?.toMap() ?: mapOf(), + headers = it.asJsonObject.get("headers")?.toMap() ?: mapOf(), + ) + } + } + + /** + * Send message to the Chat Backend + * + * @return sent message instance + */ + suspend fun sendMessage(roomId: String, params: SendMessageParams): Message { + 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 { + Message( + timeserial = it.requireString("timeserial"), + clientId = clientId, + roomId = roomId, + text = params.text, + createdAt = it.requireLong("createdAt"), + metadata = params.metadata ?: mapOf(), + headers = params.headers ?: mapOf(), + ) + } ?: throw AblyException.fromErrorInfo(ErrorInfo("Send message endpoint returned empty value", HttpStatusCodes.InternalServerError)) + } + + /** + * return occupancy for specified room + */ + suspend fun getOccupancy(roomId: String): OccupancyEvent { + return this.makeAuthorizedRequest("/chat/v1/rooms/$roomId/occupancy", "GET")?.let { + OccupancyEvent( + connections = it.requireInt("connections"), + presenceMembers = it.requireInt("presenceMembers"), + ) + } ?: 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)) + } + }, + ) + } +} + +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 JsonElement.toMap() = buildMap { + requireJsonObject().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" + }, + ), + ) +} + +private fun JsonElement.requireJsonObject(): JsonObject { + if (!isJsonObject) { + throw AblyException.fromErrorInfo( + ErrorInfo("Response value expected to be JsonObject, got primitive instead", HttpStatusCodes.InternalServerError), + ) + } + return asJsonObject +} + +private fun JsonElement.requireString(memberName: String): String { + val memberElement = requireField(memberName) + if (!memberElement.isJsonPrimitive) { + throw AblyException.fromErrorInfo( + ErrorInfo( + "Value for \"$memberName\" field expected to be JsonPrimitive, got object instead", + HttpStatusCodes.InternalServerError, + ), + ) + } + return memberElement.asString +} + +private fun JsonElement.requireLong(memberName: String): Long { + val memberElement = requireJsonPrimitive(memberName) + try { + return memberElement.asLong + } catch (formatException: NumberFormatException) { + throw AblyException.fromErrorInfo( + formatException, + ErrorInfo("Required numeric field \"$memberName\" is not a valid long", HttpStatusCodes.InternalServerError), + ) + } +} + +private fun JsonElement.requireInt(memberName: String): Int { + val memberElement = requireJsonPrimitive(memberName) + try { + return memberElement.asInt + } catch (formatException: NumberFormatException) { + throw AblyException.fromErrorInfo( + formatException, + ErrorInfo("Required numeric field \"$memberName\" is not a valid int", HttpStatusCodes.InternalServerError), + ) + } +} + +private fun JsonElement.requireJsonPrimitive(memberName: String): JsonPrimitive { + val memberElement = requireField(memberName) + if (!memberElement.isJsonPrimitive) { + throw AblyException.fromErrorInfo( + ErrorInfo( + "Value for \"$memberName\" field expected to be JsonPrimitive, got object instead", + HttpStatusCodes.InternalServerError, + ), + ) + } + return memberElement.asJsonPrimitive +} + +private fun JsonElement.requireField(memberName: String): JsonElement = requireJsonObject().get(memberName) + ?: throw AblyException.fromErrorInfo( + ErrorInfo("Required field \"$memberName\" is missing", HttpStatusCodes.InternalServerError), + ) 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 39b5f13..7b893ad 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 6edee6a..224f55e 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 0000000..814bddf --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/PaginatedResult.kt @@ -0,0 +1,65 @@ +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 = 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/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt b/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt new file mode 100644 index 0000000..29e8069 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt @@ -0,0 +1,172 @@ +package com.ably.chat + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.types.AblyException +import io.ably.lib.types.AsyncHttpPaginatedResponse +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatApiTest { + + private val realtime = mockk(relaxed = true) + private val chatApi = ChatApi(realtime, "clientId") + + @Test + fun `getMessages should ignore unknown fields for Chat Backend`() = runTest { + every { + realtime.requestAsync("GET", "/chat/v1/rooms/roomId/messages", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("foo", "bar") + addProperty("timeserial", "timeserial") + addProperty("roomId", "roomId") + addProperty("clientId", "clientId") + addProperty("text", "hello") + addProperty("createdAt", 1_000_000) + }, + ), + ), + ) + } + + val messages = chatApi.getMessages("roomId", QueryOptions()) + + assertEquals( + listOf( + Message( + timeserial = "timeserial", + roomId = "roomId", + clientId = "clientId", + text = "hello", + createdAt = 1_000_000L, + metadata = mapOf(), + headers = mapOf(), + ), + ), + messages.items, + ) + } + + @Test + fun `getMessages should throws AblyException if some required fields are missing`() = runTest { + every { + realtime.requestAsync("GET", "/chat/v1/rooms/roomId/messages", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("foo", "bar") + }, + ), + ), + ) + } + + val exception = assertThrows(AblyException::class.java) { + runBlocking { chatApi.getMessages("roomId", QueryOptions()) } + } + + assertTrue(exception.message!!.matches(""".*Required field "\w+" is missing""".toRegex())) + } + + @Test + fun `sendMessage should ignore unknown fields for Chat Backend`() = runTest { + every { + realtime.requestAsync("POST", "/chat/v1/rooms/roomId/messages", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("foo", "bar") + addProperty("timeserial", "timeserial") + addProperty("createdAt", 1_000_000) + }, + ), + ), + ) + } + + val message = chatApi.sendMessage("roomId", SendMessageParams(text = "hello")) + + assertEquals( + Message( + timeserial = "timeserial", + roomId = "roomId", + clientId = "clientId", + text = "hello", + createdAt = 1_000_000L, + headers = mapOf(), + metadata = mapOf(), + ), + message, + ) + } + + @Test + fun `sendMessage should throw exception if 'timeserial' field is not presented`() = runTest { + every { + realtime.requestAsync("POST", "/chat/v1/rooms/roomId/messages", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("foo", "bar") + addProperty("createdAt", 1_000_000) + }, + ), + ), + ) + } + + assertThrows(AblyException::class.java) { + runBlocking { chatApi.sendMessage("roomId", SendMessageParams(text = "hello")) } + } + } + + @Test + fun `getOccupancy should throw exception if 'connections' field is not presented`() = runTest { + every { + realtime.requestAsync("GET", "/chat/v1/rooms/roomId/occupancy", any(), any(), any(), any()) + } answers { + val callback = lastArg() + callback.onResponse( + buildAsyncHttpPaginatedResponse( + listOf( + JsonObject().apply { + addProperty("presenceMembers", 1_000) + }, + ), + ), + ) + } + + assertThrows(AblyException::class.java) { + runBlocking { chatApi.getOccupancy("roomId") } + } + } +} + +private fun buildAsyncHttpPaginatedResponse(items: List): AsyncHttpPaginatedResponse { + val response = mockk() + every { + response.items() + } returns items.toTypedArray() + return response +} diff --git a/chat-android/src/test/java/com/ably/chat/ExampleUnitTest.kt b/chat-android/src/test/java/com/ably/chat/ExampleUnitTest.kt deleted file mode 100644 index 66a4ad7..0000000 --- a/chat-android/src/test/java/com/ably/chat/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.ably.chat - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/detekt.yml b/detekt.yml index cc8eda1..3995e03 100644 --- a/detekt.yml +++ b/detekt.yml @@ -148,6 +148,7 @@ complexity: ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] TooManyFunctions: active: true thresholdInFiles: 22 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57ae9ce..0bc8708 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,9 @@ 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" +mockk = "1.13.12" +coroutine = "1.8.1" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -36,6 +39,13 @@ 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" } + +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } + +coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" } +coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } + [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"} android-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }