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:
@@ -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
|
||||
Reference in New Issue
Block a user