feat: add Ktor networking layer with ApiService and auth token holder

Add ApiConfig, AuthTokenHolder singleton, expect/actual HttpClient
engine factories (CIO for Android/Desktop, Darwin for iOS), and
ApiService with four GET endpoints. Bearer token from AuthTokenHolder
is injected as Authorization header on each request.

Adjusted for Ktor 3.5.1: HttpClientEngineFactory lives in
io.ktor.client.engine; the timeout plugin is HttpTimeout (package
io.ktor.client.plugins), not HttpTimeoutPlugin. Tests wrap suspend
calls in runTest and assert request URLs without a trailing '?'.
This commit is contained in:
2026-07-08 17:07:20 +02:00
parent a24297f821
commit b1661aaa4f
8 changed files with 210 additions and 0 deletions
@@ -0,0 +1,7 @@
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = CIO
@@ -0,0 +1,5 @@
package ch.parano.myice.network
object ApiConfig {
const val baseUrl = "https://myice.parano.ch"
}
@@ -0,0 +1,23 @@
package ch.parano.myice.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.HttpTimeout
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
expect fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig>
fun createHttpClient(): HttpClient {
return HttpClient(createEngine()) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = 10_000
}
}
}
@@ -0,0 +1,38 @@
package ch.parano.myice.network
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.models.EventDetail
import ch.parano.myice.models.UserInfo
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
class ApiService(private val client: HttpClient) {
private fun authHeader(): String? = AuthTokenHolder.token?.let { "Bearer $it" }
suspend fun getUserInfo(): UserInfo =
client.get("${ApiConfig.baseUrl}/userinfo") {
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getAccounts(): List<Account> =
client.get("${ApiConfig.baseUrl}/accounts") {
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getSchedule(account: String): List<Event> =
client.get("${ApiConfig.baseUrl}/schedule") {
url.parameters.append("account", account)
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getGameDetail(gameId: String, account: String): EventDetail =
client.get("${ApiConfig.baseUrl}/game/$gameId") {
url.parameters.append("account", account)
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
}
@@ -0,0 +1,5 @@
package ch.parano.myice.network
object AuthTokenHolder {
var token: String? = null
}
@@ -0,0 +1,118 @@
package ch.parano.myice.network
import ch.parano.myice.models.EventDetail
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ApiServiceTest {
private val json = Json { ignoreUnknownKeys = true }
private fun mockClient(respondJson: String, requestUrl: String, expectedAuth: String? = "Bearer test-token"): HttpClient {
val engine = MockEngine { request ->
val actualUrl = if (request.url.encodedQuery.isEmpty()) {
request.url.encodedPath
} else {
request.url.encodedPath + "?" + request.url.encodedQuery
}
assertEquals(requestUrl, actualUrl)
if (expectedAuth != null) {
assertEquals(expectedAuth, request.headers["Authorization"])
}
respond(
content = respondJson,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
return HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
}
@Test
fun testGetUserInfo() = runTest {
AuthTokenHolder.token = "test-token"
val client = mockClient(
"""{"email":"rene@luria.ch","sub":"abc"}""",
"/userinfo",
)
val api = ApiService(client)
val result = api.getUserInfo()
assertEquals("rene@luria.ch", result.email)
}
@Test
fun testGetAccounts() = runTest {
AuthTokenHolder.token = "test-token"
val client = mockClient(
"""[{"name":"default","label":"Default"},{"name":"leonard","label":"Leonard"}]""",
"/accounts",
)
val api = ApiService(client)
val result = api.getAccounts()
assertEquals(2, result.size)
assertEquals("default", result[0].name)
assertEquals("Leonard", result[1].label)
}
@Test
fun testGetSchedule() = runTest {
AuthTokenHolder.token = "test-token"
val responseJson = """
[{"id_event":"761040","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"U13 (Elite)\nGame\nArena","opponent":"HC Ajoie","place":"Arena","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}]
""".trimIndent()
val client = mockClient(responseJson, "/schedule?account=default")
val api = ApiService(client)
val result = api.getSchedule("default")
assertEquals(1, result.size)
assertEquals("761040", result[0].idEvent)
assertEquals("Jeu", result[0].eventType)
}
@Test
fun testGetGameDetail() = runTest {
AuthTokenHolder.token = "test-token"
val responseJson = """
{"title":"U13 - Game","type":"games","place":"Arena","time_start":"2024-11-10T14:00:00","time_end":"2024-11-10T16:15:00","convocation":{"available":[{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}],"staff":[{"role":"Coach","fname":"Marc","lname":"Blanc"}]}}
""".trimIndent()
val client = mockClient(responseJson, "/game/761040?account=default")
val api = ApiService(client)
val result: EventDetail = api.getGameDetail("761040", "default")
assertEquals("U13 - Game", result.title)
assertEquals("games", result.type)
assertEquals(1, result.convocation.available.size)
assertEquals("1", result.convocation.available[0].number)
assertEquals(1, result.convocation.staff.size)
}
@Test
fun testNoAuthTokenSendsNoAuthHeader() = runTest {
AuthTokenHolder.token = null
val engine = MockEngine { request ->
assertNull(request.headers["Authorization"])
respond(
content = """{"email":""}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
val api = ApiService(client)
val result = api.getUserInfo()
assertEquals("", result.email)
}
}
@@ -0,0 +1,7 @@
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = CIO
@@ -0,0 +1,7 @@
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.darwin.Darwin
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = Darwin