Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ECO-4962] feat: add ChatApi implementation #9

Merged
merged 2 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
ttypic marked this conversation as resolved.
Show resolved Hide resolved
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
ttypic marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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
ttypic marked this conversation as resolved.
Show resolved Hide resolved
}
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> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use lightweight version of PaginatedResult, not sure we actually need first() and isLast() methods


/**
* 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()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when would EmptyPaginatedResult() get returned?

Copy link
Collaborator Author

@ttypic ttypic Sep 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now when there is no body, core Java SDK return null. I don't like this because we already return container, that can be empty, so I return EmptyPaginatedResult instead. For end user there is no difference between empty list and empty body, the same behavior we have in js.

The other case is when we invoke next() and hasNext() is false. I personally would throw exception in this case, but decided not to do this, because we don't throw exception in core SDK, and silently return null. Once again don't want to return null, because you definitely don't want to do null-checks here.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting - I personally think returning null makes more sense when looking at the code but I am coming from an optional-centric (and crazy) language in Swift. This will likely be one of the points in which we deviate between Kotlin and Swift due to platform best practices

Copy link
Collaborator Author

@ttypic ttypic Sep 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure it's Kotlin best practice, the best practice probably would be throwing exceptions, but I decide to silence them as Ably Java does. This will be an extremely small change.

And about optional-centricity: from the SDK user’s perspective, I personally don’t think PaginatedResult#next() should return a nullable result, both for Swift and Kotlin—I’ve never seen this before, except in Ably Java. :) Users will have to handle this nullability on their own, but there is no reason for that.


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