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