From 0bb96baa4d963f9647334b6cecb9b267029be23d Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 12:37:40 +0200 Subject: [PATCH] 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() }. - 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. --- core/data/build.gradle.kts | 3 ++ .../core/data/network/HttpClientExt.kt | 18 ++++++-- .../data/NetworkCharacterRepository.kt | 25 +++++++++++ .../datasource/KtorCharacterDataSource.kt | 22 ++++++++++ .../data/di/CharactersDataModule.kt | 13 ++++++ .../characters/data/dto/CharacterDto.kt | 23 ++++++++++ .../data/dto/CharactersResponseDto.kt | 9 ++++ .../characters/data/dto/PageInfoDto.kt | 11 +++++ .../data/mappers/CharacterMapper.kt | 44 +++++++++++++++++++ 9 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepository.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/datasource/KtorCharacterDataSource.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/di/CharactersDataModule.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharacterDto.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharactersResponseDto.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/PageInfoDto.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/mappers/CharacterMapper.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index c12abdf..f5177bd 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -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) } diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt index f2fb972..b14caa1 100644 --- a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt @@ -65,21 +65,31 @@ suspend inline fun 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 responseToResult( response: HttpResponse, diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepository.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepository.kt new file mode 100644 index 0000000..27567d0 --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepository.kt @@ -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 = + dataSource.getCharacters(page).map { it.toDomain() } + + override suspend fun getCharacterDetails(id: Int): Result = + dataSource.getCharacter(id).map { it.toCharacterDetails() } +} diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/datasource/KtorCharacterDataSource.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/datasource/KtorCharacterDataSource.kt new file mode 100644 index 0000000..8b6298b --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/datasource/KtorCharacterDataSource.kt @@ -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 = + httpClient.get(route = "/character", queryParameters = mapOf("page" to page)) + + suspend fun getCharacter(id: Int): Result = + httpClient.get(route = "/character/$id") +} diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/di/CharactersDataModule.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/di/CharactersDataModule.kt new file mode 100644 index 0000000..439f64b --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/di/CharactersDataModule.kt @@ -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() } +} diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharacterDto.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharacterDto.kt new file mode 100644 index 0000000..0bbb796 --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharacterDto.kt @@ -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, +) + +@Serializable +data class LocationRefDto( + val name: String, + val url: String, +) diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharactersResponseDto.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharactersResponseDto.kt new file mode 100644 index 0000000..17e56ff --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharactersResponseDto.kt @@ -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, +) diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/PageInfoDto.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/PageInfoDto.kt new file mode 100644 index 0000000..73eccb2 --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/PageInfoDto.kt @@ -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?, +) diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/mappers/CharacterMapper.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/mappers/CharacterMapper.kt new file mode 100644 index 0000000..f727019 --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/mappers/CharacterMapper.kt @@ -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()