diff --git a/composeApp/src/androidMain/kotlin/ch/parano/myice/network/ApiEngine.android.kt b/composeApp/src/androidMain/kotlin/ch/parano/myice/network/ApiEngine.android.kt new file mode 100644 index 0000000..1e02b0d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ch/parano/myice/network/ApiEngine.android.kt @@ -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 = CIO diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt new file mode 100644 index 0000000..0458239 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt @@ -0,0 +1,5 @@ +package ch.parano.myice.network + +object ApiConfig { + const val baseUrl = "https://myice.parano.ch" +} diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt b/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt new file mode 100644 index 0000000..0b44f4f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt @@ -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 + +fun createHttpClient(): HttpClient { + return HttpClient(createEngine()) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = 10_000 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt b/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt new file mode 100644 index 0000000..09253fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt @@ -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 = + client.get("${ApiConfig.baseUrl}/accounts") { + authHeader()?.let { header(HttpHeaders.Authorization, it) } + }.body() + + suspend fun getSchedule(account: String): List = + 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() +} diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt b/composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt new file mode 100644 index 0000000..adc20cf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt @@ -0,0 +1,5 @@ +package ch.parano.myice.network + +object AuthTokenHolder { + var token: String? = null +} diff --git a/composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt b/composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt new file mode 100644 index 0000000..0575886 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt @@ -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) + } +} diff --git a/composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt new file mode 100644 index 0000000..1e02b0d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt @@ -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 = CIO diff --git a/composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt b/composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt new file mode 100644 index 0000000..882e6d5 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt @@ -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 = Darwin