diff --git a/ERROR_HANDLING.md b/ERROR_HANDLING.md new file mode 100644 index 0000000..d027bbd --- /dev/null +++ b/ERROR_HANDLING.md @@ -0,0 +1,112 @@ +# Error Handling in MyCrib Apps + +Both iOS and Android apps now display detailed error messages from the API. + +## How It Works + +### Backend (Django) +The Django backend returns errors in a standardized JSON format: + +```json +{ + "error": "Error Type", + "detail": "Detailed error message", + "status_code": 400, + "errors": { + "field_name": ["Field-specific error messages"] + } +} +``` + +### Shared Network Layer (Kotlin Multiplatform) + +#### ErrorResponse Model +Located at: `composeApp/src/commonMain/kotlin/com/example/mycrib/models/ErrorResponse.kt` + +Defines the structure of error responses from the API. + +#### ErrorParser +Located at: `composeApp/src/commonMain/kotlin/com/example/mycrib/network/ErrorParser.kt` + +Parses error responses and extracts detailed messages: +- Primary error detail from the `detail` field +- Field-specific errors from the `errors` field (if present) +- Fallback to plain text or generic message if parsing fails + +#### Updated API Calls +All TaskApi methods now use ErrorParser to extract detailed error messages: +- `getTasks()` +- `getTask()` +- `createTask()` +- `updateTask()` +- `deleteTask()` +- `getTasksByResidence()` +- `cancelTask()` +- `uncancelTask()` +- `markInProgress()` +- `archiveTask()` +- `unarchiveTask()` + +### Android + +Android automatically receives parsed error messages through the shared `ApiResult.Error` class. ViewModels and UI components display these messages to users. + +**Example:** +```kotlin +when (result) { + is ApiResult.Error -> { + // result.message contains the detailed error from ErrorParser + showError(result.message) + } +} +``` + +### iOS + +iOS receives parsed error messages through the `ApiResultError` wrapper class from the shared KMM module. ViewModels extract and display these messages. + +**Example:** +```swift +if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message // Contains detailed error from ErrorParser +} +``` + +## Error Message Format + +### Simple Errors +``` +Invalid task status +``` + +### Validation Errors with Field Details +``` +Invalid data provided + +Details: +• email: This field is required. +• password: Password must be at least 8 characters. +``` + +## Benefits + +1. **User-Friendly**: Users see specific error messages instead of generic "Failed to..." messages +2. **Debugging**: Developers can quickly identify issues from error messages +3. **Consistent**: Both platforms display the same detailed errors +4. **Maintainable**: Single source of truth for error parsing in shared code +5. **Backend-Driven**: Error messages are controlled by the Django backend + +## Testing + +To test error handling: + +1. Create a task with invalid data +2. Update a task with missing required fields +3. Try to perform actions without authentication +4. Observe the detailed error messages displayed in the UI + +## Future Improvements + +- Add error codes for programmatic handling +- Implement retry logic for specific error types +- Add localization support for error messages diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/ErrorResponse.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/ErrorResponse.kt new file mode 100644 index 0000000..4c11b6f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/ErrorResponse.kt @@ -0,0 +1,12 @@ +package com.mycrib.shared.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponse( + val error: String, + val detail: String, + @SerialName("status_code") val statusCode: Int, + val errors: Map>? = null +) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ErrorParser.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ErrorParser.kt new file mode 100644 index 0000000..e006329 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ErrorParser.kt @@ -0,0 +1,42 @@ +package com.mycrib.shared.network + +import com.mycrib.shared.models.ErrorResponse +import io.ktor.client.call.body +import io.ktor.client.statement.HttpResponse +import kotlinx.serialization.json.Json + +object ErrorParser { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + suspend fun parseError(response: HttpResponse): String { + return try { + val errorResponse = response.body() + + // Build detailed error message + val message = StringBuilder() + message.append(errorResponse.detail) + + // Add field-specific errors if present + errorResponse.errors?.let { fieldErrors -> + if (fieldErrors.isNotEmpty()) { + message.append("\n\nDetails:") + fieldErrors.forEach { (field, errors) -> + message.append("\n• $field: ${errors.joinToString(", ")}") + } + } + } + + message.toString() + } catch (e: Exception) { + // Fallback to reading as plain text + try { + response.body() + } catch (e: Exception) { + "An error occurred (${response.status.value})" + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt index 4ac5e53..b322145 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt @@ -22,7 +22,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to fetch tasks", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -38,7 +39,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to fetch task", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -56,7 +58,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to create task", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -74,7 +77,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to update task", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -90,7 +94,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(Unit) } else { - ApiResult.Error("Failed to delete task", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -111,7 +116,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to fetch tasks by residence", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -127,7 +133,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to cancel task", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -143,7 +150,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to uncancel task", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -159,7 +167,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to mark task as in progress", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -175,7 +184,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to archive task", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") @@ -191,7 +201,8 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to unarchive task", response.status.value) + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred")