Skip to content

Commit

Permalink
[ECO-4962] feat: add ChatApi implementation
Browse files Browse the repository at this point in the history
Created basic ChatApi implementation to perform REST request to the Chat Backend
  • Loading branch information
ttypic committed Sep 3, 2024
1 parent 6b55e22 commit 727b8d2
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 3 deletions.
3 changes: 2 additions & 1 deletion chat-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
149 changes: 149 additions & 0 deletions chat-android/src/main/java/com/ably/chat/ChatApi.kt
Original file line number Diff line number Diff line change
@@ -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<Message> {
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 <T> makeAuthorizedPaginatedRequest(
url: String,
method: String,
params: List<Param> = listOf(),
transform: (JsonElement) -> T,
): PaginatedResult<T> = 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<String, String>.toJson() = JsonObject().apply {
forEach { (key, value) -> addProperty(key, value) }
}

private fun JsonObject.toMap() = buildMap<String, String> {
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"
},
),
)
}
25 changes: 25 additions & 0 deletions chat-android/src/main/java/com/ably/chat/ErrorCodes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 2 additions & 2 deletions chat-android/src/main/java/com/ably/chat/Message.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)
66 changes: 66 additions & 0 deletions chat-android/src/main/java/com/ably/chat/PaginatedResult.kt
Original file line number Diff line number Diff line change
@@ -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<T> {

/**
* The items returned by the query.
*/
val items: List<T>

/**
* Fetches the next page of items.
*/
suspend fun next(): PaginatedResult<T>

/**
* Whether there are more items to query.
*
* @returns `true` if there are more items to query, `false` otherwise.
*/
fun hasNext(): Boolean
}

fun <T> AsyncHttpPaginatedResponse?.toPaginatedResult(transform: (JsonElement) -> T): PaginatedResult<T> =
this?.let { AsyncPaginatedResultWrapper(it, transform) } ?: EmptyPaginatedResult()

private class EmptyPaginatedResult<T> : PaginatedResult<T> {
override val items: List<T>
get() = emptyList()

override suspend fun next(): PaginatedResult<T> = this

override fun hasNext(): Boolean = false
}

private class AsyncPaginatedResultWrapper<T>(
val asyncPaginatedResult: AsyncHttpPaginatedResponse,
val transform: (JsonElement) -> T,
) : PaginatedResult<T> {
override val items: List<T>
get() = asyncPaginatedResult.items()?.map(transform) ?: emptyList()

override suspend fun next(): PaginatedResult<T> = 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()
}
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down

0 comments on commit 727b8d2

Please sign in to comment.