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