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::{ 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 anyhow::Context;
use argon2::{ use argon2::{
@@ -135,7 +137,7 @@ pub async fn require_auth(
session: TypedSession, session: TypedSession,
mut request: Request, mut request: Request,
next: Next, next: Next,
) -> Result<Response, AdminError> { ) -> Result<Response, AppError> {
let user_id = session let user_id = session
.get_user_id() .get_user_id()
.await .await

View File

@@ -8,7 +8,10 @@ mod subscriptions_confirm;
pub use admin::*; pub use admin::*;
use askama::Template; use askama::Template;
use axum::response::{Html, IntoResponse, Response}; use axum::{
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
pub use health_check::*; pub use health_check::*;
pub use home::*; pub use home::*;
pub use login::*; pub use login::*;
@@ -17,6 +20,62 @@ use reqwest::StatusCode;
pub use subscriptions::*; pub use subscriptions::*;
pub use subscriptions_confirm::*; 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)] #[derive(Template)]
#[template(path = "../templates/404.html")] #[template(path = "../templates/404.html")]
struct NotFoundTemplate; struct NotFoundTemplate;

View File

@@ -4,19 +4,12 @@ mod logout;
mod newsletters; mod newsletters;
mod posts; mod posts;
use crate::{routes::error_chain_fmt, templates::MessageTemplate}; use crate::routes::error_chain_fmt;
use askama::Template;
use axum::{
Json,
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
pub use change_password::*; pub use change_password::*;
pub use dashboard::*; pub use dashboard::*;
pub use logout::*; pub use logout::*;
pub use newsletters::*; pub use newsletters::*;
pub use posts::*; pub use posts::*;
use reqwest::StatusCode;
#[derive(thiserror::Error)] #[derive(thiserror::Error)]
pub enum AdminError { pub enum AdminError {
@@ -37,45 +30,3 @@ impl std::fmt::Debug for AdminError {
error_chain_fmt(self, f) 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::{ use crate::{
authentication::{self, AuthenticatedUser, Credentials, validate_credentials}, authentication::{self, AuthenticatedUser, Credentials, validate_credentials},
routes::AdminError, routes::{AdminError, AppError},
startup::AppState, startup::AppState,
templates::MessageTemplate, templates::MessageTemplate,
}; };
@@ -25,7 +25,7 @@ pub async fn change_password(
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Form(form): Form<PasswordFormData>, Form(form): Form<PasswordFormData>,
) -> Result<Response, AdminError> { ) -> Result<Response, AppError> {
let credentials = Credentials { let credentials = Credentials {
username, username,
password: form.current_password, 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() { if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
Err(AdminError::ChangePassword( Err(AdminError::ChangePassword(
"You entered two different passwords - the field values must match.".to_string(), "You entered two different passwords - the field values must match.".to_string(),
)) )
.into())
} else if validate_credentials(credentials, &connection_pool) } else if validate_credentials(credentials, &connection_pool)
.await .await
.is_err() .is_err()
{ {
Err(AdminError::ChangePassword( Err(AdminError::ChangePassword("The current password is incorrect.".to_string()).into())
"The current password is incorrect.".to_string(),
))
} else if let Err(e) = verify_password(form.new_password.expose_secret()) { } else if let Err(e) = verify_password(form.new_password.expose_secret()) {
Err(AdminError::ChangePassword(e)) Err(AdminError::ChangePassword(e).into())
} else { } else {
authentication::change_password(user_id, form.new_password, &connection_pool) authentication::change_password(user_id, form.new_password, &connection_pool)
.await .await

View File

@@ -1,13 +1,13 @@
use crate::{routes::AdminError, session_state::TypedSession}; use crate::session_state::TypedSession;
use axum::{ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
#[tracing::instrument(name = "Logging out", skip(session))] #[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; session.clear().await;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap()); 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::{ use crate::{
authentication::AuthenticatedUser, authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing}, idempotency::{IdempotencyKey, save_response, try_processing},
routes::AdminError, routes::{AdminError, AppError},
startup::AppState, startup::AppState,
templates::MessageTemplate, templates::MessageTemplate,
}; };
@@ -75,7 +75,7 @@ pub async fn publish_newsletter(
}): State<AppState>, }): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<BodyData>, Form(form): Form<BodyData>,
) -> Result<Response, AdminError> { ) -> Result<Response, AppError> {
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?; validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
let idempotency_key: IdempotencyKey = form let idempotency_key: IdempotencyKey = form
@@ -104,9 +104,10 @@ pub async fn publish_newsletter(
); );
let template = MessageTemplate::Success { message }; let template = MessageTemplate::Success { message };
let response = Html(template.render().unwrap()).into_response(); 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 .await
.map_err(AdminError::UnexpectedError) .map_err(AdminError::UnexpectedError)?;
Ok(response)
} }
fn validate_form(form: &BodyData) -> Result<(), &'static str> { fn validate_form(form: &BodyData) -> Result<(), &'static str> {

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
authentication::AuthenticatedUser, authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing}, 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, startup::AppState,
templates::MessageTemplate, templates::MessageTemplate,
}; };
@@ -38,7 +38,7 @@ pub async fn create_post(
}): State<AppState>, }): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<CreatePostForm>, Form(form): Form<CreatePostForm>,
) -> Result<Response, AdminError> { ) -> Result<Response, AppError> {
validate_form(&form).map_err(AdminError::Publish)?; validate_form(&form).map_err(AdminError::Publish)?;
let idempotency_key: IdempotencyKey = form let idempotency_key: IdempotencyKey = form
@@ -65,16 +65,14 @@ pub async fn create_post(
.await .await
.context("Failed to enqueue delivery tasks.")?; .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 { let template = MessageTemplate::Success {
message: "Your new post has been saved. Subscribers will be notified.".into(), message: "Your new post has been saved. Subscribers will be notified.".into(),
}; };
let response = Html(template.render().unwrap()).into_response(); 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 .await
.map_err(AdminError::UnexpectedError) .map_err(AdminError::UnexpectedError)?;
Ok(response)
} }
#[tracing::instrument( #[tracing::instrument(
@@ -116,5 +114,6 @@ pub async fn create_newsletter(
content: &str, content: &str,
_post_id: &Uuid, _post_id: &Uuid,
) -> Result<Uuid, sqlx::Error> { ) -> 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 insert_newsletter_issue(transaction, title, content, content).await
} }