From 5f3cc51195a31bd7861c1be70daf02dd7ca5eebb Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 11:45:53 +0200 Subject: [PATCH] feat(core:data): Ktor network core + coreDataModule (REDI-83) - HttpClientFactory.create(engine) with the engine injected (MockEngine seam for tests): ContentNegotiation JSON (ignoreUnknownKeys), Kermit-backed Ktor logging, default JSON request. - safeCall / responseToResult (status -> DataError.Network, extended with 400/403/404/503) / constructRoute (reads BuildConfig.BASE_URL) and typed HttpClient.get/post/delete. - BASE_URL BuildConfig field = Rick & Morty API. - coreDataModule: single via factory lambda (the one sanctioned lambda-DSL binding). --- core/data/build.gradle.kts | 9 ++ .../core/data/di/CoreDataModule.kt | 15 +++ .../core/data/network/HttpClientExt.kt | 107 ++++++++++++++++++ .../core/data/network/HttpClientFactory.kt | 43 +++++++ 4 files changed, 174 insertions(+) create mode 100644 core/data/src/main/kotlin/com/example/architecture/core/data/di/CoreDataModule.kt create mode 100644 core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt create mode 100644 core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientFactory.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 6ef43a2..c9d7554 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -6,8 +6,17 @@ plugins { android { namespace = "com.example.architecture.core.data" + + buildFeatures { + buildConfig = true + } + defaultConfig { + // The no-key Rick & Morty API. constructRoute() reads this BuildConfig field. + buildConfigField("String", "BASE_URL", "\"https://rickandmortyapi.com/api\"") + } } dependencies { implementation(project(":core:domain")) + implementation(libs.kermit) } diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/di/CoreDataModule.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/di/CoreDataModule.kt new file mode 100644 index 0000000..4ab8b4d --- /dev/null +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/di/CoreDataModule.kt @@ -0,0 +1,15 @@ +package com.example.architecture.core.data.di + +import com.example.architecture.core.data.network.HttpClientFactory +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import org.koin.dsl.module + +/** + * Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding — + * HttpClient is assembled by a factory plus the OkHttp engine (not a plain constructor), so the + * constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings. + */ +val coreDataModule = module { + single { HttpClientFactory.create(OkHttp.create()) } +} diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt new file mode 100644 index 0000000..b6fa067 --- /dev/null +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt @@ -0,0 +1,107 @@ +package com.example.architecture.core.data.network + +import com.example.architecture.core.data.BuildConfig +import com.example.architecture.core.domain.DataError +import com.example.architecture.core.domain.Result +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.request.url +import io.ktor.client.statement.HttpResponse +import kotlinx.serialization.SerializationException +import java.net.UnknownHostException +import java.nio.channels.UnresolvedAddressException +import kotlin.coroutines.cancellation.CancellationException +import co.touchlab.kermit.Logger as KermitLogger + +suspend inline fun HttpClient.get( + route: String, + queryParameters: Map = emptyMap(), +): Result { + return safeCall { + get { + url(constructRoute(route)) + queryParameters.forEach { (key, value) -> parameter(key, value) } + } + } +} + +suspend inline fun HttpClient.post( + route: String, + body: Request, +): Result { + return safeCall { + post { + url(constructRoute(route)) + setBody(body) + } + } +} + +suspend inline fun HttpClient.delete( + route: String, + queryParameters: Map = emptyMap(), +): Result { + return safeCall { + delete { + url(constructRoute(route)) + queryParameters.forEach { (key, value) -> parameter(key, value) } + } + } +} + +/** Wraps a Ktor call, turning transport exceptions into typed [DataError.Network] results. */ +suspend inline fun safeCall( + execute: () -> HttpResponse, +): Result { + val response = try { + execute() + } catch (e: UnresolvedAddressException) { + KermitLogger.withTag("HttpClient").e(e) { "No internet (unresolved address)" } + return Result.Error(DataError.Network.NO_INTERNET) + } catch (e: UnknownHostException) { + KermitLogger.withTag("HttpClient").e(e) { "No internet (unknown host)" } + return Result.Error(DataError.Network.NO_INTERNET) + } catch (e: SerializationException) { + KermitLogger.withTag("HttpClient").e(e) { "Serialization failure" } + return Result.Error(DataError.Network.SERIALIZATION) + } catch (e: Exception) { + if (e is CancellationException) throw e + KermitLogger.withTag("HttpClient").e(e) { "Unknown network failure" } + return Result.Error(DataError.Network.UNKNOWN) + } + return responseToResult(response) +} + +/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */ +suspend inline fun responseToResult( + response: HttpResponse, +): Result { + return when (response.status.value) { + in 200..299 -> Result.Success(response.body()) + 400 -> Result.Error(DataError.Network.BAD_REQUEST) + 401 -> Result.Error(DataError.Network.UNAUTHORIZED) + 403 -> Result.Error(DataError.Network.FORBIDDEN) + 404 -> Result.Error(DataError.Network.NOT_FOUND) + 408 -> Result.Error(DataError.Network.REQUEST_TIMEOUT) + 409 -> Result.Error(DataError.Network.CONFLICT) + 413 -> Result.Error(DataError.Network.PAYLOAD_TOO_LARGE) + 429 -> Result.Error(DataError.Network.TOO_MANY_REQUESTS) + 503 -> Result.Error(DataError.Network.SERVICE_UNAVAILABLE) + in 500..599 -> Result.Error(DataError.Network.SERVER_ERROR) + else -> Result.Error(DataError.Network.UNKNOWN) + } +} + +/** Prepends [BuildConfig.BASE_URL] unless [route] is already absolute. */ +fun constructRoute(route: String): String { + return when { + route.contains(BuildConfig.BASE_URL) -> route + route.startsWith("/") -> BuildConfig.BASE_URL + route + else -> BuildConfig.BASE_URL + "/$route" + } +} diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientFactory.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientFactory.kt new file mode 100644 index 0000000..49268ec --- /dev/null +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientFactory.kt @@ -0,0 +1,43 @@ +package com.example.architecture.core.data.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import co.touchlab.kermit.Logger as KermitLogger +import io.ktor.client.plugins.logging.Logger as KtorLogger + +/** + * Builds the app's single [HttpClient]. The [engine] is injected so tests can pass a Ktor + * `MockEngine` while production passes OkHttp (see `coreDataModule`). + */ +object HttpClientFactory { + fun create(engine: HttpClientEngine): HttpClient { + return HttpClient(engine) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + }, + ) + } + install(Logging) { + logger = object : KtorLogger { + override fun log(message: String) { + KermitLogger.withTag("HttpClient").d(message) + } + } + level = LogLevel.ALL + } + defaultRequest { + contentType(ContentType.Application.Json) + } + } + } +}