Skip to content

Use ktor and kotlinx serialization #126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ plugins {
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
}

val appConfig = AppConfig()
Expand Down Expand Up @@ -99,7 +100,6 @@ dependencies {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.converter.gson)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this! For a long time wanted to get rid of gson :)


// Compose
// @see: https://developer.android.google.cn/develop/ui/compose/setup?hl=en#kotlin_1
Expand Down Expand Up @@ -130,6 +130,7 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
testImplementation(libs.ktor.client.mock)

// UI tests dependencies
androidTestImplementation(composeBom)
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/kotlin/com/fernandocejas/sample/AllFeatures.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.fernandocejas.sample

import com.fernandocejas.sample.core.navigation.navigationFeature
import com.fernandocejas.sample.core.network.networkFeature
import com.fernandocejas.sample.features.auth.authFeature
import com.fernandocejas.sample.features.login.loginFeature
import com.fernandocejas.sample.features.movies.di.moviesFeature

fun allFeatures() = listOf(
networkFeature(),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not make network and navigation a feature. Unless we have a good reason for it. Think of features as user features. Networking and Navigation belong to core, which is the module shared across features.

Happy to read your thoughts :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally agree about the semantics here. My idea was to break core into independent functionalities like network, etc. If it's too much too early, we can keep core as is and see how it develops? Navigation feature is only temporary to keep the proeject compiling, till we move to androidx navigation and use one activity. Let me know your thoughts :)

authFeature(),
loginFeature(),
moviesFeature(),
navigationFeature(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
package com.fernandocejas.sample

import android.app.Application
import com.fernandocejas.sample.core.allFeatures
import com.fernandocejas.sample.core.di.coreModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin
Expand Down
23 changes: 0 additions & 23 deletions app/src/main/kotlin/com/fernandocejas/sample/core/di/CoreModule.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package com.fernandocejas.sample.core
package com.fernandocejas.sample.core.di

import com.fernandocejas.sample.core.di.coreModule
import com.fernandocejas.sample.features.auth.authFeature
import com.fernandocejas.sample.features.login.loginFeature
import com.fernandocejas.sample.features.movies.moviesFeature
import org.koin.core.module.Module

/**
Expand Down Expand Up @@ -45,15 +41,3 @@ interface Feature {
*/
// fun databaseTables(): List<Table> = emptyList()
}

private fun coreFeature() = object : Feature {
override fun name() = "core"
override fun diModule() = coreModule
}

fun allFeatures() = listOf(
coreFeature(),
authFeature(),
loginFeature(),
moviesFeature(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ import android.content.Intent
import android.net.Uri
import android.view.View
import androidx.fragment.app.FragmentActivity
import com.fernandocejas.sample.core.di.Feature
import com.fernandocejas.sample.core.extension.emptyString
import com.fernandocejas.sample.features.auth.credentials.Authenticator
import com.fernandocejas.sample.features.movies.ui.MovieView
import com.fernandocejas.sample.features.movies.ui.MoviesActivity
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module


class Navigator(private val authenticator: Authenticator) {
Expand Down Expand Up @@ -83,4 +87,10 @@ class Navigator(private val authenticator: Authenticator) {
class Extras(val transitionSharedElement: View)
}


// temporary solution to compile till Navigator is deleted
fun navigationFeature() = object : Feature {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the comment above :)

override fun name() = "navigation"
override fun diModule() = module {
singleOf(::Navigator)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.fernandocejas.sample.core.network

import com.fernandocejas.sample.core.functional.Either
import com.fernandocejas.sample.core.functional.toLeft
import com.fernandocejas.sample.core.functional.toRight

sealed class ApiResponse<out T, out E> {
/**
* Represents successful network responses (2xx).
*/
data class Success<T>(val body: T) : ApiResponse<T, Nothing>()

sealed class Error<E> : ApiResponse<Nothing, E>() {
/**
* Represents server (50x) and client (40x) errors.
*/
data class HttpError<E>(val code: Int, val errorBody: E?) : Error<E>()

/**
* Represent IOExceptions and connectivity issues.
*/
data object NetworkError : Error<Nothing>()

/**
* Represent SerializationExceptions.
*/
data object SerializationError : Error<Nothing>()
}
}

// Side Effect helpers
inline fun <T, E> ApiResponse<T, E>.onSuccess(block: (T) -> Unit): ApiResponse<T, E> {
if (this is ApiResponse.Success) {
block(body)
}
return this
}

fun <T, E> ApiResponse<T, E>.toEither(): Either<E?, T> {
return when (this) {
is ApiResponse.Success -> body.toRight()
is ApiResponse.Error.HttpError -> errorBody.toLeft()
is ApiResponse.Error.NetworkError -> null.toLeft()
is ApiResponse.Error.SerializationError -> null.toLeft()
}
}

fun <T, E, F, D> ApiResponse<T, E>.toEither(
successTransform: (T) -> D,
errorTransform: (ApiResponse.Error<E>) -> F,
): Either<F, D> {
return when (this) {
is ApiResponse.Success -> successTransform(body).toRight()
is ApiResponse.Error -> errorTransform(this).toLeft()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.fernandocejas.sample.core.network

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request
import io.ktor.serialization.JsonConvertException
import kotlinx.io.IOException

suspend inline fun <reified T, reified E> HttpClient.safeRequest(
block: HttpRequestBuilder.() -> Unit,
): ApiResponse<T, E> =
try {
val response = request { block() }
ApiResponse.Success(response.body())
} catch (e: ClientRequestException) {
ApiResponse.Error.HttpError(e.response.status.value, e.errorBody())
} catch (e: ServerResponseException) {
ApiResponse.Error.HttpError(e.response.status.value, e.errorBody())
} catch (e: IOException) {
ApiResponse.Error.NetworkError
} catch (e: JsonConvertException) {
ApiResponse.Error.SerializationError
}

suspend inline fun <reified E> ResponseException.errorBody(): E? =
try {
response.body()
} catch (e: JsonConvertException) {
null
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ package com.fernandocejas.sample.core.network

import android.content.Context
import android.net.NetworkCapabilities
import android.os.Build
import com.fernandocejas.sample.core.extension.connectivityManager

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.fernandocejas.sample.core.network

import co.touchlab.kermit.Logger
import com.fernandocejas.sample.core.di.Feature
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import io.ktor.client.plugins.logging.Logger as KtorLogger

fun networkFeature() = object : Feature {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this might not become a feature, the DI part should be rethought then. If do not see any issues to continue having a DI core module that includes all the cross cutting dependencies.

override fun name() = "network"
override fun diModule() = networkModule
}

private val networkModule = module {
singleOf(::NetworkHandler)
single { json }
single { client }
}

private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}

private val client = HttpClient(OkHttp) {
engine {
config {
followRedirects(true)
}
}
install(HttpCache)
install(HttpTimeout)
install(ContentNegotiation) {
json(json, ContentType.Text.Plain)
}
install(Logging) {
logger = object : KtorLogger {
override fun log(message: String) {
Logger.withTag("HTTP").d { "\uD83C\uDF10 $message" }
}
}
level = LogLevel.HEADERS
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.fernandocejas.sample.features.auth

import com.fernandocejas.sample.core.Feature
import com.fernandocejas.sample.core.di.Feature
import com.fernandocejas.sample.features.auth.credentials.Authenticator
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
package com.fernandocejas.sample.features.auth.di

import com.fernandocejas.sample.core.navigation.Navigator
import com.fernandocejas.sample.core.network.NetworkHandler
import com.fernandocejas.sample.features.auth.credentials.Authenticator
import okhttp3.OkHttpClient
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

val authModule = module {
singleOf(::Authenticator)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.fernandocejas.sample.features.login

import com.fernandocejas.sample.core.Feature
import com.fernandocejas.sample.core.di.Feature
import org.koin.dsl.module

fun loginFeature() = object : Feature {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
package com.fernandocejas.sample.features.movies.data

import com.fernandocejas.sample.features.movies.interactor.Movie
import kotlinx.serialization.Serializable

data class MovieEntity(private val id: Int, private val poster: String) {
fun toMovie() = Movie(id, poster)
}
@Serializable
data class MovieEntity(val id: Int, val poster: String)


fun MovieEntity.toMovie() = Movie(id, poster)

This file was deleted.

Loading