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<HttpClient> via factory lambda (the one sanctioned lambda-DSL binding).
This commit is contained in:
2026-06-10 11:45:53 +02:00
parent 709c7d6ff5
commit 5f3cc51195
4 changed files with 174 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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<HttpClient> { HttpClientFactory.create(OkHttp.create()) }
}

View File

@@ -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 <reified Response : Any> HttpClient.get(
route: String,
queryParameters: Map<String, Any?> = emptyMap(),
): Result<Response, DataError.Network> {
return safeCall {
get {
url(constructRoute(route))
queryParameters.forEach { (key, value) -> parameter(key, value) }
}
}
}
suspend inline fun <reified Request, reified Response : Any> HttpClient.post(
route: String,
body: Request,
): Result<Response, DataError.Network> {
return safeCall {
post {
url(constructRoute(route))
setBody(body)
}
}
}
suspend inline fun <reified Response : Any> HttpClient.delete(
route: String,
queryParameters: Map<String, Any?> = emptyMap(),
): Result<Response, DataError.Network> {
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 <reified T> safeCall(
execute: () -> HttpResponse,
): Result<T, DataError.Network> {
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 <reified T> responseToResult(
response: HttpResponse,
): Result<T, DataError.Network> {
return when (response.status.value) {
in 200..299 -> Result.Success(response.body<T>())
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"
}
}

View File

@@ -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)
}
}
}
}