From 3a50f3e9f0f971612c5375673f82eb0170541fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 29 Aug 2023 21:34:00 +0200 Subject: [PATCH] more error refactoring --- rust/src/components/inventory.rs | 4 +- rust/src/components/trip/mod.rs | 1 - rust/src/error.rs | 114 +++++++ rust/src/main.rs | 503 ++++++++++--------------------- rust/src/models.rs | 183 +++-------- rust/src/models/error.rs | 19 +- rust/src/sqlite.rs | 18 ++ 7 files changed, 348 insertions(+), 494 deletions(-) create mode 100644 rust/src/error.rs create mode 100644 rust/src/sqlite.rs diff --git a/rust/src/components/inventory.rs b/rust/src/components/inventory.rs index 2d09244..eb5035d 100644 --- a/rust/src/components/inventory.rs +++ b/rust/src/components/inventory.rs @@ -1,8 +1,8 @@ -use maud::{html, Markup, PreEscaped}; +use maud::{html, Markup}; use crate::models; use crate::ClientState; -use uuid::{uuid, Uuid}; +use uuid::Uuid; pub struct Inventory; diff --git a/rust/src/components/trip/mod.rs b/rust/src/components/trip/mod.rs index 51adbd1..0962f96 100644 --- a/rust/src/components/trip/mod.rs +++ b/rust/src/components/trip/mod.rs @@ -6,7 +6,6 @@ use uuid::Uuid; use serde_variant::to_variant_name; -use crate::ClientState; pub struct TripManager; pub mod packagelist; diff --git a/rust/src/error.rs b/rust/src/error.rs new file mode 100644 index 0000000..a0e3e72 --- /dev/null +++ b/rust/src/error.rs @@ -0,0 +1,114 @@ +use std::fmt; + +use crate::components; +use crate::models; + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; + +pub enum RequestError { + EmptyFormElement { name: String }, + RefererNotFound, + RefererInvalid { message: String }, + NotFound { message: String }, +} + +impl fmt::Display for RequestError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::EmptyFormElement { name } => write!(f, "Form element {name} cannot be empty"), + Self::RefererNotFound => write!(f, "Referer header not found"), + Self::RefererInvalid { message } => write!(f, "Referer header invalid: {message}"), + Self::NotFound { message } => write!(f, "Not found: {message}"), + } + } +} + +pub enum Error { + Model(models::Error), + Request(RequestError), +} + +#[derive(Debug)] +pub enum StartError { + DatabaseInitError { message: String }, + DatabaseMigrationError { message: String }, +} + +impl From for StartError { + fn from(value: sqlx::Error) -> Self { + Self::DatabaseInitError { + message: value.to_string(), + } + } +} + +impl From for StartError { + fn from(value: sqlx::migrate::MigrateError) -> Self { + Self::DatabaseMigrationError { + message: value.to_string(), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Model(model_error) => write!(f, "Model error: {model_error}"), + Self::Request(request_error) => write!(f, "Request error: {request_error}"), + } + } +} + +impl From for Error { + fn from(value: models::Error) -> Self { + Self::Model(value) + } +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + match self { + Self::Model(ref model_error) => match model_error { + models::Error::Database(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + components::ErrorPage::build(&format!("{}", self)), + ), + models::Error::Query(error) => match error { + models::QueryError::NotFound { description } => ( + StatusCode::NOT_FOUND, + components::ErrorPage::build(&description), + ), + _ => ( + StatusCode::BAD_REQUEST, + components::ErrorPage::build(&format!("{}", error)), + ), + }, + }, + Self::Request(request_error) => match request_error { + RequestError::RefererNotFound => ( + StatusCode::BAD_REQUEST, + components::ErrorPage::build("no referer header found"), + ), + RequestError::RefererInvalid { message } => ( + StatusCode::BAD_REQUEST, + components::ErrorPage::build(&format!( + "referer could not be converted: {}", + message + )), + ), + RequestError::EmptyFormElement { name } => ( + StatusCode::UNPROCESSABLE_ENTITY, + components::ErrorPage::build(&format!("empty form element: {}", name)), + ), + RequestError::NotFound { message } => ( + StatusCode::NOT_FOUND, + components::ErrorPage::build(&format!("not found: {}", message)), + ), + }, + } + .into_response() + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 9c6eaea..b5bc8a0 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,45 +1,35 @@ -#![allow(unused_imports)] use axum::{ extract::{Path, Query, State}, - headers, - headers::Header, http::{ header, header::{HeaderMap, HeaderName, HeaderValue}, StatusCode, }, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect}, routing::{get, post}, Form, Router, }; use maud::html; -use std::str::FromStr; - -use serde_variant::to_variant_name; - -use sqlx::{ - sqlite::{SqliteConnectOptions, SqlitePoolOptions}, - Pool, Sqlite, -}; - use maud::Markup; use serde::Deserialize; -use futures::TryFutureExt; -use futures::TryStreamExt; -use uuid::{uuid, Uuid}; +use uuid::Uuid; use std::net::SocketAddr; mod components; +mod error; mod models; +mod sqlite; + +use error::{Error, RequestError, StartError}; #[derive(Clone)] pub struct AppState { - database_pool: Pool, + database_pool: sqlite::Pool, client_state: ClientState, } @@ -124,22 +114,15 @@ impl From for HeaderName { } #[tokio::main] -async fn main() -> Result<(), sqlx::Error> { +async fn main() -> Result<(), StartError> { tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .init(); let args = Args::parse(); - let database_pool = SqlitePoolOptions::new() - .max_connections(5) - .connect_with( - SqliteConnectOptions::from_str(&args.database_url)?.pragma("foreign_keys", "1"), - ) - .await - .unwrap(); - - sqlx::migrate!().run(&database_pool).await?; + let database_pool = sqlite::init_database_pool(&args.database_url).await?; + sqlite::migrate(&database_pool).await?; let state = AppState { database_pool, @@ -158,6 +141,14 @@ async fn main() -> Result<(), sqlx::Error> { .route("/favicon.svg", get(icon_handler)) .route("/assets/luggage.svg", get(icon_handler)) .route("/", get(root)) + .route( + "/notfound", + get(|| async { + Error::Request(RequestError::NotFound { + message: "hi".to_string(), + }) + }), + ) .nest( "/trips/", Router::new() @@ -212,7 +203,7 @@ async fn main() -> Result<(), sqlx::Error> { .route("/item/:id/edit", post(inventory_item_edit)) .route("/item/name/validate", post(inventory_item_validate_name)), ) - .fallback(|| async { (StatusCode::NOT_FOUND, "not found") }) + .fallback(|| async { (StatusCode::NOT_FOUND, "no route found") }) .with_state(state); let addr = SocketAddr::from(([127, 0, 0, 1], args.port)); @@ -244,7 +235,7 @@ async fn inventory_active( State(mut state): State, Path(id): Path, Query(inventory_query): Query, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { state.client_state.edit_item = inventory_query.edit_item; state.client_state.active_category_id = Some(id); @@ -258,12 +249,9 @@ async fn inventory_active( .categories .iter() .find(|category| category.id == id) - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!( - "a category with id {id} does not exist" - )), - )) + .ok_or(Error::Request(RequestError::NotFound { + message: format!("a category with id {id} does not exist"), + })) }) .transpose()?; @@ -283,7 +271,7 @@ async fn inventory_active( async fn inventory_inactive( State(mut state): State, Query(inventory_query): Query, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { state.client_state.edit_item = inventory_query.edit_item; state.client_state.active_category_id = None; @@ -323,7 +311,7 @@ struct NewItemName { async fn inventory_item_validate_name( State(state): State, Form(new_item): Form, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { let exists = models::InventoryItem::name_exists(&state.database_pool, &new_item.name).await?; Ok(( @@ -332,40 +320,18 @@ async fn inventory_item_validate_name( )) } -impl From for (StatusCode, Markup) { - fn from(value: models::Error) -> (StatusCode, Markup) { - match value { - models::Error::Database(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&value.to_string()), - ), - models::Error::Query(error) => match error { - models::QueryError::NotFound { description } => ( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&description), - ), - _ => ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&error.to_string()), - ), - }, - } - } -} - async fn inventory_item_create( State(state): State, headers: HeaderMap, Form(new_item): Form, -) -> Result { +) -> Result { if new_item.name.is_empty() { - return Err(( - StatusCode::UNPROCESSABLE_ENTITY, - components::ErrorPage::build("name cannot be empty"), - )); + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); } - let new_id = models::InventoryItem::save( + let _new_id = models::InventoryItem::save( &state.database_pool, &new_item.name, new_item.category_id, @@ -377,18 +343,13 @@ async fn inventory_item_create( let inventory = models::Inventory::load(&state.database_pool).await?; // it's impossible to NOT find the item here, as we literally just added - // it. but good error handling never hurts + // it. let active_category: Option<&models::Category> = Some( inventory .categories .iter() .find(|category| category.id == new_item.category_id) - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!( - "a category with id {new_id} was inserted but does not exist, this is a bug" - )), - ))?, + .unwrap(), ); Ok(( @@ -409,37 +370,31 @@ async fn inventory_item_create( } } +fn get_referer<'a>(headers: &'a HeaderMap) -> Result<&'a str, Error> { + headers + .get("referer") + .ok_or(Error::Request(RequestError::RefererNotFound))? + .to_str() + .map_err(|error| { + Error::Request(RequestError::RefererInvalid { + message: error.to_string(), + }) + }) +} + async fn inventory_item_delete( State(state): State, headers: HeaderMap, Path(id): Path, -) -> Result { +) -> Result { let deleted = models::InventoryItem::delete(&state.database_pool, id).await?; if !deleted { - Err(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("item with id {id} not found")), - )) + Err(Error::Request(RequestError::NotFound { + message: format!("item with id {id} not found"), + })) } else { - Ok(Redirect::to( - headers - .get("referer") - .ok_or(( - StatusCode::BAD_REQUEST, - components::ErrorPage::build("no referer header found"), - ))? - .to_str() - .map_err(|error| { - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&format!( - "referer could not be converted: {}", - error - )), - ) - })?, - )) + Ok(Redirect::to(get_referer(&headers)?)) } } @@ -455,30 +410,15 @@ async fn inventory_item_edit( State(state): State, Path(id): Path, Form(edit_item): Form, -) -> Result { +) -> Result { if edit_item.name.is_empty() { - return Err(( - StatusCode::UNPROCESSABLE_ENTITY, - components::ErrorPage::build("name cannot be empty"), - )); + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); } - let id = models::Item::update( - &state.database_pool, - id, - &edit_item.name, - i64::try_from(edit_item.weight).map_err(|error| { - ( - StatusCode::UNPROCESSABLE_ENTITY, - components::ErrorPage::build(&error.to_string()), - ) - })?, - ) - .await? - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("item with id {id} not found", id = id)), - ))?; + let id = + models::Item::update(&state.database_pool, id, &edit_item.name, edit_item.weight).await?; Ok(Redirect::to(&format!("/inventory/category/{id}/", id = id))) } @@ -486,11 +426,12 @@ async fn inventory_item_edit( async fn inventory_item_cancel( State(state): State, Path(id): Path, -) -> Result { - let id = models::Item::find(&state.database_pool, id).await?.ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("item with id {id} not found")), - ))?; +) -> Result { + let id = models::Item::find(&state.database_pool, id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("item with id {id} not found"), + }))?; Ok(Redirect::to(&format!( "/inventory/category/{id}/", @@ -511,12 +452,11 @@ struct NewTrip { async fn trip_create( State(state): State, Form(new_trip): Form, -) -> Result { +) -> Result { if new_trip.name.is_empty() { - return Err(( - StatusCode::UNPROCESSABLE_ENTITY, - components::ErrorPage::build("name cannot be empty"), - )); + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); } let new_id = models::Trip::save( @@ -530,9 +470,7 @@ async fn trip_create( Ok(Redirect::to(&format!("/trips/{new_id}/"))) } -async fn trips( - State(state): State, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +async fn trips(State(state): State) -> Result<(StatusCode, Markup), Error> { let trips = models::Trip::all(&state.database_pool).await?; Ok(( @@ -554,14 +492,16 @@ async fn trip( State(mut state): State, Path(id): Path, Query(trip_query): Query, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { state.client_state.trip_edit_attribute = trip_query.edit; state.client_state.active_category_id = trip_query.category; - let mut trip: models::Trip = models::Trip::find(&state.database_pool, id).await?.ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("trip with id {} not found", id)), - ))?; + let mut trip: models::Trip = + models::Trip::find(&state.database_pool, id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("trip with id {id} not found"), + }))?; trip.load_trips_types(&state.database_pool).await?; @@ -577,12 +517,9 @@ async fn trip( trip.categories() .iter() .find(|category| category.category.id == id) - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!( - "an active category with id {id} does not exist" - )), - )) + .ok_or(Error::Request(RequestError::NotFound { + message: format!("an active category with id {id} does not exist"), + })) }) .transpose()?; @@ -602,16 +539,13 @@ async fn trip( async fn trip_type_remove( State(state): State, Path((trip_id, type_id)): Path<(Uuid, Uuid)>, -) -> Result { +) -> Result { let found = models::Trip::trip_type_remove(&state.database_pool, trip_id, type_id).await?; if !found { - Err(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!( - "type {type_id} is not active for trip {trip_id}" - )), - )) + Err(Error::Request(RequestError::NotFound { + message: format!("type {type_id} is not active for trip {trip_id}"), + })) } else { Ok(Redirect::to(&format!("/trips/{trip_id}/"))) } @@ -620,7 +554,7 @@ async fn trip_type_remove( async fn trip_type_add( State(state): State, Path((trip_id, type_id)): Path<(Uuid, Uuid)>, -) -> Result { +) -> Result { models::Trip::trip_type_add(&state.database_pool, trip_id, type_id).await?; Ok(Redirect::to(&format!("/trips/{trip_id}/"))) @@ -636,16 +570,15 @@ async fn trip_comment_set( State(state): State, Path(trip_id): Path, Form(comment_update): Form, -) -> Result { +) -> Result { let found = models::Trip::set_comment(&state.database_pool, trip_id, &comment_update.new_comment) .await?; if !found { - Err(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)), - )) + Err(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + })) } else { Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id))) } @@ -661,13 +594,12 @@ async fn trip_edit_attribute( State(state): State, Path((trip_id, attribute)): Path<(Uuid, models::TripAttribute)>, Form(trip_update): Form, -) -> Result { +) -> Result { if attribute == models::TripAttribute::Name { if trip_update.new_value.is_empty() { - return Err(( - StatusCode::UNPROCESSABLE_ENTITY, - components::ErrorPage::build("name cannot be empty"), - )); + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); } } models::Trip::set_attribute( @@ -687,26 +619,18 @@ async fn trip_item_set_state( item_id: Uuid, key: models::TripItemStateKey, value: bool, -) -> Result<(), (StatusCode, Markup)> { +) -> Result<(), Error> { models::TripItem::set_state(&state.database_pool, trip_id, item_id, key, value).await?; Ok(()) } -async fn trip_row( - state: &AppState, - trip_id: Uuid, - item_id: Uuid, -) -> Result { +async fn trip_row(state: &AppState, trip_id: Uuid, item_id: Uuid) -> Result { let item = models::TripItem::find(&state.database_pool, trip_id, item_id) .await? .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!( - "item with id {} not found for trip {}", - item_id, trip_id - )), - ) + Error::Request(RequestError::NotFound { + message: format!("item with id {item_id} not found for trip {trip_id}"), + }) })?; let item_row = components::trip::TripItemListRow::build( @@ -718,13 +642,9 @@ async fn trip_row( let category = models::TripCategory::find(&state.database_pool, trip_id, item.item.category_id) .await? .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!( - "category with id {} not found", - item.item.category_id - )), - ) + Error::Request(RequestError::NotFound { + message: format!("category with id {} not found", item.item.category_id), + }) })?; // TODO biggest_category_weight? @@ -738,8 +658,8 @@ async fn trip_item_set_pick( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, -) -> Result { - Ok::<_, models::Error>( +) -> Result { + Ok::<_, Error>( trip_item_set_state( &state, trip_id, @@ -749,32 +669,13 @@ async fn trip_item_set_pick( ) .await?, ) - .map(|_| -> Result { - Ok(Redirect::to( - headers - .get("referer") - .ok_or(( - StatusCode::BAD_REQUEST, - components::ErrorPage::build("no referer header found"), - ))? - .to_str() - .map_err(|error| { - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&format!( - "referer could not be converted: {}", - error - )), - ) - })?, - )) - })? + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? } async fn trip_item_set_pick_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), Error> { trip_item_set_state( &state, trip_id, @@ -799,8 +700,8 @@ async fn trip_item_set_unpick( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, -) -> Result { - Ok::<_, models::Error>( +) -> Result { + Ok::<_, Error>( trip_item_set_state( &state, trip_id, @@ -810,32 +711,13 @@ async fn trip_item_set_unpick( ) .await?, ) - .map(|_| -> Result { - Ok(Redirect::to( - headers - .get("referer") - .ok_or(( - StatusCode::BAD_REQUEST, - components::ErrorPage::build("no referer header found"), - ))? - .to_str() - .map_err(|error| { - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&format!( - "referer could not be converted: {}", - error - )), - ) - })?, - )) - })? + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? } async fn trip_item_set_unpick_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), Error> { trip_item_set_state( &state, trip_id, @@ -860,8 +742,8 @@ async fn trip_item_set_pack( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, -) -> Result { - Ok::<_, models::Error>( +) -> Result { + Ok::<_, Error>( trip_item_set_state( &state, trip_id, @@ -871,32 +753,13 @@ async fn trip_item_set_pack( ) .await?, ) - .map(|_| -> Result { - Ok(Redirect::to( - headers - .get("referer") - .ok_or(( - StatusCode::BAD_REQUEST, - components::ErrorPage::build("no referer header found"), - ))? - .to_str() - .map_err(|error| { - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&format!( - "referer could not be converted: {}", - error - )), - ) - })?, - )) - })? + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? } async fn trip_item_set_pack_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), Error> { trip_item_set_state( &state, trip_id, @@ -921,8 +784,8 @@ async fn trip_item_set_unpack( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, -) -> Result { - Ok::<_, models::Error>( +) -> Result { + Ok::<_, Error>( trip_item_set_state( &state, trip_id, @@ -932,32 +795,13 @@ async fn trip_item_set_unpack( ) .await?, ) - .map(|_| -> Result { - Ok(Redirect::to( - headers - .get("referer") - .ok_or(( - StatusCode::BAD_REQUEST, - components::ErrorPage::build("no referer header found"), - ))? - .to_str() - .map_err(|error| { - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&format!( - "referer could not be converted: {}", - error - )), - ) - })?, - )) - })? + .map(|_| -> Result { Ok(Redirect::to(get_referer(&headers)?)) })? } async fn trip_item_set_unpack_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), Error> { trip_item_set_state( &state, trip_id, @@ -981,13 +825,9 @@ async fn trip_item_set_unpack_htmx( async fn trip_total_weight_htmx( State(state): State, Path(trip_id): Path, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { - let total_weight = models::Trip::find_total_picked_weight(&state.database_pool, trip_id) - .await? - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("trip with id {trip_id} not found")), - ))?; +) -> Result<(StatusCode, Markup), Error> { + let total_weight = + models::Trip::find_total_picked_weight(&state.database_pool, trip_id).await?; Ok(( StatusCode::OK, components::trip::TripInfoTotalWeightRow::build(trip_id, total_weight), @@ -1003,12 +843,11 @@ struct NewCategory { async fn inventory_category_create( State(state): State, Form(new_category): Form, -) -> Result { +) -> Result { if new_category.name.is_empty() { - return Err(( - StatusCode::UNPROCESSABLE_ENTITY, - components::ErrorPage::build("name cannot be empty"), - )); + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); } let _new_id = models::Category::save(&state.database_pool, &new_category.name).await?; @@ -1020,14 +859,13 @@ async fn trip_state_set( State(state): State, headers: HeaderMap, Path((trip_id, new_state)): Path<(Uuid, models::TripState)>, -) -> Result { +) -> Result { let exists = models::Trip::set_state(&state.database_pool, trip_id, &new_state).await?; if !exists { - return Err(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)), - )); + return Err(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + })); } if is_htmx(&headers) { @@ -1056,7 +894,7 @@ struct TripTypeQuery { async fn trips_types( State(mut state): State, Query(trip_type_query): Query, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { state.client_state.trip_type_edit = trip_type_query.edit; let trip_types: Vec = models::TripsType::all(&state.database_pool).await?; @@ -1079,12 +917,11 @@ struct NewTripType { async fn trip_type_create( State(state): State, Form(new_trip_type): Form, -) -> Result { +) -> Result { if new_trip_type.name.is_empty() { - return Err(( - StatusCode::UNPROCESSABLE_ENTITY, - components::ErrorPage::build("name cannot be empty"), - )); + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); } let _new_id = models::TripsType::save(&state.database_pool, &new_trip_type.name).await?; @@ -1102,12 +939,11 @@ async fn trips_types_edit_name( State(state): State, Path(trip_type_id): Path, Form(trip_update): Form, -) -> Result { +) -> Result { if trip_update.new_value.is_empty() { - return Err(( - StatusCode::UNPROCESSABLE_ENTITY, - components::ErrorPage::build("name cannot be empty"), - )); + return Err(Error::Request(RequestError::EmptyFormElement { + name: "name".to_string(), + })); } let exists = @@ -1115,13 +951,9 @@ async fn trips_types_edit_name( .await?; if !exists { - Err(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!( - "tript type with id {id} not found", - id = trip_type_id - )), - )) + return Err(Error::Request(RequestError::NotFound { + message: format!("trip type with id {trip_type_id} not found"), + })); } else { Ok(Redirect::to("/trips/types/")) } @@ -1130,13 +962,12 @@ async fn trips_types_edit_name( async fn inventory_item( State(state): State, Path(id): Path, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { let item = models::InventoryItem::find(&state.database_pool, id) .await? - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("inventory item with id {id} not found")), - ))?; + .ok_or(Error::Request(RequestError::NotFound { + message: format!("inventory item with id {id} not found"), + }))?; Ok(( StatusCode::OK, @@ -1150,13 +981,12 @@ async fn inventory_item( async fn trip_category_select( State(state): State, Path((trip_id, category_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), Error> { let mut trip = models::Trip::find(&state.database_pool, trip_id) .await? - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("trip with id {trip_id} not found")), - ))?; + .ok_or(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + }))?; trip.load_categories(&state.database_pool).await?; @@ -1164,10 +994,9 @@ async fn trip_category_select( .categories() .iter() .find(|c| c.category.id == category_id) - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("category with id {category_id} not found")), - ))?; + .ok_or(Error::Request(RequestError::NotFound { + message: format!("category with id {category_id} not found"), + }))?; let mut headers = HeaderMap::new(); headers.insert::( @@ -1185,7 +1014,7 @@ async fn trip_category_select( async fn inventory_category_select( State(state): State, Path(category_id): Path, -) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), Error> { let inventory = models::Inventory::load(&state.database_pool).await?; let active_category: Option<&models::Category> = Some( @@ -1193,12 +1022,9 @@ async fn inventory_category_select( .categories .iter() .find(|category| category.id == category_id) - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!( - "a category with id {category_id} not found" - )), - ))?, + .ok_or(Error::Request(RequestError::NotFound { + message: format!("a category with id {category_id} not found"), + }))?, ); let mut headers = HeaderMap::new(); @@ -1223,13 +1049,12 @@ async fn inventory_category_select( async fn trip_packagelist( State(state): State, Path(trip_id): Path, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { let mut trip = models::Trip::find(&state.database_pool, trip_id) .await? - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("trip with id {trip_id} not found")), - ))?; + .ok_or(Error::Request(RequestError::NotFound { + message: format!("trip with id {trip_id} not found"), + }))?; trip.load_categories(&state.database_pool).await?; @@ -1245,7 +1070,7 @@ async fn trip_packagelist( async fn trip_item_packagelist_set_pack_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { trip_item_set_state( &state, trip_id, @@ -1257,10 +1082,9 @@ async fn trip_item_packagelist_set_pack_htmx( let item = models::TripItem::find(&state.database_pool, trip_id, item_id) .await? - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("an item with id {item_id} does not exist")), - ))?; + .ok_or(Error::Request(RequestError::NotFound { + message: format!("an item with id {item_id} does not exist"), + }))?; Ok(( StatusCode::OK, @@ -1271,7 +1095,7 @@ async fn trip_item_packagelist_set_pack_htmx( async fn trip_item_packagelist_set_unpack_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, Markup), Error> { trip_item_set_state( &state, trip_id, @@ -1285,10 +1109,9 @@ async fn trip_item_packagelist_set_unpack_htmx( // return 404. but error handling cannot hurt ;) let item = models::TripItem::find(&state.database_pool, trip_id, item_id) .await? - .ok_or(( - StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("an item with id {item_id} does not exist")), - ))?; + .ok_or(Error::Request(RequestError::NotFound { + message: format!("an item with id {item_id} does not exist"), + }))?; Ok(( StatusCode::OK, diff --git a/rust/src/models.rs b/rust/src/models.rs index f155eed..6db3121 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -1,15 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_variant::to_variant_name; -use sqlx::{ - database::Database, - database::HasValueRef, - sqlite::{Sqlite, SqliteRow}, - Decode, Row, -}; -use std::convert; use std::fmt; -use std::num::TryFromIntError; -use std::str::FromStr; use uuid::Uuid; use futures::TryFutureExt; @@ -288,7 +279,7 @@ impl TripItem { ) -> Result, Error> { let item_id_param = item_id.to_string(); let trip_id_param = trip_id.to_string(); - let item: Result, Error> = sqlx::query_as!( + sqlx::query_as!( DbTripsItemsRow, " SELECT @@ -309,18 +300,10 @@ impl TripItem { item_id_param, trip_id_param, ) - .fetch_one(pool) - .map_ok(|row| row.try_into()) - .await - .map_err(|error| error.into()); - - match item { - Err(error) => match error { - Error::Query(QueryError::NotFound { description: _ }) => Ok(None), - _ => Err(error), - }, - Ok(v) => Ok(Some(v?)), - } + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() } pub async fn set_state( @@ -446,7 +429,7 @@ impl Trip { trip_id: Uuid, ) -> Result, Error> { let trip_id_param = trip_id.to_string(); - let trip = sqlx::query_as!( + sqlx::query_as!( DbTripRow, "SELECT id, @@ -462,18 +445,10 @@ impl Trip { WHERE id = ?", trip_id_param ) - .fetch_one(pool) - .map_ok(|row| row.try_into()) - .map_err(|error| error.into()) - .await; - - match trip { - Err(error) => match error { - Error::Query(QueryError::NotFound { description: _ }) => Ok(None), - _ => Err(error), - }, - Ok(v) => Ok(Some(v?)), - } + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() } pub async fn trip_type_remove( @@ -615,7 +590,7 @@ impl Trip { pub async fn find_total_picked_weight( pool: &sqlx::Pool, trip_id: Uuid, - ) -> Result, Error> { + ) -> Result { let trip_id_param = trip_id.to_string(); let weight = sqlx::query_as!( DbTripWeightRow, @@ -634,17 +609,10 @@ impl Trip { trip_id_param ) .fetch_one(pool) - .map_ok(|row| row.total_weight.map(|weight| weight as i64)) - .map_err(|error| error.into()) - .await; + .map_ok(|row| row.total_weight.unwrap() as i64) + .await?; - match weight { - Err(error) => match error { - Error::Query(QueryError::NotFound { description: _ }) => Ok(None), - _ => Err(error.into()), - }, - Ok(v) => Ok(v), - } + Ok(weight) } pub fn types(&self) -> &Vec { @@ -997,7 +965,7 @@ impl Category { id: Uuid, ) -> Result, Error> { let id_param = id.to_string(); - let item = sqlx::query_as!( + sqlx::query_as!( DbCategoryRow, "SELECT id, @@ -1007,18 +975,10 @@ impl Category { WHERE category.id = ?", id_param, ) - .fetch_one(pool) - .map_ok(|row| row.try_into()) - .map_err(|error| error.into()) - .await; - - match item { - Err(error) => match error { - Error::Query(QueryError::NotFound { description: _ }) => Ok(None), - _ => Err(error), - }, - Ok(v) => Ok(Some(v?)), - } + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() } pub async fn save(pool: &sqlx::Pool, name: &str) -> Result { @@ -1100,7 +1060,7 @@ impl TryFrom for Item { impl Item { pub async fn find(pool: &sqlx::Pool, id: Uuid) -> Result, Error> { let id_param = id.to_string(); - let item = sqlx::query_as!( + sqlx::query_as!( DbInventoryItemsRow, "SELECT id, @@ -1112,28 +1072,22 @@ impl Item { WHERE item.id = ?", id_param, ) - .fetch_one(pool) - .map_err(|error| error.into()) - .map_ok(|row| row.try_into()) - .await; - - match item { - Err(error) => match error { - Error::Query(QueryError::NotFound { description: _ }) => Ok(None), - _ => Err(error), - }, - Ok(v) => Ok(Some(v?)), - } + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() } pub async fn update( pool: &sqlx::Pool, id: Uuid, name: &str, - weight: i64, - ) -> Result, Error> { + weight: u32, + ) -> Result { + let weight = i64::try_from(weight).unwrap(); + let id_param = id.to_string(); - let id = sqlx::query!( + Ok(sqlx::query!( "UPDATE inventory_items AS item SET name = ?, @@ -1146,22 +1100,8 @@ impl Item { id_param, ) .fetch_one(pool) - .map_ok(|row| { - let id: &str = &row.id.unwrap(); // TODO - let uuid: Result = Uuid::try_parse(id); - let uuid: Result = uuid.map_err(|error| error.into()); - uuid - }) - .map_err(|error| error.into()) - .await; - - match id { - Err(error) => match error { - Error::Query(QueryError::NotFound { description: _ }) => Ok(None), - _ => Err(error.into()), - }, - Ok(v) => Ok(Some(v?)), - } + .map_ok(|row| Uuid::try_parse(&row.id.unwrap())) + .await??) } pub async fn get_category_max_weight( @@ -1197,7 +1137,7 @@ impl Item { category_id: Uuid, ) -> Result { let category_id_param = category_id.to_string(); - let weight: Result = sqlx::query!( + Ok(sqlx::query!( " SELECT COALESCE(SUM(i_item.weight), 0) as weight FROM inventory_items_categories as category @@ -1218,10 +1158,7 @@ impl Item { // We can be certain that the row exists, as we COALESCE it row.weight.unwrap() as i64 }) - .map_err(|error| error.into()) - .await; - - Ok(weight?) + .await?) } } @@ -1316,7 +1253,7 @@ impl InventoryItem { pub async fn find(pool: &sqlx::Pool, id: Uuid) -> Result, Error> { let id_param = id.to_string(); - let item = sqlx::query_as!( + sqlx::query_as!( DbInventoryItemRow, "SELECT item.id AS id, @@ -1338,39 +1275,23 @@ impl InventoryItem { WHERE item.id = ?", id_param, ) - .fetch_one(pool) - .map_ok(|row| row.try_into()) - .map_err(|error| error.into()) - .await; - - match item { - Err(error) => match error { - Error::Query(QueryError::NotFound { description: _ }) => Ok(None), - _ => Err(error.into()), - }, - Ok(v) => Ok(Some(v?)), - } + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() } pub async fn name_exists(pool: &sqlx::Pool, name: &str) -> Result { - let item = sqlx::query!( + Ok(sqlx::query!( "SELECT id FROM inventory_items WHERE name = ?", name, ) - .fetch_one(pool) - .map_ok(|_row| ()) - .map_err(|error| error.into()) - .await; - - match item { - Err(error) => match error { - Error::Query(QueryError::NotFound { description: _ }) => Ok(false), - _ => Err(error.into()), - }, - Ok(_) => Ok(true), - } + .fetch_optional(pool) + .await? + .map(|_row| ()) + .is_some()) } pub async fn delete(pool: &sqlx::Pool, id: Uuid) -> Result { @@ -1427,23 +1348,9 @@ impl Inventory { .fetch(pool) .map_ok(|row: DbCategoryRow| row.try_into()) .try_collect::>>() - .await - // we have two error handling lines here. these are distinct errors - // this one is the SQL error that may arise during the query - .map_err(|error| { - Error::Database(DatabaseError::Sql { - description: error.to_string(), - }) - })? + .await? .into_iter() - .collect::, Error>>() - // and this one is the model mapping error that may arise e.g. during - // reading of the rows - .map_err(|error| { - Error::Database(DatabaseError::Sql { - description: error.to_string(), - }) - })?; + .collect::, Error>>()?; for category in &mut categories { category.populate_items(pool).await?; diff --git a/rust/src/models/error.rs b/rust/src/models/error.rs index 3a28b0e..99cd195 100644 --- a/rust/src/models/error.rs +++ b/rust/src/models/error.rs @@ -1,4 +1,3 @@ -use std::convert; use std::fmt; use sqlx::error::DatabaseError as _; @@ -43,9 +42,6 @@ impl fmt::Display for DatabaseError { pub enum QueryError { /// Errors that are caused by wrong input data, e.g. ids that cannot be found, or /// inserts that violate unique constraints - Constraint { - description: String, - }, Duplicate { description: String, }, @@ -60,9 +56,6 @@ pub enum QueryError { impl fmt::Display for QueryError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - Self::Constraint { description } => { - write!(f, "SQL constraint error: {description}") - } Self::Duplicate { description } => { write!(f, "Duplicate data entry: {description}") } @@ -97,7 +90,7 @@ impl fmt::Debug for Error { } } -impl convert::From for Error { +impl From for Error { fn from(value: uuid::Error) -> Self { Error::Database(DatabaseError::Uuid { description: value.to_string(), @@ -105,7 +98,7 @@ impl convert::From for Error { } } -impl convert::From for Error { +impl From for Error { fn from(value: time::error::Format) -> Self { Error::Database(DatabaseError::TimeParse { description: value.to_string(), @@ -113,7 +106,7 @@ impl convert::From for Error { } } -impl convert::From for Error { +impl From for Error { fn from(value: sqlx::Error) -> Self { match value { sqlx::Error::RowNotFound => Error::Query(QueryError::NotFound { @@ -124,11 +117,11 @@ impl convert::From for Error { if let Some(code) = sqlite_error.code() { match &*code { // SQLITE_CONSTRAINT_FOREIGNKEY - "787" => Error::Query(QueryError::Constraint { + "787" => Error::Query(QueryError::ReferenceNotFound { description: format!("foreign key reference not found"), }), // SQLITE_CONSTRAINT_UNIQUE - "2067" => Error::Query(QueryError::Constraint { + "2067" => Error::Query(QueryError::Duplicate { description: format!("item with unique constraint already exists",), }), _ => Error::Database(DatabaseError::Sql { @@ -154,7 +147,7 @@ impl convert::From for Error { } } -impl convert::From for Error { +impl From for Error { fn from(value: time::error::Parse) -> Self { Error::Database(DatabaseError::TimeParse { description: value.to_string(), diff --git a/rust/src/sqlite.rs b/rust/src/sqlite.rs new file mode 100644 index 0000000..e000ce5 --- /dev/null +++ b/rust/src/sqlite.rs @@ -0,0 +1,18 @@ +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +pub use sqlx::{Pool, Sqlite}; + +use std::str::FromStr as _; + +use crate::StartError; + +pub async fn init_database_pool(url: &str) -> Result, StartError> { + Ok(SqlitePoolOptions::new() + .max_connections(5) + .connect_with(SqliteConnectOptions::from_str(url)?.pragma("foreign_keys", "1")) + .await?) +} + +pub async fn migrate(pool: &Pool) -> Result<(), StartError> { + sqlx::migrate!().run(pool).await?; + Ok(()) +}