refactoring

This commit is contained in:
2023-08-29 21:34:00 +02:00
parent c1f16ce035
commit edd9b94fb4
7 changed files with 951 additions and 390 deletions

View File

@@ -3,8 +3,12 @@ use axum::{
extract::{Path, Query, State},
headers,
headers::Header,
http::{header, header::HeaderMap, StatusCode},
response::{Html, Redirect},
http::{
header,
header::{HeaderMap, HeaderName, HeaderValue},
StatusCode,
},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
Form, Router,
};
@@ -50,7 +54,9 @@ use clap::Parser;
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(long)]
port: Option<u16>,
database_url: String,
#[arg(long, default_value_t = 3000)]
port: u16,
}
#[derive(Clone)]
@@ -78,19 +84,60 @@ impl Default for ClientState {
}
}
enum HtmxEvents {
TripItemEdited,
}
impl Into<HeaderValue> for HtmxEvents {
fn into(self) -> HeaderValue {
HeaderValue::from_static(self.to_str())
}
}
impl HtmxEvents {
fn to_str(self) -> &'static str {
match self {
Self::TripItemEdited => "TripItemEdited",
}
}
}
enum HtmxResponseHeaders {
Trigger,
}
impl Into<HeaderName> for HtmxResponseHeaders {
fn into(self) -> HeaderName {
match self {
Self::Trigger => HeaderName::from_static("hx-trigger"),
}
}
}
enum HtmxRequestHeaders {
HtmxRequest,
}
impl Into<HeaderName> for HtmxRequestHeaders {
fn into(self) -> HeaderName {
match self {
Self::HtmxRequest => HeaderName::from_static("hx-request"),
}
}
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
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(
&std::env::var("DATABASE_URL").expect("env DATABASE_URL not found"),
)?
.pragma("foreign_keys", "1"),
SqliteConnectOptions::from_str(&args.database_url)?.pragma("foreign_keys", "1"),
)
.await
.unwrap();
@@ -118,12 +165,14 @@ async fn main() -> Result<(), sqlx::Error> {
"/trips/",
Router::new()
.route("/", get(trips))
.route("/trips/types/", get(trips_types).post(trip_type_create))
.route("/types/", get(trips_types).post(trip_type_create))
.route("/types/:id/edit/name/submit", post(trips_types_edit_name))
.route("/", post(trip_create))
.route("/:id/", get(trip))
.route("/:id/comment/submit", post(trip_comment_set))
.route("/:id/categories/:id/select", post(trip_category_select))
.route("/:id/state/:id", post(trip_state_set))
.route("/:id/total_weight", get(trip_total_weight_htmx))
.route("/:id/type/:id/add", get(trip_type_add))
.route("/:id/type/:id/remove", get(trip_type_remove))
.route("/:id/edit/:attribute/submit", post(trip_edit_attribute))
@@ -163,9 +212,7 @@ async fn main() -> Result<(), sqlx::Error> {
.fallback(|| async { (StatusCode::NOT_FOUND, "not found") })
.with_state(state);
let args = Args::parse();
let addr = SocketAddr::from(([127, 0, 0, 1], args.port.unwrap_or(3000)));
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
tracing::debug!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
@@ -193,7 +240,43 @@ async fn inventory_active(
Query(inventory_query): Query<InventoryQuery>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
state.client_state.edit_item = inventory_query.edit_item;
inventory(state, Some(id)).await
state.client_state.active_category_id = Some(id);
let inventory = models::Inventory::load(&state.database_pool)
.await
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&error.to_string()),
)
})?;
let active_category: Option<&Category> = state
.client_state
.active_category_id
.map(|id| {
inventory
.categories
.iter()
.find(|category| category.id == id)
.ok_or((
StatusCode::NOT_FOUND,
ErrorPage::build(&format!("a category with id {id} does not exist")),
))
})
.transpose()?;
Ok((
StatusCode::OK,
Root::build(
&components::Inventory::build(
active_category,
&inventory.categories,
state.client_state.edit_item,
),
&TopLevelPage::Inventory,
),
))
}
async fn inventory_inactive(
@@ -201,71 +284,53 @@ async fn inventory_inactive(
Query(inventory_query): Query<InventoryQuery>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
state.client_state.edit_item = inventory_query.edit_item;
inventory(state, None).await
}
state.client_state.active_category_id = None;
async fn inventory(
mut state: AppState,
active_id: Option<Uuid>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
state.client_state.active_category_id = active_id;
let mut categories = query_as!(
DbCategoryRow,
"SELECT id,name,description FROM inventory_items_categories"
)
.fetch(&state.database_pool)
.map_ok(|row: DbCategoryRow| row.try_into())
.try_collect::<Vec<Result<Category, models::Error>>>()
.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(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
)
})?
.into_iter()
.collect::<Result<Vec<Category>, models::Error>>()
// and this one is the model mapping error that may arise e.g. during
// reading of the rows
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
)
})?;
for category in &mut categories {
category
.populate_items(&state.database_pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
)
})?;
}
let inventory = models::Inventory::load(&state.database_pool)
.await
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&error.to_string()),
)
})?;
Ok((
StatusCode::OK,
Root::build(
&Inventory::build(state.client_state, categories).map_err(|e| match e {
Error::NotFound { description } => {
(StatusCode::NOT_FOUND, ErrorPage::build(&description))
}
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
),
})?,
&components::Inventory::build(
None,
&inventory.categories,
state.client_state.edit_item,
),
&TopLevelPage::Inventory,
),
))
}
// async fn inventory(
// mut state: AppState,
// active_id: Option<Uuid>,
// ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
// state.client_state.active_category_id = active_id;
// Ok((
// StatusCode::OK,
// Root::build(
// &components::Inventory::build(state.client_state, categories).map_err(|e| match e {
// Error::NotFound { description } => {
// (StatusCode::NOT_FOUND, ErrorPage::build(&description))
// }
// _ => (
// StatusCode::INTERNAL_SERVER_ERROR,
// ErrorPage::build(&e.to_string()),
// ),
// })?,
// &TopLevelPage::Inventory,
// ),
// ))
// }
#[derive(Deserialize)]
struct NewItem {
#[serde(rename = "new-item-name")]
@@ -325,12 +390,13 @@ async fn inventory_item_validate_name(
async fn inventory_item_create(
State(state): State<AppState>,
headers: HeaderMap,
Form(new_item): Form<NewItem>,
) -> Result<Redirect, (StatusCode, String)> {
) -> Result<impl IntoResponse, (StatusCode, Markup)> {
if new_item.name.is_empty() {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"name cannot be empty".to_string(),
ErrorPage::build("name cannot be empty"),
));
}
@@ -360,42 +426,90 @@ async fn inventory_item_create(
// SQLITE_CONSTRAINT_FOREIGNKEY
(
StatusCode::BAD_REQUEST,
format!("category {id} not found", id = new_item.category_id),
ErrorPage::build(&format!(
"category {id} not found",
id = new_item.category_id
)),
)
}
"2067" => {
// SQLITE_CONSTRAINT_UNIQUE
(
StatusCode::BAD_REQUEST,
format!(
ErrorPage::build(&format!(
"item with name \"{name}\" already exists in category {id}",
name = new_item.name,
id = new_item.category_id
),
)),
)
}
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("got error with unknown code: {}", sqlite_error.to_string()),
ErrorPage::build(&format!(
"got error with unknown code: {}",
sqlite_error.to_string()
)),
),
}
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("got error without code: {}", sqlite_error.to_string()),
ErrorPage::build(&format!(
"got error without code: {}",
sqlite_error.to_string()
)),
)
}
}
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("got unknown error: {}", e.to_string()),
ErrorPage::build(&format!("got unknown error: {}", e.to_string())),
),
})?;
Ok(Redirect::to(&format!(
"/inventory/category/{id}/",
id = new_item.category_id
)))
if is_htmx(&headers) {
let inventory = models::Inventory::load(&state.database_pool)
.await
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&error.to_string()),
)
})?;
// it's impossible to NOT find the item here, as we literally just added
// it. but good error handling never hurts
let active_category: Option<&Category> = state
.client_state
.active_category_id
.map(|id| {
inventory
.categories
.iter()
.find(|category| category.id == id)
.ok_or((
StatusCode::NOT_FOUND,
ErrorPage::build(&format!("a category with id {id} was inserted but does not exist, this is a bug")),
))
})
.transpose()?;
Ok((
StatusCode::OK,
components::Inventory::build(
active_category,
&inventory.categories,
state.client_state.edit_item,
),
)
.into_response())
} else {
Ok(Redirect::to(&format!(
"/inventory/category/{id}/",
id = new_item.category_id
))
.into_response())
}
}
async fn inventory_item_delete(
@@ -778,18 +892,28 @@ async fn trip(
)
})?;
let active_category: Option<&TripCategory> = state
.client_state
.active_category_id
.map(|id| {
trip.categories()
.iter()
.find(|category| category.category.id == id)
.ok_or((
StatusCode::NOT_FOUND,
ErrorPage::build(&format!("an active category with id {id} does not exist")),
))
})
.transpose()?;
Ok((
StatusCode::OK,
Root::build(
&components::Trip::build(&state.client_state, &trip).map_err(|e| match e {
Error::NotFound { description } => {
(StatusCode::NOT_FOUND, ErrorPage::build(&description))
}
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
),
})?,
&components::Trip::build(
&trip,
state.client_state.trip_edit_attribute,
active_category,
),
&TopLevelPage::Trips,
),
))
@@ -1049,7 +1173,9 @@ async fn trip_row(
)
})?;
let category_row = components::trip::TripCategoryListRow::build(&category, true, 0, true);
// TODO biggest_category_weight?
let category_row =
components::trip::TripCategoryListRow::build(trip_id, &category, true, 0, true);
Ok(html!((item_row)(category_row)))
}
@@ -1083,9 +1209,18 @@ async fn trip_item_set_pick(
async fn trip_item_set_pick_htmx(
State(state): State<AppState>,
Path((trip_id, item_id)): Path<(Uuid, Uuid)>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> {
trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, true).await?;
Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?))
let mut headers = HeaderMap::new();
headers.insert::<HeaderName>(
HtmxResponseHeaders::Trigger.into(),
HtmxEvents::TripItemEdited.into(),
);
Ok((
StatusCode::OK,
headers,
trip_row(&state, trip_id, item_id).await?,
))
}
async fn trip_item_set_unpick(
@@ -1117,9 +1252,18 @@ async fn trip_item_set_unpick(
async fn trip_item_set_unpick_htmx(
State(state): State<AppState>,
Path((trip_id, item_id)): Path<(Uuid, Uuid)>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> {
trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, false).await?;
Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?))
let mut headers = HeaderMap::new();
headers.insert::<HeaderName>(
HtmxResponseHeaders::Trigger.into(),
HtmxEvents::TripItemEdited.into(),
);
Ok((
StatusCode::OK,
headers,
trip_row(&state, trip_id, item_id).await?,
))
}
async fn trip_item_set_pack(
@@ -1151,9 +1295,18 @@ async fn trip_item_set_pack(
async fn trip_item_set_pack_htmx(
State(state): State<AppState>,
Path((trip_id, item_id)): Path<(Uuid, Uuid)>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> {
trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, true).await?;
Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?))
let mut headers = HeaderMap::new();
headers.insert::<HeaderName>(
HtmxResponseHeaders::Trigger.into(),
HtmxEvents::TripItemEdited.into(),
);
Ok((
StatusCode::OK,
headers,
trip_row(&state, trip_id, item_id).await?,
))
}
async fn trip_item_set_unpack(
@@ -1185,9 +1338,40 @@ async fn trip_item_set_unpack(
async fn trip_item_set_unpack_htmx(
State(state): State<AppState>,
Path((trip_id, item_id)): Path<(Uuid, Uuid)>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> {
trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, false).await?;
Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?))
let mut headers = HeaderMap::new();
headers.insert::<HeaderName>(
HtmxResponseHeaders::Trigger.into(),
HtmxEvents::TripItemEdited.into(),
);
Ok((
StatusCode::OK,
headers,
trip_row(&state, trip_id, item_id).await?,
))
}
async fn trip_total_weight_htmx(
State(state): State<AppState>,
Path(trip_id): Path<Uuid>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
let total_weight = models::Trip::find_total_picked_weight(&state.database_pool, trip_id)
.await
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
ErrorPage::build(&error.to_string()),
)
})?
.ok_or((
StatusCode::NOT_FOUND,
ErrorPage::build(&format!("trip with id {trip_id} not found")),
))?;
Ok((
StatusCode::OK,
components::trip::TripInfoTotalWeightRow::build(trip_id, total_weight),
))
}
#[derive(Deserialize)]
@@ -1263,8 +1447,9 @@ async fn inventory_category_create(
async fn trip_state_set(
State(state): State<AppState>,
headers: HeaderMap,
Path((trip_id, new_state)): Path<(Uuid, TripState)>,
) -> Result<Redirect, (StatusCode, Markup)> {
) -> Result<impl IntoResponse, (StatusCode, Markup)> {
let trip_id = trip_id.to_string();
let result = query!(
"UPDATE trips
@@ -1283,10 +1468,25 @@ async fn trip_state_set(
ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)),
))
} else {
Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id)))
if is_htmx(&headers) {
Ok((
StatusCode::OK,
components::trip::TripInfoStateRow::build(&new_state),
)
.into_response())
} else {
Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id)).into_response())
}
}
}
fn is_htmx(headers: &HeaderMap) -> bool {
headers
.get::<HeaderName>(HtmxRequestHeaders::HtmxRequest.into())
.map(|value| value == "true")
.unwrap_or(false)
}
#[derive(Debug, Deserialize)]
struct TripTypeQuery {
edit: Option<Uuid>,
@@ -1500,3 +1700,44 @@ async fn inventory_item(
),
))
}
async fn trip_category_select(
State(state): State<AppState>,
Path((trip_id, category_id)): Path<(Uuid, Uuid)>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
let mut trip = models::Trip::find(&state.database_pool, trip_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
)
})?
.ok_or((
StatusCode::NOT_FOUND,
ErrorPage::build(&format!("trip with id {trip_id} not found")),
))?;
trip.load_categories(&state.database_pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
)
})?;
let active_category = trip
.categories()
.iter()
.find(|c| c.category.id == category_id)
.ok_or((
StatusCode::NOT_FOUND,
ErrorPage::build(&format!("category with id {category_id} not found")),
))?;
Ok((
StatusCode::OK,
components::trip::TripItems::build(Some(&active_category), &trip),
))
}