Skip to content

Commit

Permalink
Refactor error handling of API
Browse files Browse the repository at this point in the history
  • Loading branch information
aidewoode committed Mar 12, 2024
1 parent f5af4d7 commit 682debb
Show file tree
Hide file tree
Showing 14 changed files with 204 additions and 112 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package org.blackcandy.android.api

class ApiException(message: String?) : Exception(message)
class ApiException(val code: Int?, message: String?) : Exception(message)
27 changes: 27 additions & 0 deletions app/src/main/java/org/blackcandy/android/api/ApiResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.blackcandy.android.api

import org.blackcandy.android.utils.TaskResult

sealed interface ApiResponse<out T> {
data class Success<T>(val data: T) : ApiResponse<T>

data class Failure(val exception: ApiException) : ApiResponse<Nothing>

fun orNull(): T? =
when (this) {
is Success -> data
is Failure -> null
}

fun orThrow(): T =
when (this) {
is Success -> data
is Failure -> throw exception
}

fun asResult(): TaskResult<T> =
when (this) {
is Success -> TaskResult.Success(data)
is Failure -> TaskResult.Failure(exception.message)
}
}
124 changes: 74 additions & 50 deletions app/src/main/java/org/blackcandy/android/api/BlackCandyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,83 +20,107 @@ import org.blackcandy.android.models.SystemInfo
import org.blackcandy.android.models.User

interface BlackCandyService {
suspend fun getSystemInfo(): SystemInfo
suspend fun getSystemInfo(): ApiResponse<SystemInfo>

suspend fun createAuthentication(
email: String,
password: String,
): AuthenticationResponse
): ApiResponse<AuthenticationResponse>

suspend fun destroyAuthentication()
suspend fun destroyAuthentication(): ApiResponse<Unit>

suspend fun getSongsFromCurrentPlaylist(): List<Song>
suspend fun getSongsFromCurrentPlaylist(): ApiResponse<List<Song>>

suspend fun addSongToFavorite(songId: Int): Song
suspend fun addSongToFavorite(songId: Int): ApiResponse<Song>

suspend fun deleteSongFromFavorite(songId: Int): Song
suspend fun deleteSongFromFavorite(songId: Int): ApiResponse<Song>
}

