Loading modules/build.gradle +7 −0 Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ ext { core_version = '1.10.1' gson_version = '2.9.0' kotlin_reflection = '1.8.10' retrofit_version = '2.9.0' } android { Loading Loading @@ -44,6 +45,12 @@ dependencies { implementation "com.google.code.gson:gson:$gson_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_reflection" // region Retrofit implementation("com.squareup.retrofit2:retrofit:$retrofit_version") implementation("com.squareup.retrofit2:converter-gson:$retrofit_version") implementation("com.squareup.retrofit2:converter-scalars:$retrofit_version") // endregion testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' Loading modules/src/main/java/app/lounge/networking/FetchError.kt 0 → 100644 +50 −0 Original line number Diff line number Diff line package app.lounge.networking //region Generic Network Error Types sealed interface FetchError { data class Network(val underlyingError: AnyFetchError) : FetchError } /** Supertype for network error types. */ sealed interface AnyFetchError { var description: String enum class BadRequest(override var description: String) : AnyFetchError { Encode("FIXME: Error encoding request! $dumpKeyWord"), Decode("FIXME: Error decoding request! $dumpKeyWord"), } enum class NotFound(override var description: String) : AnyFetchError { MissingData("No data found! $dumpKeyWord"), MissingNetwork("No network! $dumpKeyWord") } data class BadStatusCode (val statusCode: Int, val rawResponse: Any) : AnyFetchError { override var description: String = "Bad status code: $statusCode. Raw response: $rawResponse" } /** * Represents a vague error case typically caused by `UnknownHostException`. * This error case is encountered if and only if network status cannot be determined * while the `UnknownHostException` is received. */ data class Unknown( override var description: String = "Unknown Error! $dumpKeyWord" ) : AnyFetchError companion object { const val dumpKeyWord: String = "dump:-" fun make(error: AnyFetchError, addingDump: String) : AnyFetchError { error.description = error.description + addingDump return error } } } //endregion modules/src/main/java/app/lounge/networking/RetrofitConfig.kt 0 → 100644 +29 −0 Original line number Diff line number Diff line package app.lounge.networking import okhttp3.OkHttpClient import retrofit2.Retrofit import java.util.concurrent.TimeUnit /** * Implement retrofit configuration * 1. Try to use single instance of configuration * 2. Generic way to handle success or failure * 3. API parsing can be clean and testable * * NOTE: Try to use naming which define the action for the logic. * */ internal fun Retrofit.Builder.appLounge( baseURL: String, shouldFollowRedirects: Boolean, callTimeoutInSeconds: Long, ) : Retrofit { return this.baseUrl(baseURL) .client( OkHttpClient.Builder() .callTimeout(callTimeoutInSeconds, TimeUnit.SECONDS) .followRedirects(shouldFollowRedirects) .build() ) .build() } No newline at end of file modules/src/main/java/app/lounge/networking/RetrofitFetching.kt 0 → 100644 +201 −0 Original line number Diff line number Diff line package app.lounge.networking import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.lang.IllegalStateException import java.net.UnknownHostException //region Retrofit Asynchronous Networking interface RetrofitFetching { val executor: Executor get() = callEnqueue val checkNetwork: (() -> Boolean)? get() = null interface Executor { fun <R> fetchAndCallback(endpoint: Call<R>, callback: Callback<R>) } /** * An object that receives Retrofit `Callback<T>` arguments and determines whether or not * an error should be returned. Use to return specific error cases for a given network request. */ interface ResultProcessing<R, E> { /** * Return `R` if possible to cast given object to `R` or return `null`. * This lambda is required due to jvm's type-erasing for generic types. */ val tryCastResponseBody: (Any?) -> R? /** Return result, success/failure, for the received request response */ val resultFromResponse: (call: Call<R>, response: Response<R>) -> RetrofitResult<R, E> /** Return error object `E` for the given failed request. */ val errorFromFailureResponse: (call: Call<*>, t: Throwable) -> E /** Return error object `E` that contains/represents the given `AnyFetchError` */ val errorFromNetworkFailure: (AnyFetchError) -> E } companion object { /** Creates and returns a network request executor using Retrofit `Call<T>` enqueue. */ val callEnqueue: Executor get() { return object : Executor { override fun <R> fetchAndCallback(endpoint: Call<R>, callback: Callback<R>) { endpoint.enqueue(callback) } } } } } /** * Fetch for response type `R` and callback in `success` callback. Invokes failure with * an error subtype of `AnyFetchError` upon failure. * * @param usingExecutor Network request executor (set to `this.executor` by default) * @param endpoint The API endpoint that should be fetched * @param success Success callback with the response `R` * @param failure Failure callback with an error case from `AnyFetchError` subtypes */ inline fun <reified R> RetrofitFetching.fetch( usingExecutor: RetrofitFetching.Executor = executor, endpoint: Call<R>, noinline success: (R) -> Unit, noinline failure: (FetchError) -> Unit ) { val resultProcessing = RetrofitResultProcessing<R, FetchError>( errorFromNetworkFailure = { FetchError.Network(it) }, hasNetwork = checkNetwork ) fetch(usingExecutor, endpoint, resultProcessing, success, failure) } /** * Fetch for response type `R` and callback in `success` callback. Invokes failure with * an error subtype of `E : AnyFetchError` upon failure. * * @param usingExecutor Network request executor (set to `this.executor` by default) * @param endpoint The API endpoint that should be fetched * @param resultProcessing Processes response and finds corresponding error case (if needed) * @param success Success callback with the response `R` * @param failure Failure callback with an error case from given error subtype `E` */ fun <R, E> RetrofitFetching.fetch( usingExecutor: RetrofitFetching.Executor = executor, endpoint: Call<R>, resultProcessing: RetrofitFetching.ResultProcessing<R, E>, success: (R) -> Unit, failure: (E) -> Unit ) { fetch(usingExecutor, endpoint, resultProcessing) { it.invoke(success, failure) } } inline fun <R, E> RetrofitResult<R, E>.invoke(success: (R) -> Unit, failure: (E) -> Unit) { return when (this) { is RetrofitResult.Success -> success(this.result) is RetrofitResult.Failure -> failure(this.error) } } sealed class RetrofitResult<R, E> { data class Success<R, E>(val result: R) : RetrofitResult<R, E>() data class Failure<R, E>(val error: E) : RetrofitResult<R, E>() } private fun <R, E> fetch( usingExecutor: RetrofitFetching.Executor, endpoint: Call<R>, resultProcessing: RetrofitFetching.ResultProcessing<R, E>, callback: (RetrofitResult<R, E>) -> Unit, ) { usingExecutor.fetchAndCallback(endpoint, object : Callback<R> { override fun onFailure(call: Call<R>, t: Throwable) { callback(RetrofitResult.Failure(resultProcessing.errorFromFailureResponse(call, t))) } override fun onResponse(call: Call<R>, response: Response<R>) { callback(resultProcessing.resultFromResponse(call, response)) } }) } //endregion //region Retrofit Standard Result Processing /** Returns result processing object for given response type `R` and error type `E` */ open class RetrofitResultProcessing<R, E>( override val tryCastResponseBody: (Any?) -> R?, override val errorFromNetworkFailure: (AnyFetchError) -> E, hasNetwork: (() -> Boolean)? = null, ) : RetrofitFetching.ResultProcessing<R, E> { companion object { inline operator fun <reified R, E> invoke( noinline errorFromNetworkFailure: (AnyFetchError) -> E, noinline hasNetwork: (() -> Boolean)? = null ) : RetrofitResultProcessing<R, E> { return RetrofitResultProcessing<R, E>( tryCastResponseBody = { if (it == null && R::class.java == Unit::class.java) { Unit as R } else { it as? R } }, errorFromNetworkFailure = errorFromNetworkFailure, hasNetwork = hasNetwork ) } } override var errorFromFailureResponse: (call: Call<*>, t: Throwable) -> E = { call, t -> val error: AnyFetchError = when(t) { is UnknownHostException -> errorForUnknownHostException is IllegalStateException -> errorForIllegalStateException //TODO: Check other cases else -> AnyFetchError.Unknown() } errorFromNetworkFailure( AnyFetchError.make(error = error, addingDump = "(Throwable): $t\n(Call): $call") ) } override var resultFromResponse: (Call<R>, Response<R>) -> RetrofitResult<R, E> = { call, response -> if (response.isSuccessful) { tryCastResponseBody(response.body())?.let { body -> RetrofitResult.Success(body) } ?: RetrofitResult.Failure( errorFromNetworkFailure( AnyFetchError.make( error = AnyFetchError.NotFound.MissingData, addingDump = "(Response): $response\n(Call): $call" ) ) ) } else { RetrofitResult.Failure( errorFromNetworkFailure(AnyFetchError.BadStatusCode(response.code(), response)) ) } } //region Exception to AnyFetchError mapping var errorForUnknownHostException: AnyFetchError = if (hasNetwork != null) { if (hasNetwork()) AnyFetchError.BadRequest.Encode else AnyFetchError.NotFound.MissingNetwork } else { // Cannot distinguish the error case from `MissingNetwork` and `Encode` error. AnyFetchError.Unknown() } var errorForIllegalStateException: AnyFetchError = AnyFetchError.BadRequest.Decode //endregion } //endregion No newline at end of file modules/src/main/java/app/lounge/networking/RetrofitRawResultProcessing.kt 0 → 100644 +75 −0 Original line number Diff line number Diff line package app.lounge.networking import retrofit2.Call import retrofit2.Callback import retrofit2.Response interface RawResponseProcessing<R, E, T> { val onResponse: (Call<T>, Response<T>) -> RetrofitResult<R, E> } fun <R, E, T, Processing> RetrofitFetching.fetch( usingExecutor: RetrofitFetching.Executor = executor, endpoint: Call<T>, processing: Processing, success: (R) -> Unit, failure: (E) -> Unit ) where Processing : RawResponseProcessing<R, E, T>, Processing : RetrofitFetching.ResultProcessing<R, E> { fetch(usingExecutor, endpoint, processing) { it.invoke(success, failure) } } private fun <R, E, T, Processing> fetch( usingExecutor: RetrofitFetching.Executor, endpoint: Call<T>, processing: Processing, callback: (RetrofitResult<R, E>) -> Unit, ) where Processing : RawResponseProcessing<R, E, T>, Processing : RetrofitFetching.ResultProcessing<R, E> { usingExecutor.fetchAndCallback(endpoint, object : Callback<T> { override fun onFailure(call: Call<T>, t: Throwable) { callback(RetrofitResult.Failure(processing.errorFromFailureResponse(call, t))) } override fun onResponse(call: Call<T>, response: Response<T>) { callback(processing.onResponse(call, response)) } }) } class RetrofitRawResultProcessing<R, E, T>( override val onResponse: (Call<*>, Response<*>) -> RetrofitResult<R, E>, override val tryCastResponseBody: (Any?) -> R?, override val errorFromNetworkFailure: (AnyFetchError) -> E, hasNetwork: (() -> Boolean)? = null, ) : RetrofitResultProcessing<R, E>(tryCastResponseBody, errorFromNetworkFailure, hasNetwork), RawResponseProcessing<R, E, T> { override var resultFromResponse: (Call<R>, Response<R>) -> RetrofitResult<R, E> = { call, response -> when(val customResult = onResponse(call, response)) { is RetrofitResult.Success -> customResult is RetrofitResult.Failure -> super.resultFromResponse(call, response) } } companion object { inline operator fun <reified R, E, T> invoke( noinline onResponse: (Call<*>, Response<*>) -> RetrofitResult<R, E>, noinline errorFromNetworkFailure: (AnyFetchError) -> E, noinline hasNetwork: (() -> Boolean)? = null ) : RetrofitRawResultProcessing<R, E, T> { return RetrofitRawResultProcessing( onResponse = onResponse, tryCastResponseBody = { if (it == null && R::class.java == Unit::class.java) { Unit as R } else { it as? R } }, errorFromNetworkFailure = errorFromNetworkFailure, hasNetwork = hasNetwork ) } } } Loading
modules/build.gradle +7 −0 Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ ext { core_version = '1.10.1' gson_version = '2.9.0' kotlin_reflection = '1.8.10' retrofit_version = '2.9.0' } android { Loading Loading @@ -44,6 +45,12 @@ dependencies { implementation "com.google.code.gson:gson:$gson_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_reflection" // region Retrofit implementation("com.squareup.retrofit2:retrofit:$retrofit_version") implementation("com.squareup.retrofit2:converter-gson:$retrofit_version") implementation("com.squareup.retrofit2:converter-scalars:$retrofit_version") // endregion testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' Loading
modules/src/main/java/app/lounge/networking/FetchError.kt 0 → 100644 +50 −0 Original line number Diff line number Diff line package app.lounge.networking //region Generic Network Error Types sealed interface FetchError { data class Network(val underlyingError: AnyFetchError) : FetchError } /** Supertype for network error types. */ sealed interface AnyFetchError { var description: String enum class BadRequest(override var description: String) : AnyFetchError { Encode("FIXME: Error encoding request! $dumpKeyWord"), Decode("FIXME: Error decoding request! $dumpKeyWord"), } enum class NotFound(override var description: String) : AnyFetchError { MissingData("No data found! $dumpKeyWord"), MissingNetwork("No network! $dumpKeyWord") } data class BadStatusCode (val statusCode: Int, val rawResponse: Any) : AnyFetchError { override var description: String = "Bad status code: $statusCode. Raw response: $rawResponse" } /** * Represents a vague error case typically caused by `UnknownHostException`. * This error case is encountered if and only if network status cannot be determined * while the `UnknownHostException` is received. */ data class Unknown( override var description: String = "Unknown Error! $dumpKeyWord" ) : AnyFetchError companion object { const val dumpKeyWord: String = "dump:-" fun make(error: AnyFetchError, addingDump: String) : AnyFetchError { error.description = error.description + addingDump return error } } } //endregion
modules/src/main/java/app/lounge/networking/RetrofitConfig.kt 0 → 100644 +29 −0 Original line number Diff line number Diff line package app.lounge.networking import okhttp3.OkHttpClient import retrofit2.Retrofit import java.util.concurrent.TimeUnit /** * Implement retrofit configuration * 1. Try to use single instance of configuration * 2. Generic way to handle success or failure * 3. API parsing can be clean and testable * * NOTE: Try to use naming which define the action for the logic. * */ internal fun Retrofit.Builder.appLounge( baseURL: String, shouldFollowRedirects: Boolean, callTimeoutInSeconds: Long, ) : Retrofit { return this.baseUrl(baseURL) .client( OkHttpClient.Builder() .callTimeout(callTimeoutInSeconds, TimeUnit.SECONDS) .followRedirects(shouldFollowRedirects) .build() ) .build() } No newline at end of file
modules/src/main/java/app/lounge/networking/RetrofitFetching.kt 0 → 100644 +201 −0 Original line number Diff line number Diff line package app.lounge.networking import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.lang.IllegalStateException import java.net.UnknownHostException //region Retrofit Asynchronous Networking interface RetrofitFetching { val executor: Executor get() = callEnqueue val checkNetwork: (() -> Boolean)? get() = null interface Executor { fun <R> fetchAndCallback(endpoint: Call<R>, callback: Callback<R>) } /** * An object that receives Retrofit `Callback<T>` arguments and determines whether or not * an error should be returned. Use to return specific error cases for a given network request. */ interface ResultProcessing<R, E> { /** * Return `R` if possible to cast given object to `R` or return `null`. * This lambda is required due to jvm's type-erasing for generic types. */ val tryCastResponseBody: (Any?) -> R? /** Return result, success/failure, for the received request response */ val resultFromResponse: (call: Call<R>, response: Response<R>) -> RetrofitResult<R, E> /** Return error object `E` for the given failed request. */ val errorFromFailureResponse: (call: Call<*>, t: Throwable) -> E /** Return error object `E` that contains/represents the given `AnyFetchError` */ val errorFromNetworkFailure: (AnyFetchError) -> E } companion object { /** Creates and returns a network request executor using Retrofit `Call<T>` enqueue. */ val callEnqueue: Executor get() { return object : Executor { override fun <R> fetchAndCallback(endpoint: Call<R>, callback: Callback<R>) { endpoint.enqueue(callback) } } } } } /** * Fetch for response type `R` and callback in `success` callback. Invokes failure with * an error subtype of `AnyFetchError` upon failure. * * @param usingExecutor Network request executor (set to `this.executor` by default) * @param endpoint The API endpoint that should be fetched * @param success Success callback with the response `R` * @param failure Failure callback with an error case from `AnyFetchError` subtypes */ inline fun <reified R> RetrofitFetching.fetch( usingExecutor: RetrofitFetching.Executor = executor, endpoint: Call<R>, noinline success: (R) -> Unit, noinline failure: (FetchError) -> Unit ) { val resultProcessing = RetrofitResultProcessing<R, FetchError>( errorFromNetworkFailure = { FetchError.Network(it) }, hasNetwork = checkNetwork ) fetch(usingExecutor, endpoint, resultProcessing, success, failure) } /** * Fetch for response type `R` and callback in `success` callback. Invokes failure with * an error subtype of `E : AnyFetchError` upon failure. * * @param usingExecutor Network request executor (set to `this.executor` by default) * @param endpoint The API endpoint that should be fetched * @param resultProcessing Processes response and finds corresponding error case (if needed) * @param success Success callback with the response `R` * @param failure Failure callback with an error case from given error subtype `E` */ fun <R, E> RetrofitFetching.fetch( usingExecutor: RetrofitFetching.Executor = executor, endpoint: Call<R>, resultProcessing: RetrofitFetching.ResultProcessing<R, E>, success: (R) -> Unit, failure: (E) -> Unit ) { fetch(usingExecutor, endpoint, resultProcessing) { it.invoke(success, failure) } } inline fun <R, E> RetrofitResult<R, E>.invoke(success: (R) -> Unit, failure: (E) -> Unit) { return when (this) { is RetrofitResult.Success -> success(this.result) is RetrofitResult.Failure -> failure(this.error) } } sealed class RetrofitResult<R, E> { data class Success<R, E>(val result: R) : RetrofitResult<R, E>() data class Failure<R, E>(val error: E) : RetrofitResult<R, E>() } private fun <R, E> fetch( usingExecutor: RetrofitFetching.Executor, endpoint: Call<R>, resultProcessing: RetrofitFetching.ResultProcessing<R, E>, callback: (RetrofitResult<R, E>) -> Unit, ) { usingExecutor.fetchAndCallback(endpoint, object : Callback<R> { override fun onFailure(call: Call<R>, t: Throwable) { callback(RetrofitResult.Failure(resultProcessing.errorFromFailureResponse(call, t))) } override fun onResponse(call: Call<R>, response: Response<R>) { callback(resultProcessing.resultFromResponse(call, response)) } }) } //endregion //region Retrofit Standard Result Processing /** Returns result processing object for given response type `R` and error type `E` */ open class RetrofitResultProcessing<R, E>( override val tryCastResponseBody: (Any?) -> R?, override val errorFromNetworkFailure: (AnyFetchError) -> E, hasNetwork: (() -> Boolean)? = null, ) : RetrofitFetching.ResultProcessing<R, E> { companion object { inline operator fun <reified R, E> invoke( noinline errorFromNetworkFailure: (AnyFetchError) -> E, noinline hasNetwork: (() -> Boolean)? = null ) : RetrofitResultProcessing<R, E> { return RetrofitResultProcessing<R, E>( tryCastResponseBody = { if (it == null && R::class.java == Unit::class.java) { Unit as R } else { it as? R } }, errorFromNetworkFailure = errorFromNetworkFailure, hasNetwork = hasNetwork ) } } override var errorFromFailureResponse: (call: Call<*>, t: Throwable) -> E = { call, t -> val error: AnyFetchError = when(t) { is UnknownHostException -> errorForUnknownHostException is IllegalStateException -> errorForIllegalStateException //TODO: Check other cases else -> AnyFetchError.Unknown() } errorFromNetworkFailure( AnyFetchError.make(error = error, addingDump = "(Throwable): $t\n(Call): $call") ) } override var resultFromResponse: (Call<R>, Response<R>) -> RetrofitResult<R, E> = { call, response -> if (response.isSuccessful) { tryCastResponseBody(response.body())?.let { body -> RetrofitResult.Success(body) } ?: RetrofitResult.Failure( errorFromNetworkFailure( AnyFetchError.make( error = AnyFetchError.NotFound.MissingData, addingDump = "(Response): $response\n(Call): $call" ) ) ) } else { RetrofitResult.Failure( errorFromNetworkFailure(AnyFetchError.BadStatusCode(response.code(), response)) ) } } //region Exception to AnyFetchError mapping var errorForUnknownHostException: AnyFetchError = if (hasNetwork != null) { if (hasNetwork()) AnyFetchError.BadRequest.Encode else AnyFetchError.NotFound.MissingNetwork } else { // Cannot distinguish the error case from `MissingNetwork` and `Encode` error. AnyFetchError.Unknown() } var errorForIllegalStateException: AnyFetchError = AnyFetchError.BadRequest.Decode //endregion } //endregion No newline at end of file
modules/src/main/java/app/lounge/networking/RetrofitRawResultProcessing.kt 0 → 100644 +75 −0 Original line number Diff line number Diff line package app.lounge.networking import retrofit2.Call import retrofit2.Callback import retrofit2.Response interface RawResponseProcessing<R, E, T> { val onResponse: (Call<T>, Response<T>) -> RetrofitResult<R, E> } fun <R, E, T, Processing> RetrofitFetching.fetch( usingExecutor: RetrofitFetching.Executor = executor, endpoint: Call<T>, processing: Processing, success: (R) -> Unit, failure: (E) -> Unit ) where Processing : RawResponseProcessing<R, E, T>, Processing : RetrofitFetching.ResultProcessing<R, E> { fetch(usingExecutor, endpoint, processing) { it.invoke(success, failure) } } private fun <R, E, T, Processing> fetch( usingExecutor: RetrofitFetching.Executor, endpoint: Call<T>, processing: Processing, callback: (RetrofitResult<R, E>) -> Unit, ) where Processing : RawResponseProcessing<R, E, T>, Processing : RetrofitFetching.ResultProcessing<R, E> { usingExecutor.fetchAndCallback(endpoint, object : Callback<T> { override fun onFailure(call: Call<T>, t: Throwable) { callback(RetrofitResult.Failure(processing.errorFromFailureResponse(call, t))) } override fun onResponse(call: Call<T>, response: Response<T>) { callback(processing.onResponse(call, response)) } }) } class RetrofitRawResultProcessing<R, E, T>( override val onResponse: (Call<*>, Response<*>) -> RetrofitResult<R, E>, override val tryCastResponseBody: (Any?) -> R?, override val errorFromNetworkFailure: (AnyFetchError) -> E, hasNetwork: (() -> Boolean)? = null, ) : RetrofitResultProcessing<R, E>(tryCastResponseBody, errorFromNetworkFailure, hasNetwork), RawResponseProcessing<R, E, T> { override var resultFromResponse: (Call<R>, Response<R>) -> RetrofitResult<R, E> = { call, response -> when(val customResult = onResponse(call, response)) { is RetrofitResult.Success -> customResult is RetrofitResult.Failure -> super.resultFromResponse(call, response) } } companion object { inline operator fun <reified R, E, T> invoke( noinline onResponse: (Call<*>, Response<*>) -> RetrofitResult<R, E>, noinline errorFromNetworkFailure: (AnyFetchError) -> E, noinline hasNetwork: (() -> Boolean)? = null ) : RetrofitRawResultProcessing<R, E, T> { return RetrofitRawResultProcessing( onResponse = onResponse, tryCastResponseBody = { if (it == null && R::class.java == Unit::class.java) { Unit as R } else { it as? R } }, errorFromNetworkFailure = errorFromNetworkFailure, hasNetwork = hasNetwork ) } } }