Refactor admin routes to use new AppError struct in responses

This commit is contained in:
Alphonse Paix
2025-09-20 01:08:05 +02:00
parent 2b9cf979e8
commit 7971095227
7 changed files with 86 additions and 75 deletions

View File

@@ -1,5 +1,7 @@
use crate::{
routes::AdminError, session_state::TypedSession, telemetry::spawn_blocking_with_tracing,
routes::{AdminError, AppError},
session_state::TypedSession,
telemetry::spawn_blocking_with_tracing,
};
use anyhow::Context;
use argon2::{
@@ -135,7 +137,7 @@ pub async fn require_auth(
session: TypedSession,
mut request: Request,
next: Next,
) -> Result<Response, AdminError> {
) -> Result<Response, AppError> {
let user_id = session
.get_user_id()
.await

View File

@@ -8,7 +8,10 @@ mod subscriptions_confirm;
pub use admin::*;
use askama::Template;
use axum::response::{Html, IntoResponse, Response};
use axum::{
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
pub use health_check::*;
pub use home::*;
pub use login::*;
@@ -17,6 +20,62 @@ use reqwest::StatusCode;
pub use subscriptions::*;
pub use subscriptions_confirm::*;
use crate::templates::MessageTemplate;
#[derive(thiserror::Error)]
pub enum AppError {
#[error("An unexpected error was encountered.")]
UnexpectedError(#[from] anyhow::Error),
#[error("A validation error happened.")]
ValidationError(#[source] anyhow::Error),
#[error("An authentication is required.")]
NotAuthenticated,
}
impl std::fmt::Debug for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
tracing::error!("{:?}", self);
match &self {
AppError::UnexpectedError(_) => {
let template = MessageTemplate::Error {
message: "An internal server error occured.".into(),
};
Html(template.render().unwrap()).into_response()
}
AppError::ValidationError(error) => {
let template = MessageTemplate::Error {
message: error.to_string(),
};
Html(template.render().unwrap()).into_response()
}
AppError::NotAuthenticated => {
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap());
(StatusCode::UNAUTHORIZED, headers).into_response()
}
}
}
}
impl From<AdminError> for AppError {
fn from(value: AdminError) -> Self {
match value {
AdminError::UnexpectedError(error) => AppError::UnexpectedError(error),
AdminError::NotAuthenticated => AppError::NotAuthenticated,
AdminError::ChangePassword(s) => AppError::ValidationError(anyhow::anyhow!(s)),
AdminError::Publish(e) => AppError::ValidationError(e),
AdminError::Idempotency(s) => AppError::UnexpectedError(anyhow::anyhow!(s)),
}
}
}
#[derive(Template)]
#[template(path = "../templates/404.html")]
struct NotFoundTemplate;

View File

@@ -4,19 +4,12 @@ mod logout;
mod newsletters;
mod posts;
use crate::{routes::error_chain_fmt, templates::MessageTemplate};
use askama::Template;
use axum::{
Json,
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use crate::routes::error_chain_fmt;
pub use change_password::*;
pub use dashboard::*;
pub use logout::*;
pub use newsletters::*;
pub use posts::*;
use reqwest::StatusCode;
#[derive(thiserror::Error)]
pub enum AdminError {
@@ -37,45 +30,3 @@ impl std::fmt::Debug for AdminError {
error_chain_fmt(self, f)
}
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
#[derive(serde::Serialize)]
struct ErrorResponse<'a> {
message: &'a str,
}
tracing::error!("{:?}", self);
match &self {
AdminError::UnexpectedError(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
message: "An internal server error occured.",
}),
)
.into_response(),
AdminError::NotAuthenticated => {
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap());
headers.insert("Location", "/login".parse().unwrap());
(StatusCode::SEE_OTHER, headers).into_response()
}
AdminError::ChangePassword(e) => {
let template = MessageTemplate::Error {
message: e.to_owned(),
};
Html(template.render().unwrap()).into_response()
}
AdminError::Publish(e) => {
let template = MessageTemplate::Error {
message: e.to_string(),
};
Html(template.render().unwrap()).into_response()
}
AdminError::Idempotency(e) => {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { message: e })).into_response()
}
}
}
}

View File

