feat(characters:data): DTOs, mappers, data source, repo, Koin module (REDI-86)
- @Serializable CharacterDto/CharactersResponseDto/PageInfoDto in dto/.
- mappers/CharacterMapper.kt: internal, pure toDomain()/toCharacter()/toCharacterDetails();
nextPage parsed from info.next URL. No mapping inside DTO/data-source classes.
- KtorCharacterDataSource via the typed HttpClient.get helpers (errors -> DataError.Network).
- NetworkCharacterRepository (not *Impl) maps DTO -> domain; DataError.Network widens to DataError.
- charactersDataModule: singleOf(::KtorCharacterDataSource) + singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }.
- core:data: expose ktor-client-core as api (public inline helpers are inlined into consumers) and
move Timber logging into a @PublishedApi internal fn so Timber doesn't leak across modules.
This commit is contained in:
@@ -19,4 +19,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
|
// `api`: the public inline HttpClient.get/post/delete helpers are inlined into consumer modules,
|
||||||
|
// so those modules need the Ktor request/response types on their compile classpath.
|
||||||
|
api(libs.ktor.client.core)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,21 +65,31 @@ suspend inline fun <reified T> safeCall(
|
|||||||
return try {
|
return try {
|
||||||
responseToResult(execute())
|
responseToResult(execute())
|
||||||
} catch (e: UnresolvedAddressException) {
|
} catch (e: UnresolvedAddressException) {
|
||||||
Timber.tag("HttpClient").e(e, "No internet (unresolved address)")
|
logNetworkError(e, "No internet (unresolved address)")
|
||||||
Result.Error(DataError.Network.NO_INTERNET)
|
Result.Error(DataError.Network.NO_INTERNET)
|
||||||
} catch (e: UnknownHostException) {
|
} catch (e: UnknownHostException) {
|
||||||
Timber.tag("HttpClient").e(e, "No internet (unknown host)")
|
logNetworkError(e, "No internet (unknown host)")
|
||||||
Result.Error(DataError.Network.NO_INTERNET)
|
Result.Error(DataError.Network.NO_INTERNET)
|
||||||
} catch (e: SerializationException) {
|
} catch (e: SerializationException) {
|
||||||
Timber.tag("HttpClient").e(e, "Serialization failure")
|
logNetworkError(e, "Serialization failure")
|
||||||
Result.Error(DataError.Network.SERIALIZATION)
|
Result.Error(DataError.Network.SERIALIZATION)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
Timber.tag("HttpClient").e(e, "Unknown network failure")
|
logNetworkError(e, "Unknown network failure")
|
||||||
Result.Error(DataError.Network.UNKNOWN)
|
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] (extends the skill table with 400/403/404). */
|
/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */
|
||||||
suspend inline fun <reified T> responseToResult(
|
suspend inline fun <reified T> responseToResult(
|
||||||
response: HttpResponse,
|
response: HttpResponse,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.architecture.feature.characters.data
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.core.domain.map
|
||||||
|
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||||
|
import com.example.architecture.feature.characters.data.mappers.toCharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.data.mappers.toDomain
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network-backed [CharacterRepository]. Maps DTOs to domain via the mappers; the `Result`'s
|
||||||
|
* `DataError.Network` widens to the `DataError` supertype through `Result`'s covariance.
|
||||||
|
*/
|
||||||
|
internal class NetworkCharacterRepository(
|
||||||
|
private val dataSource: KtorCharacterDataSource,
|
||||||
|
) : CharacterRepository {
|
||||||
|
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> =
|
||||||
|
dataSource.getCharacters(page).map { it.toDomain() }
|
||||||
|
|
||||||
|
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
|
||||||
|
dataSource.getCharacter(id).map { it.toCharacterDetails() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.datasource
|
||||||
|
|
||||||
|
import com.example.architecture.core.data.network.get
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote data source for characters. Returns raw DTOs (no mapping here — the repository maps via
|
||||||
|
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
|
||||||
|
*/
|
||||||
|
internal class KtorCharacterDataSource(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
) {
|
||||||
|
suspend fun getCharacters(page: Int): Result<CharactersResponseDto, DataError.Network> =
|
||||||
|
httpClient.get(route = "/character", queryParameters = mapOf("page" to page))
|
||||||
|
|
||||||
|
suspend fun getCharacter(id: Int): Result<CharacterDto, DataError.Network> =
|
||||||
|
httpClient.get(route = "/character/$id")
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.di
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.data.NetworkCharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import org.koin.core.module.dsl.bind
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val charactersDataModule = module {
|
||||||
|
singleOf(::KtorCharacterDataSource)
|
||||||
|
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharacterDto(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val status: String,
|
||||||
|
val species: String,
|
||||||
|
val type: String,
|
||||||
|
val gender: String,
|
||||||
|
val origin: LocationRefDto,
|
||||||
|
val location: LocationRefDto,
|
||||||
|
val image: String,
|
||||||
|
val episode: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LocationRefDto(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharactersResponseDto(
|
||||||
|
val info: PageInfoDto,
|
||||||
|
val results: List<CharacterDto>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PageInfoDto(
|
||||||
|
val count: Int,
|
||||||
|
val pages: Int,
|
||||||
|
val next: String?,
|
||||||
|
val prev: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.mappers
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||||
|
import com.example.architecture.feature.characters.domain.model.Character
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
internal fun CharactersResponseDto.toDomain(): CharactersPage = CharactersPage(
|
||||||
|
characters = results.map { it.toCharacter() },
|
||||||
|
nextPage = info.next?.toPageNumber(),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun CharacterDto.toCharacter(): Character = Character(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
status = status.toCharacterStatus(),
|
||||||
|
species = species,
|
||||||
|
imageUrl = image,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun CharacterDto.toCharacterDetails(): CharacterDetails = CharacterDetails(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
status = status.toCharacterStatus(),
|
||||||
|
species = species,
|
||||||
|
type = type,
|
||||||
|
gender = gender,
|
||||||
|
origin = origin.name,
|
||||||
|
location = location.name,
|
||||||
|
imageUrl = image,
|
||||||
|
episodeCount = episode.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun String.toCharacterStatus(): CharacterStatus = when (lowercase()) {
|
||||||
|
"alive" -> CharacterStatus.ALIVE
|
||||||
|
"dead" -> CharacterStatus.DEAD
|
||||||
|
else -> CharacterStatus.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The API's `next` is a full URL like `.../character?page=2`; pull the page number out of it. */
|
||||||
|
private fun String.toPageNumber(): Int? =
|
||||||
|
Regex("[?&]page=(\\d+)").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||||
Reference in New Issue
Block a user