slave 0.2.0

Laravel-inspired CLI for Rust web apps: scaffold Axum projects with built-in auth, users, roles and Turso migrations
use axum::Json;
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use slave_framework::turso;

/// Errors returned by request handlers, rendered as JSON responses.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("{0} not found")]
    NotFound(String),

    #[error("{0}")]
    Validation(String),

    #[error("{0}")]
    Conflict(String),

    #[error("authentication required")]
    Unauthorized,

    #[error("invalid email or password")]
    InvalidCredentials,

    #[error("you do not have permission to do this")]
    Forbidden,

    #[error("database error: {0}")]
    Database(#[from] turso::Error),

    #[error("{0}")]
    Internal(String),
}

pub type AppResult<T> = Result<T, AppError>;

impl From<slave_framework::Error> for AppError {
    fn from(err: slave_framework::Error) -> Self {
        Self::Internal(err.to_string())
    }
}

#[derive(Serialize)]
struct ErrorBody {
    error: String,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = match &self {
            Self::NotFound(_) => StatusCode::NOT_FOUND,
            Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
            Self::Conflict(_) => StatusCode::CONFLICT,
            Self::Unauthorized | Self::InvalidCredentials => StatusCode::UNAUTHORIZED,
            Self::Forbidden => StatusCode::FORBIDDEN,
            Self::Database(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
        };

        // Server-side failures are logged in full but never leaked to clients.
        let message = if status == StatusCode::INTERNAL_SERVER_ERROR {
            if self.to_string().contains("no such table") {
                tracing::error!("{self} (have you run `slave migrate`?)");
            } else {
                tracing::error!("{self}");
            }
            "internal server error".to_owned()
        } else {
            self.to_string()
        };

        let mut response = (status, Json(ErrorBody { error: message })).into_response();
        if matches!(self, Self::Unauthorized) {
            response.headers_mut().insert(
                header::WWW_AUTHENTICATE,
                header::HeaderValue::from_static("Bearer"),
            );
        }
        response
    }
}