mirror of
https://github.com/trailbaseio/trailbase.git
synced 2026-05-20 00:09:21 -05:00
Add initial support for RecordApi subscriptions to Kotlin client and update dependencies.
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
[versions]
|
||||
agp = "9.0.1"
|
||||
junit = "6.0.3"
|
||||
kotlin = "2.2.20"
|
||||
kotlin = "2.3.10"
|
||||
kotlin-gradle-plugin = "2.1.20"
|
||||
ktor = "3.2.3"
|
||||
ktor = "3.4.1"
|
||||
maven-publish = "0.34.0"
|
||||
spotless = "8.3.0"
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ kotlin {
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.junit.jupiter)
|
||||
runtimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ import io.ktor.client.*
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.sse.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
@@ -20,117 +23,148 @@ data class Tokens(val auth_token: String, val refresh_token: String?, val csrf_t
|
||||
|
||||
@Serializable
|
||||
data class JwtTokenClaims(
|
||||
val sub: String,
|
||||
val iat: Long,
|
||||
val exp: Long,
|
||||
val email: String,
|
||||
val csrf_token: String
|
||||
val sub: String,
|
||||
val iat: Long,
|
||||
val exp: Long,
|
||||
val email: String,
|
||||
val csrf_token: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class DbEvent {
|
||||
public class Update(val obj: JsonObject) : DbEvent()
|
||||
public class Insert(val obj: JsonObject) : DbEvent()
|
||||
public class Delete(val obj: JsonObject) : DbEvent()
|
||||
public class Error(val msg: String) : DbEvent()
|
||||
|
||||
companion object {
|
||||
fun from(obj: JsonObject): DbEvent? {
|
||||
val update = obj.get("Update")
|
||||
if (update != null) {
|
||||
return DbEvent.Update(update.jsonObject)
|
||||
}
|
||||
val insert = obj.get("Insert")
|
||||
if (insert != null) {
|
||||
return DbEvent.Insert(insert.jsonObject)
|
||||
}
|
||||
val delete = obj.get("Delete")
|
||||
if (delete != null) {
|
||||
return DbEvent.Delete(delete.jsonObject)
|
||||
}
|
||||
val error = obj.get("Error")
|
||||
if (error != null) {
|
||||
return DbEvent.Error("${error}")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TokenState(val state: Pair<Tokens, JwtTokenClaims>?, val headers: Map<String, List<String>>) {
|
||||
companion object {
|
||||
fun build(tokens: Tokens?): TokenState {
|
||||
return TokenState(
|
||||
if (tokens != null) Pair(tokens, decodeJwtTokenClaims(tokens.auth_token)) else null,
|
||||
buildHeaders(tokens)
|
||||
)
|
||||
}
|
||||
companion object {
|
||||
fun build(tokens: Tokens?): TokenState {
|
||||
return TokenState(
|
||||
if (tokens != null) Pair(tokens, decodeJwtTokenClaims(tokens.auth_token)) else null,
|
||||
buildHeaders(tokens)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun user(): User? {
|
||||
val jwt = state?.second
|
||||
return if (jwt != null) User(jwt.sub, jwt.email) else null
|
||||
}
|
||||
fun user(): User? {
|
||||
val jwt = state?.second
|
||||
return if (jwt != null) User(jwt.sub, jwt.email) else null
|
||||
}
|
||||
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
internal fun shouldRefresh(): String? {
|
||||
if (state != null) {
|
||||
val now = Clock.System.now().toEpochMilliseconds() / 1000
|
||||
if (state.second.exp - 60 < now) {
|
||||
return state.first.refresh_token
|
||||
}
|
||||
}
|
||||
return null
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
internal fun shouldRefresh(): String? {
|
||||
if (state != null) {
|
||||
val now = Clock.System.now().toEpochMilliseconds() / 1000
|
||||
if (state.second.exp - 60 < now) {
|
||||
return state.first.refresh_token
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
sealed class RecordId {
|
||||
abstract fun id(): String
|
||||
abstract fun id(): String
|
||||
|
||||
companion object {
|
||||
fun uuid(id: String): RecordId {
|
||||
return StringRecordId(id)
|
||||
}
|
||||
|
||||
fun string(id: String): RecordId {
|
||||
return StringRecordId(id)
|
||||
}
|
||||
|
||||
fun int(id: Int): RecordId {
|
||||
return IntegerRecordId(id)
|
||||
}
|
||||
companion object {
|
||||
fun uuid(id: String): RecordId {
|
||||
return StringRecordId(id)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other is RecordId && id() == other.id()
|
||||
fun string(id: String): RecordId {
|
||||
return StringRecordId(id)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return id()
|
||||
fun int(id: Int): RecordId {
|
||||
return IntegerRecordId(id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other is RecordId && id() == other.id()
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return id()
|
||||
}
|
||||
}
|
||||
|
||||
class StringRecordId(private val id: String) : RecordId() {
|
||||
override fun id(): String {
|
||||
return id
|
||||
}
|
||||
override fun id(): String {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
class IntegerRecordId(private val id: Int) : RecordId() {
|
||||
override fun id(): String {
|
||||
return id.toString()
|
||||
}
|
||||
override fun id(): String {
|
||||
return id.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable private data class ResponseRecordIds(val ids: List<String>)
|
||||
|
||||
@Serializable
|
||||
data class ListResponse<T>(
|
||||
val records: List<T>,
|
||||
val cursor: String? = null,
|
||||
val total_count: Int? = null
|
||||
val records: List<T>,
|
||||
val cursor: String? = null,
|
||||
val total_count: Int? = null
|
||||
)
|
||||
|
||||
class Pagination(val cursor: String? = null, val limit: Int? = null, val offset: Int? = null) {}
|
||||
|
||||
enum class CompareOp {
|
||||
equal,
|
||||
notEqual,
|
||||
lessThan,
|
||||
lessThanEqual,
|
||||
greaterThan,
|
||||
greaterThanEqual,
|
||||
like,
|
||||
regexp,
|
||||
stWithin,
|
||||
stIntersects,
|
||||
stContains,
|
||||
equal,
|
||||
notEqual,
|
||||
lessThan,
|
||||
lessThanEqual,
|
||||
greaterThan,
|
||||
greaterThanEqual,
|
||||
like,
|
||||
regexp,
|
||||
stWithin,
|
||||
stIntersects,
|
||||
stContains,
|
||||
}
|
||||
|
||||
private fun opToString(op: CompareOp): String {
|
||||
return when (op) {
|
||||
CompareOp.equal -> "\$eq"
|
||||
CompareOp.notEqual -> "\$ne"
|
||||
CompareOp.lessThan -> "\$lt"
|
||||
CompareOp.lessThanEqual -> "\$lte"
|
||||
CompareOp.greaterThan -> "\$gt"
|
||||
CompareOp.greaterThanEqual -> "\$gte"
|
||||
CompareOp.like -> "\$like"
|
||||
CompareOp.regexp -> "\$re"
|
||||
CompareOp.stWithin -> "@within"
|
||||
CompareOp.stIntersects -> "@intersects"
|
||||
CompareOp.stContains -> "@contains"
|
||||
}
|
||||
return when (op) {
|
||||
CompareOp.equal -> "\$eq"
|
||||
CompareOp.notEqual -> "\$ne"
|
||||
CompareOp.lessThan -> "\$lt"
|
||||
CompareOp.lessThanEqual -> "\$lte"
|
||||
CompareOp.greaterThan -> "\$gt"
|
||||
CompareOp.greaterThanEqual -> "\$gte"
|
||||
CompareOp.like -> "\$like"
|
||||
CompareOp.regexp -> "\$re"
|
||||
CompareOp.stWithin -> "@within"
|
||||
CompareOp.stIntersects -> "@intersects"
|
||||
CompareOp.stContains -> "@contains"
|
||||
}
|
||||
}
|
||||
|
||||
sealed class FilterBase {}
|
||||
@@ -142,272 +176,308 @@ class And(val filters: List<FilterBase>) : FilterBase() {}
|
||||
class Or(val filters: List<FilterBase>) : FilterBase() {}
|
||||
|
||||
class RecordApi(val name: String, val client: Client) {
|
||||
suspend inline fun <reified T> read(id: RecordId, expand: List<String>? = null): T {
|
||||
return client
|
||||
.fetch(
|
||||
"${RECORD_API}/${name}/${id.id()}",
|
||||
params =
|
||||
if (expand != null) mapOf(Pair("expand", expand.joinToString(","))) else null
|
||||
suspend inline fun <reified T> read(id: RecordId, expand: List<String>? = null): T {
|
||||
return client.fetch(
|
||||
"${RECORD_API}/${name}/${id.id()}",
|
||||
params =
|
||||
if (expand != null) mapOf(Pair("expand", expand.joinToString(",")))
|
||||
else null
|
||||
)
|
||||
.body()
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> list(
|
||||
pagination: Pagination? = null,
|
||||
order: List<String>? = null,
|
||||
filters: List<FilterBase>? = null,
|
||||
count: Boolean = false,
|
||||
expand: List<String>? = null,
|
||||
): ListResponse<T> {
|
||||
val params: MutableMap<String, String> = mutableMapOf()
|
||||
if (pagination != null) {
|
||||
val cursor = pagination.cursor
|
||||
if (cursor != null) params["cursor"] = cursor
|
||||
|
||||
val limit = pagination.limit
|
||||
if (limit != null) params["limit"] = limit.toString()
|
||||
|
||||
val offset = pagination.offset
|
||||
if (offset != null) params["offset"] = offset.toString()
|
||||
}
|
||||
if (order != null) {
|
||||
params["order"] = order.joinToString(",")
|
||||
}
|
||||
if (count) {
|
||||
params["count"] = "true"
|
||||
}
|
||||
if (expand != null) {
|
||||
params["expand"] = expand.joinToString(",")
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> list(
|
||||
pagination: Pagination? = null,
|
||||
order: List<String>? = null,
|
||||
filters: List<FilterBase>? = null,
|
||||
count: Boolean = false,
|
||||
expand: List<String>? = null,
|
||||
): ListResponse<T> {
|
||||
val params: MutableMap<String, String> = mutableMapOf()
|
||||
if (pagination != null) {
|
||||
val cursor = pagination.cursor
|
||||
if (cursor != null) params["cursor"] = cursor
|
||||
filters?.forEach { addFiltersToParams(params, "filter", it) }
|
||||
|
||||
val limit = pagination.limit
|
||||
if (limit != null) params["limit"] = limit.toString()
|
||||
return client.fetch("${RECORD_API}/${name}", params = params).body()
|
||||
}
|
||||
|
||||
val offset = pagination.offset
|
||||
if (offset != null) params["offset"] = offset.toString()
|
||||
suspend fun <T> create(record: T): RecordId {
|
||||
val response = client.fetch("${RECORD_API}/${name}", Method.post, record)
|
||||
val ids: ResponseRecordIds = response.body()
|
||||
return StringRecordId(ids.ids[0])
|
||||
}
|
||||
|
||||
suspend fun <T> update(id: RecordId, record: T) {
|
||||
client.fetch("${RECORD_API}/${name}/${id.id()}", Method.patch, record)
|
||||
}
|
||||
|
||||
suspend fun delete(id: RecordId) {
|
||||
client.fetch("${RECORD_API}/${name}/${id.id()}", Method.delete)
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> subscribe(id: RecordId): Flow<DbEvent> {
|
||||
|
||||
val url = "${client.site()}/${RECORD_API}/${name}/subscribe/${id.id()}"
|
||||
val tokenState = TokenState.build(client.tokens())
|
||||
|
||||
val session =
|
||||
client.http.sseSession(
|
||||
urlString = url,
|
||||
block = {
|
||||
method = HttpMethod.Get
|
||||
headers { tokenState.headers.forEach { appendAll(it.key, it.value) } }
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
)
|
||||
|
||||
// Check HTTP status before processing SSE events
|
||||
if (!session.call.response.status.isSuccess()) {
|
||||
throw Exception("Stream connection failed: ${session.call.response.status}")
|
||||
}
|
||||
|
||||
return flow {
|
||||
session.incoming.collect { ev ->
|
||||
// event.data?.takeIf { predicate }(predicate)
|
||||
val data = ev.data
|
||||
if (data != null) {
|
||||
val obj: JsonObject = jsonSerializer.decodeFromString(data)
|
||||
val event = DbEvent.from(obj)
|
||||
if (event != null) {
|
||||
emit(event)
|
||||
}
|
||||
}
|
||||
if (order != null) {
|
||||
params["order"] = order.joinToString(",")
|
||||
}
|
||||
if (count) {
|
||||
params["count"] = "true"
|
||||
}
|
||||
if (expand != null) {
|
||||
params["expand"] = expand.joinToString(",")
|
||||
}
|
||||
|
||||
filters?.forEach { addFiltersToParams(params, "filter", it) }
|
||||
|
||||
return client.fetch("${RECORD_API}/${name}", params = params).body()
|
||||
}
|
||||
|
||||
suspend fun <T> create(record: T): RecordId {
|
||||
val response = client.fetch("${RECORD_API}/${name}", Method.post, record)
|
||||
val ids: ResponseRecordIds = response.body()
|
||||
return StringRecordId(ids.ids[0])
|
||||
}
|
||||
|
||||
suspend fun <T> update(id: RecordId, record: T) {
|
||||
client.fetch("${RECORD_API}/${name}/${id.id()}", Method.patch, record)
|
||||
}
|
||||
|
||||
suspend fun delete(id: RecordId) {
|
||||
client.fetch("${RECORD_API}/${name}/${id.id()}", Method.delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Method {
|
||||
get,
|
||||
post,
|
||||
patch,
|
||||
delete,
|
||||
get,
|
||||
post,
|
||||
patch,
|
||||
delete,
|
||||
}
|
||||
|
||||
class HttpException(val status: Int, message: String?) : Throwable(message) {}
|
||||
|
||||
class Client(
|
||||
private val site: Url,
|
||||
private var tokenState: TokenState,
|
||||
private val http: HttpClient = initClient()
|
||||
private val site: Url,
|
||||
private var tokenState: TokenState,
|
||||
public val http: HttpClient = initClient()
|
||||
) {
|
||||
constructor(
|
||||
site: String
|
||||
) : this(
|
||||
Url(site),
|
||||
TokenState.build(null),
|
||||
)
|
||||
constructor(
|
||||
site: String
|
||||
) : this(
|
||||
Url(site),
|
||||
TokenState.build(null),
|
||||
)
|
||||
|
||||
companion object {
|
||||
suspend fun withTokens(site: String, tokens: Tokens): Client {
|
||||
val client = Client(site)
|
||||
client.tokenState = TokenState.build(tokens)
|
||||
client.refreshAuthToken()
|
||||
return client
|
||||
}
|
||||
companion object {
|
||||
suspend fun withTokens(site: String, tokens: Tokens): Client {
|
||||
val client = Client(site)
|
||||
client.tokenState = TokenState.build(tokens)
|
||||
client.refreshAuthToken()
|
||||
return client
|
||||
}
|
||||
}
|
||||
|
||||
fun site(): Url {
|
||||
return this.site
|
||||
}
|
||||
fun site(): Url {
|
||||
return this.site
|
||||
}
|
||||
|
||||
fun tokens(): Tokens? {
|
||||
return tokenState.state?.first
|
||||
}
|
||||
fun tokens(): Tokens? {
|
||||
return tokenState.state?.first
|
||||
}
|
||||
|
||||
fun user(): User? {
|
||||
return tokenState.user()
|
||||
}
|
||||
fun user(): User? {
|
||||
return tokenState.user()
|
||||
}
|
||||
|
||||
fun records(name: String): RecordApi {
|
||||
return RecordApi(name, this)
|
||||
}
|
||||
fun records(name: String): RecordApi {
|
||||
return RecordApi(name, this)
|
||||
}
|
||||
|
||||
suspend fun login(email: String, password: String): Tokens {
|
||||
@Serializable data class Credentials(val email: String, val password: String)
|
||||
suspend fun login(email: String, password: String): Tokens {
|
||||
@Serializable data class Credentials(val email: String, val password: String)
|
||||
|
||||
val tokens: Tokens =
|
||||
val tokens: Tokens =
|
||||
fetch("${AUTH_API}/login", Method.post, Credentials(email, password)).body()
|
||||
tokenState = TokenState.build(tokens)
|
||||
return tokens
|
||||
tokenState = TokenState.build(tokens)
|
||||
return tokens
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
try {
|
||||
val refreshToken = tokenState.state?.first?.refresh_token
|
||||
if (refreshToken != null) {
|
||||
@Serializable data class Body(val refresh_token: String)
|
||||
|
||||
fetch("${AUTH_API}/logout", Method.post, Body(refreshToken))
|
||||
} else {
|
||||
fetch("${AUTH_API}/logout")
|
||||
}
|
||||
} finally {
|
||||
tokenState = TokenState.build(null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshAuthToken() {
|
||||
val refreshToken = tokenState.shouldRefresh()
|
||||
if (refreshToken != null) {
|
||||
tokenState = refreshTokensImpl(refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetch(
|
||||
path: String,
|
||||
method: Method = Method.get,
|
||||
body: Any? = null,
|
||||
params: Map<String, String>? = null,
|
||||
): HttpResponse {
|
||||
val refreshToken = tokenState.shouldRefresh()
|
||||
if (refreshToken != null) {
|
||||
tokenState = refreshTokensImpl(refreshToken)
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
try {
|
||||
val refreshToken = tokenState.state?.first?.refresh_token
|
||||
if (refreshToken != null) {
|
||||
@Serializable data class Body(val refresh_token: String)
|
||||
|
||||
fetch("${AUTH_API}/logout", Method.post, Body(refreshToken))
|
||||
} else {
|
||||
fetch("${AUTH_API}/logout")
|
||||
}
|
||||
} finally {
|
||||
tokenState = TokenState.build(null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshAuthToken() {
|
||||
val refreshToken = tokenState.shouldRefresh()
|
||||
if (refreshToken != null) {
|
||||
tokenState = refreshTokensImpl(refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetch(
|
||||
path: String,
|
||||
method: Method = Method.get,
|
||||
body: Any? = null,
|
||||
params: Map<String, String>? = null,
|
||||
): HttpResponse {
|
||||
val refreshToken = tokenState.shouldRefresh()
|
||||
if (refreshToken != null) {
|
||||
tokenState = refreshTokensImpl(refreshToken)
|
||||
}
|
||||
|
||||
val response =
|
||||
val response =
|
||||
http.request(site) {
|
||||
this.method =
|
||||
when (method) {
|
||||
this.method =
|
||||
when (method) {
|
||||
Method.get -> HttpMethod.Get
|
||||
Method.post -> HttpMethod.Post
|
||||
Method.patch -> HttpMethod.Patch
|
||||
Method.delete -> HttpMethod.Delete
|
||||
}
|
||||
url {
|
||||
path(path)
|
||||
if (params != null) {
|
||||
for ((k, v) in params) {
|
||||
parameters.append(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
url {
|
||||
path(path)
|
||||
if (params != null) {
|
||||
for ((k, v) in params) {
|
||||
parameters.append(k, v)
|
||||
}
|
||||
}
|
||||
headers { tokenState.headers.forEach { appendAll(it.key, it.value) } }
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}
|
||||
headers { tokenState.headers.forEach { appendAll(it.key, it.value) } }
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}
|
||||
|
||||
if (!response.status.isSuccess()) {
|
||||
throw HttpException(response.status.value, response.body())
|
||||
}
|
||||
|
||||
return response
|
||||
if (!response.status.isSuccess()) {
|
||||
throw HttpException(response.status.value, response.body())
|
||||
}
|
||||
|
||||
private suspend fun refreshTokensImpl(refreshToken: String): TokenState {
|
||||
@Serializable data class Body(val refresh_token: String)
|
||||
return response
|
||||
}
|
||||
|
||||
val tokens: Tokens =
|
||||
private suspend fun refreshTokensImpl(refreshToken: String): TokenState {
|
||||
@Serializable data class Body(val refresh_token: String)
|
||||
|
||||
val tokens: Tokens =
|
||||
http
|
||||
.post(site) {
|
||||
url { path("${AUTH_API}/refresh") }
|
||||
contentType(ContentType.Application.Json)
|
||||
headers { tokenState.headers.forEach { appendAll(it.key, it.value) } }
|
||||
setBody(Body(refreshToken))
|
||||
}
|
||||
.body()
|
||||
.post(site) {
|
||||
url { path("${AUTH_API}/refresh") }
|
||||
contentType(ContentType.Application.Json)
|
||||
headers { tokenState.headers.forEach { appendAll(it.key, it.value) } }
|
||||
setBody(Body(refreshToken))
|
||||
}
|
||||
.body()
|
||||
|
||||
return TokenState.build(tokens)
|
||||
}
|
||||
return TokenState.build(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initClient(): HttpClient {
|
||||
return HttpClient(CIO.create()) {
|
||||
install(ContentNegotiation) {
|
||||
// Register Kotlinx.serialization converter
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
)
|
||||
}
|
||||
return HttpClient(CIO.create()) {
|
||||
install(SSE)
|
||||
install(ContentNegotiation) {
|
||||
// Register Kotlinx.serialization converter
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildHeaders(tokens: Tokens?): Map<String, List<String>> {
|
||||
val headers: MutableMap<String, List<String>> = mutableMapOf()
|
||||
val headers: MutableMap<String, List<String>> = mutableMapOf()
|
||||
|
||||
if (tokens != null) {
|
||||
headers["Authorization"] = listOf("Bearer ${tokens.auth_token}")
|
||||
if (tokens != null) {
|
||||
headers["Authorization"] = listOf("Bearer ${tokens.auth_token}")
|
||||
|
||||
val refresh = tokens.refresh_token
|
||||
if (refresh != null) {
|
||||
headers["Refresh-Token"] = listOf(refresh)
|
||||
}
|
||||
|
||||
val csrf = tokens.csrf_token
|
||||
if (csrf != null) {
|
||||
headers["CSRF-Token"] = listOf(csrf)
|
||||
}
|
||||
val refresh = tokens.refresh_token
|
||||
if (refresh != null) {
|
||||
headers["Refresh-Token"] = listOf(refresh)
|
||||
}
|
||||
|
||||
return headers
|
||||
val csrf = tokens.csrf_token
|
||||
if (csrf != null) {
|
||||
headers["CSRF-Token"] = listOf(csrf)
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
val jsonSerializer = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
|
||||
private fun decodeJwtTokenClaims(jwt: String): JwtTokenClaims {
|
||||
val parts = jwt.split('.')
|
||||
if (parts.size != 3) {
|
||||
throw Exception("Invalid JWT format")
|
||||
}
|
||||
val parts = jwt.split('.')
|
||||
if (parts.size != 3) {
|
||||
throw Exception("Invalid JWT format")
|
||||
}
|
||||
|
||||
val decoded =
|
||||
Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
|
||||
.decode(parts[1])
|
||||
.decodeToString()
|
||||
val decoded =
|
||||
Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
|
||||
.decode(parts[1])
|
||||
.decodeToString()
|
||||
|
||||
return Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
.decodeFromString(decoded)
|
||||
return jsonSerializer.decodeFromString(decoded)
|
||||
}
|
||||
|
||||
fun addFiltersToParams(params: MutableMap<String, String>, path: String, filter: FilterBase) {
|
||||
when (filter) {
|
||||
is Filter -> {
|
||||
if (filter.op != null) {
|
||||
params["${path}[${filter.column}][${opToString(filter.op)}]"] = filter.value
|
||||
} else {
|
||||
params["${path}[${filter.column}]"] = filter.value
|
||||
}
|
||||
}
|
||||
is And -> {
|
||||
for ((i, f) in filter.filters.withIndex()) {
|
||||
addFiltersToParams(params, "${path}[\$and][${i}]", f)
|
||||
}
|
||||
}
|
||||
is Or -> {
|
||||
for ((i, f) in filter.filters.withIndex()) {
|
||||
addFiltersToParams(params, "${path}[\$or][${i}]", f)
|
||||
}
|
||||
}
|
||||
when (filter) {
|
||||
is Filter -> {
|
||||
if (filter.op != null) {
|
||||
params["${path}[${filter.column}][${opToString(filter.op)}]"] = filter.value
|
||||
} else {
|
||||
params["${path}[${filter.column}]"] = filter.value
|
||||
}
|
||||
}
|
||||
|
||||
is And -> {
|
||||
for ((i, f) in filter.filters.withIndex()) {
|
||||
addFiltersToParams(params, "${path}[\$and][${i}]", f)
|
||||
}
|
||||
}
|
||||
is Or -> {
|
||||
for ((i, f) in filter.filters.withIndex()) {
|
||||
addFiltersToParams(params, "${path}[\$or][${i}]", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val AUTH_API: String = "api/auth/v1"
|
||||
|
||||
@@ -15,11 +15,16 @@ import kotlin.io.path.name
|
||||
import kotlin.test.*
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.test.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import kotlinx.serialization.json.*
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.MethodOrderer
|
||||
import org.junit.jupiter.api.Order
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.api.TestMethodOrder
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
@Serializable data class SimpleStrict(val id: String, val text_not_null: String)
|
||||
@@ -29,196 +34,242 @@ import org.junit.jupiter.api.assertThrows
|
||||
@Serializable data class SimpleStrictUpdate(val text_not_null: String?)
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
|
||||
// @TestClassOrder(ClassOrderer.OrderAnnotation.class)
|
||||
class ClientTest {
|
||||
companion object {
|
||||
const val port = 4061
|
||||
const val address = "127.0.0.1:${port}"
|
||||
var process: Process? = null
|
||||
}
|
||||
companion object {
|
||||
const val port = 4061
|
||||
const val address = "127.0.0.1:${port}"
|
||||
var process: Process? = null
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
fun setUpAll() {
|
||||
val workingDirectory: Path = Paths.get("").toAbsolutePath().parent
|
||||
assertEquals("kotlin", workingDirectory.name)
|
||||
// Depot path relative to working directory.
|
||||
val depotPath = "../testfixture"
|
||||
@BeforeAll
|
||||
fun setUpAll() {
|
||||
val workingDirectory: Path = Paths.get("").toAbsolutePath().parent
|
||||
assertEquals("kotlin", workingDirectory.name)
|
||||
// Depot path relative to working directory.
|
||||
val depotPath = "../testfixture"
|
||||
|
||||
val result =
|
||||
ProcessBuilder("cargo", "build")
|
||||
val result =
|
||||
ProcessBuilder("cargo", "build")
|
||||
.directory(workingDirectory.toFile())
|
||||
.redirectOutput(ProcessBuilder.Redirect.INHERIT)
|
||||
.redirectError(ProcessBuilder.Redirect.INHERIT)
|
||||
.start()
|
||||
.waitFor()
|
||||
if (result > 0) {
|
||||
throw Exception()
|
||||
}
|
||||
if (result > 0) {
|
||||
throw Exception()
|
||||
}
|
||||
|
||||
process =
|
||||
ProcessBuilder(
|
||||
"cargo",
|
||||
"run",
|
||||
"--",
|
||||
"--data-dir=${depotPath}",
|
||||
"run",
|
||||
"--address=${address}",
|
||||
"--runtime-threads=2"
|
||||
process =
|
||||
ProcessBuilder(
|
||||
"cargo",
|
||||
"run",
|
||||
"--",
|
||||
"--data-dir=${depotPath}",
|
||||
"run",
|
||||
"--address=${address}",
|
||||
"--runtime-threads=2"
|
||||
)
|
||||
.directory(workingDirectory.toFile())
|
||||
.redirectOutput(ProcessBuilder.Redirect.INHERIT)
|
||||
.redirectError(ProcessBuilder.Redirect.INHERIT)
|
||||
.start()
|
||||
|
||||
var success = false
|
||||
runBlocking {
|
||||
val client = HttpClient(CIO.create())
|
||||
val url = Url("http://${address}/api/healthcheck")
|
||||
var success = false
|
||||
runBlocking {
|
||||
val client = HttpClient(CIO.create())
|
||||
val url = Url("http://${address}/api/healthcheck")
|
||||
|
||||
for (i in 0..100) {
|
||||
try {
|
||||
val response = client.get(url)
|
||||
if (response.status.isSuccess()) {
|
||||
success = true
|
||||
break
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
println("Trying to connect to TrailBase ${i+1}/50: ${err}")
|
||||
}
|
||||
|
||||
// No point in waiting longer.
|
||||
if (process?.isAlive() == false) {
|
||||
break
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
process?.destroyForcibly()
|
||||
throw Exception("Cargo run failed: ${process?.exitValue()}")
|
||||
}
|
||||
for (i in 0..100) {
|
||||
try {
|
||||
val response = client.get(url)
|
||||
if (response.status.isSuccess()) {
|
||||
success = true
|
||||
break
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
println("Trying to connect to TrailBase ${i+1}/50: ${err}")
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun tearDownAll() {
|
||||
process?.destroyForcibly()?.waitFor()
|
||||
process?.exitValue()
|
||||
// No point in waiting longer.
|
||||
if (process?.isAlive() == false) {
|
||||
break
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filter params`() {
|
||||
val params: MutableMap<String, String> = mutableMapOf()
|
||||
if (!success) {
|
||||
process?.destroyForcibly()
|
||||
throw Exception("Cargo run failed: ${process?.exitValue()}")
|
||||
}
|
||||
}
|
||||
|
||||
val filters =
|
||||
@AfterAll
|
||||
fun tearDownAll() {
|
||||
process?.destroyForcibly()?.waitFor()
|
||||
process?.exitValue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filter params`() {
|
||||
val params: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
val filters =
|
||||
listOf(
|
||||
Filter("col0", "0", CompareOp.greaterThan),
|
||||
Filter("col0", "5", CompareOp.lessThan)
|
||||
Filter("col0", "0", CompareOp.greaterThan),
|
||||
Filter("col0", "5", CompareOp.lessThan)
|
||||
)
|
||||
for (filter in filters) {
|
||||
addFiltersToParams(params, "filter", filter)
|
||||
}
|
||||
|
||||
assertEquals(2, params.size)
|
||||
for (filter in filters) {
|
||||
addFiltersToParams(params, "filter", filter)
|
||||
}
|
||||
|
||||
// WARN: TrailBase binding to localhost:4000 doesn't work. ktor only finds it when bound to
|
||||
// 127.0.0.1 or 0.0.0.0, no IPv6?.
|
||||
@Test
|
||||
fun `client authentication`() = runTest {
|
||||
val client = Client("http://${address}")
|
||||
assertNull(client.user())
|
||||
assertNull(client.tokens())
|
||||
assertEquals(2, params.size)
|
||||
}
|
||||
|
||||
val tokens = client.login("admin@localhost", "secret")
|
||||
assertNotNull(tokens)
|
||||
assertEquals("admin@localhost", client.user()?.email)
|
||||
// WARN: TrailBase binding to localhost:4000 doesn't work. ktor only finds it when bound to
|
||||
// 127.0.0.1 or 0.0.0.0, no IPv6?.
|
||||
@Test
|
||||
fun `client authentication`() = runTest {
|
||||
val client = Client("http://${address}")
|
||||
assertNull(client.user())
|
||||
assertNull(client.tokens())
|
||||
|
||||
client.logout()
|
||||
assertNull(client.tokens())
|
||||
val tokens = client.login("admin@localhost", "secret")
|
||||
assertNotNull(tokens)
|
||||
assertEquals("admin@localhost", client.user()?.email)
|
||||
|
||||
client.logout()
|
||||
assertNull(client.tokens())
|
||||
}
|
||||
|
||||
suspend fun connect(): Client {
|
||||
val client = Client("http://${address}")
|
||||
client.login("admin@localhost", "secret")
|
||||
return client
|
||||
}
|
||||
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
@Order(1)
|
||||
@Test
|
||||
fun `client records`() = runTest {
|
||||
val client = connect()
|
||||
val api = client.records("simple_strict_table")
|
||||
|
||||
val now = Clock.System.now().toEpochMilliseconds() / 1000
|
||||
val messages = listOf("kotlin client test 0: =?&${now}", "kotlin client test 1: =?&${now}")
|
||||
|
||||
val ids: MutableList<RecordId> = mutableListOf()
|
||||
for (msg in messages) {
|
||||
ids.add(api.create(SimpleStrictInsert(msg)))
|
||||
}
|
||||
|
||||
suspend fun connect(): Client {
|
||||
val client = Client("http://${address}")
|
||||
client.login("admin@localhost", "secret")
|
||||
return client
|
||||
val record0: SimpleStrict = api.read(ids[0])
|
||||
assertEquals(RecordId.string(record0.id), ids[0])
|
||||
|
||||
if (true) {
|
||||
val response: ListResponse<SimpleStrict> =
|
||||
api.list(
|
||||
filters =
|
||||
listOf<FilterBase>(
|
||||
Filter(column = "text_not_null", value = messages[0])
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(messages[0], response.records[0].text_not_null)
|
||||
}
|
||||
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
@Test
|
||||
fun `client records`() = runTest {
|
||||
val client = connect()
|
||||
val api = client.records("simple_strict_table")
|
||||
|
||||
val now = Clock.System.now().toEpochMilliseconds() / 1000
|
||||
val messages = listOf("kotlin client test 0: =?&${now}", "kotlin client test 1: =?&${now}")
|
||||
|
||||
val ids: MutableList<RecordId> = mutableListOf()
|
||||
for (msg in messages) {
|
||||
ids.add(api.create(SimpleStrictInsert(msg)))
|
||||
}
|
||||
|
||||
val record0: SimpleStrict = api.read(ids[0])
|
||||
assertEquals(RecordId.string(record0.id), ids[0])
|
||||
|
||||
if (true) {
|
||||
val response: ListResponse<SimpleStrict> =
|
||||
api.list(
|
||||
filters =
|
||||
listOf<FilterBase>(Filter(column = "text_not_null", value = messages[0]))
|
||||
)
|
||||
|
||||
assertEquals(messages[0], response.records[0].text_not_null)
|
||||
}
|
||||
|
||||
if (true) {
|
||||
val response: ListResponse<SimpleStrict> =
|
||||
api.list(
|
||||
order = listOf("+text_not_null"),
|
||||
filters =
|
||||
listOf<FilterBase>(
|
||||
Filter(column = "text_not_null", value = "% =?&${now}", CompareOp.like)
|
||||
)
|
||||
)
|
||||
assertEquals(messages, response.records.map { it.text_not_null })
|
||||
}
|
||||
|
||||
if (true) {
|
||||
val response: ListResponse<SimpleStrict> =
|
||||
api.list(
|
||||
order = listOf("-text_not_null"),
|
||||
filters =
|
||||
listOf<FilterBase>(
|
||||
Filter(column = "text_not_null", value = "% =?&${now}", CompareOp.like)
|
||||
)
|
||||
)
|
||||
assertEquals(messages.reversed(), response.records.map { it.text_not_null })
|
||||
}
|
||||
|
||||
if (true) {
|
||||
val response: ListResponse<SimpleStrict> =
|
||||
api.list(
|
||||
count = true,
|
||||
pagination = Pagination(limit = 1),
|
||||
order = listOf("-text_not_null"),
|
||||
filters =
|
||||
listOf<FilterBase>(
|
||||
Filter(column = "text_not_null", value = "% =?&${now}", CompareOp.like)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(response.total_count, 2)
|
||||
assertEquals(
|
||||
messages.reversed().subList(0, 1),
|
||||
response.records.map { it.text_not_null }
|
||||
)
|
||||
}
|
||||
|
||||
val updateMessage = "kotlin client update test 0: =?&${now}"
|
||||
api.update(ids[0], SimpleStrictUpdate(text_not_null = updateMessage))
|
||||
val updatedRecord: SimpleStrict = api.read(ids[0])
|
||||
assertEquals(updateMessage, updatedRecord.text_not_null)
|
||||
|
||||
api.delete(ids[0])
|
||||
assertThrows<HttpException>({ api.read<SimpleStrict>(ids[0]) })
|
||||
if (true) {
|
||||
val response: ListResponse<SimpleStrict> =
|
||||
api.list(
|
||||
order = listOf("+text_not_null"),
|
||||
filters =
|
||||
listOf<FilterBase>(
|
||||
Filter(
|
||||
column = "text_not_null",
|
||||
value = "% =?&${now}",
|
||||
CompareOp.like
|
||||
)
|
||||
)
|
||||
)
|
||||
assertEquals(messages, response.records.map { it.text_not_null })
|
||||
}
|
||||
|
||||
if (true) {
|
||||
val response: ListResponse<SimpleStrict> =
|
||||
api.list(
|
||||
order = listOf("-text_not_null"),
|
||||
filters =
|
||||
listOf<FilterBase>(
|
||||
Filter(
|
||||
column = "text_not_null",
|
||||
value = "% =?&${now}",
|
||||
CompareOp.like
|
||||
)
|
||||
)
|
||||
)
|
||||
assertEquals(messages.reversed(), response.records.map { it.text_not_null })
|
||||
}
|
||||
|
||||
if (true) {
|
||||
val response: ListResponse<SimpleStrict> =
|
||||
api.list(
|
||||
count = true,
|
||||
pagination = Pagination(limit = 1),
|
||||
order = listOf("-text_not_null"),
|
||||
filters =
|
||||
listOf<FilterBase>(
|
||||
Filter(
|
||||
column = "text_not_null",
|
||||
value = "% =?&${now}",
|
||||
CompareOp.like
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(response.total_count, 2)
|
||||
assertEquals(messages.reversed().subList(0, 1), response.records.map { it.text_not_null })
|
||||
}
|
||||
|
||||
val updateMessage = "kotlin client update test 0: =?&${now}"
|
||||
api.update(ids[0], SimpleStrictUpdate(text_not_null = updateMessage))
|
||||
val updatedRecord: SimpleStrict = api.read(ids[0])
|
||||
assertEquals(updateMessage, updatedRecord.text_not_null)
|
||||
|
||||
api.delete(ids[0])
|
||||
assertThrows<HttpException>({ api.read<SimpleStrict>(ids[0]) })
|
||||
}
|
||||
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
@Order(100)
|
||||
@Test
|
||||
fun `client record subscriptions`() = runTest {
|
||||
val client = connect()
|
||||
val api = client.records("simple_strict_table")
|
||||
|
||||
val flow = api.subscribe<SimpleStrict>(RecordId.string("*"))
|
||||
|
||||
val now = Clock.System.now().toEpochMilliseconds() / 1000
|
||||
val id = api.create(SimpleStrictInsert("kotlin subscription test 0: =?&${now}"))
|
||||
api.delete(id)
|
||||
|
||||
val result = mutableListOf<DbEvent>()
|
||||
flow.take(2).toList(result)
|
||||
|
||||
assertEquals(2, result.count())
|
||||
|
||||
val insert: SimpleStrict =
|
||||
localJsonSerializer.decodeFromJsonElement((result[0] as DbEvent.Insert).obj)
|
||||
assertEquals(insert.id, id.id())
|
||||
|
||||
val delete: SimpleStrict =
|
||||
localJsonSerializer.decodeFromJsonElement((result[1] as DbEvent.Delete).obj)
|
||||
assertEquals(delete.id, id.id())
|
||||
}
|
||||
}
|
||||
|
||||
val localJsonSerializer = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user