Skip to content

Commit

Permalink
Merge pull request #9 from ably-labs/ECO-4962/chat-api-implementation
Browse files Browse the repository at this point in the history
[ECO-4962] feat: add `ChatApi` implementation
  • Loading branch information
ttypic authored Sep 5, 2024
2 parents 3fd39eb + 783cd4a commit 0e0a264
Show file tree
Hide file tree
Showing 9 changed files with 507 additions and 19 deletions.
5 changes: 4 additions & 1 deletion chat-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
228 changes: 228 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,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<Message> {
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 <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))
}
},
)
}
}

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 JsonElement.toMap() = buildMap<String, String> {
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),
)
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,
)
65 changes: 65 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,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<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> = 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()
}
Loading

0 comments on commit 0e0a264

Please sign in to comment.