class BlackCandyServiceImpl(
private val client: HttpClient,
) : BlackCandyService {
override suspend fun getSystemInfo(): SystemInfo {
return client.get("system").body()
override suspend fun getSystemInfo(): ApiResponse<SystemInfo> {
return handleResponse {
client.get("system").body()
}
}

override suspend fun createAuthentication(
email: String,
password: String,
): AuthenticationResponse {
val response: HttpResponse =
client.submitForm(
url = "authentication",
formParameters =
parameters {
append("with_cookie", "true")
append("session[email]", email)
append("session[password]", password)
},
): ApiResponse<AuthenticationResponse> {
return handleResponse {
val response: HttpResponse =
client.submitForm(
url = "authentication",
formParameters =
parameters {
append("with_cookie", "true")
append("session[email]", email)
append("session[password]", password)
},
)

val userElement = Json.parseToJsonElement(response.bodyAsText()).jsonObject["user"]!!

val token = userElement.jsonObject["api_token"]?.jsonPrimitive.toString()
val id = userElement.jsonObject["id"]?.jsonPrimitive?.int!!
val userEmail = userElement.jsonObject["email"]?.jsonPrimitive.toString()
val isAdmin = userElement.jsonObject["is_admin"]?.jsonPrimitive?.boolean!!
val cookies = response.headers.getAll(HttpHeaders.SetCookie)!!

AuthenticationResponse(
token = token,
user =
User(
id = id,
email = userEmail,
isAdmin = isAdmin,
),
cookies = cookies,
)
}
}

val userElement = Json.parseToJsonElement(response.bodyAsText()).jsonObject["user"]!!

val token = userElement.jsonObject["api_token"]?.jsonPrimitive.toString()
val id = userElement.jsonObject["id"]?.jsonPrimitive?.int!!
val userEmail = userElement.jsonObject["email"]?.jsonPrimitive.toString()
val isAdmin = userElement.jsonObject["is_admin"]?.jsonPrimitive?.boolean!!
val cookies = response.headers.getAll(HttpHeaders.SetCookie)!!

return AuthenticationResponse(
token = token,
user =
User(
id = id,
email = userEmail,
isAdmin = isAdmin,
),
cookies = cookies,
)
override suspend fun destroyAuthentication(): ApiResponse<Unit> {
return handleResponse {
client.delete("authentication").body()
}
}

override suspend fun destroyAuthentication() {
client.delete("authentication")
override suspend fun getSongsFromCurrentPlaylist(): ApiResponse<List<Song>> {
return handleResponse {
client.get("current_playlist/songs").body()
}
}

override suspend fun getSongsFromCurrentPlaylist(): List<Song> {
return client.get("current_playlist/songs").body()
override suspend fun addSongToFavorite(songId: Int): ApiResponse<Song> {
return handleResponse {
client.submitForm(
url = "favorite_playlist/songs",
formParameters =
parameters {
append("song_id", songId.toString())
},
).body()
}
}

override suspend fun addSongToFavorite(songId: Int): Song {
return client.submitForm(
url = "favorite_playlist/songs",
formParameters =
parameters {
append("song_id", songId.toString())
},
).body()
override suspend fun deleteSongFromFavorite(songId: Int): ApiResponse<Song> {
return handleResponse {
client.delete("favorite_playlist/songs/$songId").body()
}
}

override suspend fun deleteSongFromFavorite(songId: Int): Song {
return client.delete("favorite_playlist/songs/$songId").body()
private suspend fun <T> handleResponse(request: suspend () -> T): ApiResponse<T> {
try {
return ApiResponse.Success(request())
} catch (e: ApiException) {
if (e.code == 204) {
return ApiResponse.Success(Unit as T)
}

return ApiResponse.Failure(e)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package org.blackcandy.android.data

import org.blackcandy.android.api.BlackCandyService
import org.blackcandy.android.models.Song
import org.blackcandy.android.utils.TaskResult

class CurrentPlaylistRepository(
private val service: BlackCandyService,
) {
suspend fun getSongs(): List<Song> {
return service.getSongsFromCurrentPlaylist()
suspend fun getSongs(): TaskResult<List<Song>> {
return service.getSongsFromCurrentPlaylist().asResult()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@ package org.blackcandy.android.data

import org.blackcandy.android.api.BlackCandyService
import org.blackcandy.android.models.Song
import org.blackcandy.android.utils.TaskResult

class FavoritePlaylistRepository(
private val service: BlackCandyService,
) {
suspend fun addSong(songId: Int): Song {
return service.addSongToFavorite(songId)
}

suspend fun deleteSong(songId: Int): Song {
return service.deleteSongFromFavorite(songId)
suspend fun toggleSong(song: Song): TaskResult<Song> {
val response = if (song.isFavorited) service.deleteSongFromFavorite(song.id) else service.addSongToFavorite(song.id)
return response.asResult()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package org.blackcandy.android.data

import org.blackcandy.android.api.BlackCandyService
import org.blackcandy.android.models.SystemInfo
import org.blackcandy.android.utils.TaskResult

class SystemInfoRepository(
private val service: BlackCandyService,
) {
suspend fun getSystemInfo(): SystemInfo {
return service.getSystemInfo()
suspend fun getSystemInfo(): TaskResult<SystemInfo> {
return service.getSystemInfo().asResult()
}
}
39 changes: 28 additions & 11 deletions app/src/main/java/org/blackcandy/android/data/UserRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ package org.blackcandy.android.data

import android.webkit.CookieManager
import androidx.datastore.core.DataStore
import io.ktor.client.HttpClient
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import io.ktor.client.plugins.plugin
import kotlinx.coroutines.flow.Flow
import org.blackcandy.android.api.BlackCandyService
import org.blackcandy.android.models.User
import org.blackcandy.android.utils.TaskResult

class UserRepository(
private val httpClient: HttpClient,
private val service: BlackCandyService,
private val cookieManager: CookieManager,
private val userDataStore: DataStore<User?>,
Expand All @@ -16,24 +22,35 @@ class UserRepository(
suspend fun login(
email: String,
password: String,
) {
val response = service.createAuthentication(email, password)
val serverAddress = preferencesDataSource.getServerAddress()
): TaskResult<Unit> {
try {
val response = service.createAuthentication(email, password).orThrow()
val serverAddress = preferencesDataSource.getServerAddress()

response.cookies.forEach {
cookieManager.setCookie(serverAddress, it)
}
response.cookies.forEach {
cookieManager.setCookie(serverAddress, it)
}

cookieManager.flush()
userDataStore.updateData { response.user }
encryptedPreferencesDataSource.updateApiToken(response.token)

cookieManager.flush()
userDataStore.updateData { response.user }
encryptedPreferencesDataSource.updateApiToken(response.token)
// Clear previous cached auth token in http client
httpClient.plugin(Auth).providers
.filterIsInstance<BearerAuthProvider>()
.first().clearToken()

return TaskResult.Success(Unit)
} catch (e: Exception) {
return TaskResult.Failure(e.message)
}
}

suspend fun logout() {
service.destroyAuthentication()
userDataStore.updateData { null }
cookieManager.removeAllCookies(null)
encryptedPreferencesDataSource.removeApiToken()
cookieManager.removeAllCookies(null)
userDataStore.updateData { null }
}

fun getCurrentUserFlow(): Flow<User?> {
Expand Down
27 changes: 22 additions & 5 deletions app/src/main/java/org/blackcandy/android/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ val appModule =
single { MusicServiceController(androidContext()) }
single { ServerAddressRepository(get()) }
single { SystemInfoRepository(get()) }
single { UserRepository(get(), get(), get(named("UserDataStore")), get(), get()) }
single { UserRepository(get(), get(), get(), get(named("UserDataStore")), get(), get()) }
single { CurrentPlaylistRepository(get()) }
single { FavoritePlaylistRepository(get()) }

Expand Down Expand Up @@ -132,23 +132,40 @@ private fun provideHttpClient(
}

HttpResponseValidator {
validateResponse { response ->
val statusCode = response.status.value

if (statusCode == 204) {
throw ApiException(
code = statusCode,
message = null,
)
}
}

handleResponseExceptionWithRequest { exception, _ ->
when (exception) {
is ClientRequestException -> {
val responseText = exception.response.bodyAsText()
val response = exception.response

val apiError =
try {
json.decodeFromString<ApiError>(responseText)
json.decodeFromString<ApiError>(response.bodyAsText())
} catch (e: Exception) {
null
}

throw ApiException(apiError?.message ?: exception.message)
throw ApiException(
code = response.status.value,
message = apiError?.message ?: exception.message,
)
}

else -> {
throw ApiException(exception.message)
throw ApiException(
code = null,
message = exception.message,
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package org.blackcandy.android.models
import androidx.annotation.StringRes

sealed class AlertMessage {
data class String(val value: kotlin.String) : AlertMessage()
data class String(val value: kotlin.String?) : AlertMessage()

data class StringResource(
@StringRes val value: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class SnackbarUtil {
when (message) {
is AlertMessage.String -> message.value
is AlertMessage.StringResource -> stringResource(message.value)
}
} ?: return

LaunchedEffect(state) {
state.showSnackbar(snackbarText)
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/org/blackcandy/android/utils/TaskResult.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.blackcandy.android.utils

sealed interface TaskResult<out T> {
data class Success<T>(val data: T) : TaskResult<T>

data class Failure(val message: String?) : TaskResult<Nothing>
}
Loading

0 comments on commit 682debb

Please sign in to comment.