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