7
core/data/src/main/AndroidManifest.xml
Normal file
7
core/data/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Networking lives in this module, so the permission is declared here and merges into :app. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
</manifest>
|
||||
@@ -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,128 @@
|
||||
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 timber.log.Timber
|
||||
import java.net.UnknownHostException
|
||||
import java.nio.channels.UnresolvedAddressException
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
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 AND its response deserialization, turning transport/parse exceptions into typed
|
||||
* [DataError.Network] results. `responseToResult` runs inside the try so a malformed 2xx body maps
|
||||
* to SERIALIZATION instead of escaping uncaught.
|
||||
*/
|
||||
suspend inline fun <reified T> safeCall(
|
||||
execute: () -> HttpResponse,
|
||||
): Result<T, DataError.Network> {
|
||||
return try {
|
||||
responseToResult(execute())
|
||||
} catch (e: UnresolvedAddressException) {
|
||||
logNetworkError(e, "No internet (unresolved address)")
|
||||
Result.Error(DataError.Network.NO_INTERNET)
|
||||
} catch (e: UnknownHostException) {
|
||||
logNetworkError(e, "No internet (unknown host)")
|
||||
Result.Error(DataError.Network.NO_INTERNET)
|
||||
} catch (e: SerializationException) {
|
||||
logNetworkError(e, "Serialization failure")
|
||||
Result.Error(DataError.Network.SERIALIZATION)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// Ktor's ContentNegotiation wraps a kotlinx SerializationException (malformed/garbage body)
|
||||
// in its own ContentConvertException, so the catch above misses it. Scan the cause chain so a
|
||||
// bad payload still maps to SERIALIZATION instead of the generic UNKNOWN.
|
||||
if (generateSequence(e as Throwable) { it.cause }.any { it is SerializationException }) {
|
||||
logNetworkError(e, "Serialization failure (wrapped)")
|
||||
Result.Error(DataError.Network.SERIALIZATION)
|
||||
} else {
|
||||
logNetworkError(e, "Unknown network failure")
|
||||
Result.Error(DataError.Network.UNKNOWN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it
|
||||
* across modules WITHOUT leaking Timber: the Timber dependency stays inside `:core:data` because
|
||||
* this function's body is not inlined into the caller.
|
||||
*/
|
||||
@PublishedApi
|
||||
internal fun logNetworkError(throwable: Throwable, message: String) {
|
||||
Timber.tag("HttpClient").e(throwable, message)
|
||||
}
|
||||
|
||||
/** Maps HTTP status codes to typed [DataError.Network] (covering 400/403/404 as well). */
|
||||
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,44 @@
|
||||
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 timber.log.Timber
|
||||
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`). Ktor logging is bridged to
|
||||
* Timber so all logs flow through one tree (planted in the Application).
|
||||
*/
|
||||
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) {
|
||||
Timber.tag("HttpClient").d(message)
|
||||
}
|
||||
}
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
defaultRequest {
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user