Add initial support for RecordApi subscriptions to Kotlin client and update dependencies.

This commit is contained in:
Sebastian Jeltsch
2026-03-08 21:01:39 +01:00
parent 5af8d1e485
commit c483cb72d7
4 changed files with 564 additions and 443 deletions
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -28,7 +28,7 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
implementation(libs.junit.jupiter)
runtimeOnly(libs.junit.jupiter.engine)
}
}
}
}
+349 -279
View File
@@ -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
}