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 {
|
android {
|
||||||
namespace = "com.example.architecture.core.data"
|
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 {
|
dependencies {
|
||||||
implementation(project(":core:domain"))
|
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