@@ -1,6 +1,6 @@
use crate::{
authentication::{self, AuthenticatedUser, Credentials, validate_credentials},
routes::AdminError,
routes::{AdminError, AppError},
startup::AppState,
templates::MessageTemplate,
};
@@ -25,7 +25,7 @@ pub async fn change_password(
connection_pool, ..
}): State<AppState>,
Form(form): Form<PasswordFormData>,
) -> Result<Response, AdminError> {
) -> Result<Response, AppError> {
let credentials = Credentials {
username,
password: form.current_password,
@@ -33,16 +33,15 @@ pub async fn change_password(
if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
Err(AdminError::ChangePassword(
"You entered two different passwords - the field values must match.".to_string(),
))
)
.into())
} else if validate_credentials(credentials, &connection_pool)
.await
.is_err()
{
Err(AdminError::ChangePassword(
"The current password is incorrect.".to_string(),
))
Err(AdminError::ChangePassword("The current password is incorrect.".to_string()).into())
} else if let Err(e) = verify_password(form.new_password.expose_secret()) {
Err(AdminError::ChangePassword(e))
Err(AdminError::ChangePassword(e).into())
} else {
authentication::change_password(user_id, form.new_password, &connection_pool)
.await

View File

@@ -1,13 +1,13 @@
use crate::{routes::AdminError, session_state::TypedSession};
use crate::session_state::TypedSession;
use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
#[tracing::instrument(name = "Logging out", skip(session))]
pub async fn logout(session: TypedSession) -> Result<Response, AdminError> {
pub async fn logout(session: TypedSession) -> Response {
session.clear().await;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap());
Ok((StatusCode::OK, headers).into_response())
(StatusCode::OK, headers).into_response()
}

View File

@@ -1,7 +1,7 @@
use crate::{
authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing},
routes::AdminError,
routes::{AdminError, AppError},
startup::AppState,
templates::MessageTemplate,
};
@@ -75,7 +75,7 @@ pub async fn publish_newsletter(
}): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<BodyData>,
) -> Result<Response, AdminError> {
) -> Result<Response, AppError> {
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
let idempotency_key: IdempotencyKey = form
@@ -104,9 +104,10 @@ pub async fn publish_newsletter(
);
let template = MessageTemplate::Success { message };
let response = Html(template.render().unwrap()).into_response();
save_response(transaction, &idempotency_key, user_id, response)
let response = save_response(transaction, &idempotency_key, user_id, response)
.await
.map_err(AdminError::UnexpectedError)
.map_err(AdminError::UnexpectedError)?;
Ok(response)
}
fn validate_form(form: &BodyData) -> Result<(), &'static str> {

View File

@@ -1,7 +1,7 @@
use crate::{
authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing},
routes::{AdminError, enqueue_delivery_tasks, insert_newsletter_issue},
routes::{AdminError, AppError, enqueue_delivery_tasks, insert_newsletter_issue},
startup::AppState,
templates::MessageTemplate,
};
@@ -38,7 +38,7 @@ pub async fn create_post(
}): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<CreatePostForm>,
) -> Result<Response, AdminError> {
) -> Result<Response, AppError> {
validate_form(&form).map_err(AdminError::Publish)?;
let idempotency_key: IdempotencyKey = form
@@ -65,16 +65,14 @@ pub async fn create_post(
.await
.context("Failed to enqueue delivery tasks.")?;
// Send emails with unique identifiers that contains link to blog post with special param
// Get handpoint that returns the post and mark the email as opened
let template = MessageTemplate::Success {
message: "Your new post has been saved. Subscribers will be notified.".into(),
};
let response = Html(template.render().unwrap()).into_response();
save_response(transaction, &idempotency_key, user_id, response)
let response = save_response(transaction, &idempotency_key, user_id, response)
.await
.map_err(AdminError::UnexpectedError)
.map_err(AdminError::UnexpectedError)?;
Ok(response)
}
#[tracing::instrument(
@@ -116,5 +114,6 @@ pub async fn create_newsletter(
content: &str,
_post_id: &Uuid,
) -> Result<Uuid, sqlx::Error> {
// We need to send a special link with a unique ID to determine if the user clicked it or not.
insert_newsletter_issue(transaction, title, content, content).await
}