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:
2026-06-10 12:37:40 +02:00
parent 600f12259d
commit 0bb96baa4d
9 changed files with 164 additions and 4 deletions

View File

@@ -19,4 +19,7 @@ android {
dependencies {
implementation(project(":core:domain"))
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)
}

View File

@@ -65,21 +65,31 @@ suspend inline fun <reified T> safeCall(
return try {
responseToResult(execute())
} catch (e: UnresolvedAddressException) {
Timber.tag("HttpClient").e(e, "No internet (unresolved address)")
logNetworkError(e, "No internet (unresolved address)")
Result.Error(DataError.Network.NO_INTERNET)
} catch (e: UnknownHostException) {
Timber.tag("HttpClient").e(e, "No internet (unknown host)")
logNetworkError(e, "No internet (unknown host)")
Result.Error(DataError.Network.NO_INTERNET)
} catch (e: SerializationException) {
Timber.tag("HttpClient").e(e, "Serialization failure")
logNetworkError(e, "Serialization failure")
Result.Error(DataError.Network.SERIALIZATION)
} catch (e: Exception) {
if (e is CancellationException) throw e
Timber.tag("HttpClient").e(e, "Unknown network failure")
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] (extends the skill table with 400/403/404). */
suspend inline fun <reified T> responseToResult(
response: HttpResponse,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?,
)

View File

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