error messages

This commit is contained in:
Priec
2026-08-15 15:08:35 +02:00
parent d0bcdb1b7c
commit fe3848c453
10 changed files with 395 additions and 348 deletions

View File

@@ -62,3 +62,19 @@ pub(crate) enum LoadError {
Forbidden, Forbidden,
Backend(String), Backend(String),
} }
impl LoadError {
/// Hands the page load's failure to the one place that decides statuses.
/// `permission_key` names the message shown when the load was refused.
pub(crate) fn into_form_error(
self,
locale: crate::i18n::Locale,
permission_key: &str,
) -> crate::ui::FormError {
match self {
Self::Unauthenticated => crate::ui::FormError::Unauthenticated,
Self::Forbidden => crate::ui::FormError::Forbidden(locale.lookup(permission_key)),
Self::Backend(message) => crate::ui::FormError::Unavailable(message),
}
}
}

View File

@@ -1,14 +1,15 @@
use axum::{ use axum::{
Form, Form,
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::HeaderMap,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use crate::{ use crate::{
AppState, AppState,
{i18n::Locale, tr}, i18n::Locale,
services::{authenticated_request, reject_cross_site}, services::{authenticated_request, reject_cross_site},
ui::FormError,
}; };
use super::{ use super::{
@@ -38,12 +39,7 @@ pub(crate) async fn create_logic(
} }
let request = match form.into_request(Locale::from_headers(&headers)) { let request = match form.into_request(Locale::from_headers(&headers)) {
Ok(request) => request, Ok(request) => request,
Err(message) => { Err(message) => return reject(&headers, message),
return render_loaded(
&headers,
load_page(state, &headers, submitted, Some(message)).await,
);
}
}; };
let mut scripts = state.scripts; let mut scripts = state.scripts;
let request = match authenticated_request(&headers, request) { let request = match authenticated_request(&headers, request) {
@@ -57,34 +53,25 @@ pub(crate) async fn create_logic(
&response.get_ref().warnings, &response.get_ref().warnings,
)) ))
.into_response(), .into_response(),
Err(error) => ( Err(error) => FormError::from_status(&error)
StatusCode::UNPROCESSABLE_ENTITY, .into_response(Locale::from_headers(&headers), ui::render_submission_error),
Html(ui::render_submission_error(
Locale::from_headers(&headers),
error.message(),
)),
)
.into_response(),
} }
} }
/// A submission this crate rejected before it reached the backend. Same
/// response as a backend rejection: the user has to change something either
/// way, and the form's `hx-target` is the status block in both cases.
fn reject(headers: &HeaderMap, message: String) -> Response {
FormError::Rejected(message)
.into_response(Locale::from_headers(headers), ui::render_submission_error)
}
fn render_loaded(headers: &HeaderMap, result: Result<super::state::AddLogicPageState, LoadError>) -> Response { fn render_loaded(headers: &HeaderMap, result: Result<super::state::AddLogicPageState, LoadError>) -> Response {
let locale = Locale::from_headers(headers); let locale = Locale::from_headers(headers);
match result { match result {
Ok(page) => Html(ui::render_page(&page)).into_response(), Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), Err(error) => error
Err(LoadError::Forbidden) => { .into_form_error(locale, "add-logic-err-permission")
let message = tr!(locale, "add-logic-err-permission"); .into_response(locale, ui::render_submission_error),
(
StatusCode::FORBIDDEN,
Html(ui::render_submission_error(locale, &message)),
)
.into_response()
}
Err(LoadError::Backend(message)) => (
StatusCode::BAD_GATEWAY,
Html(ui::render_submission_error(locale, &message)),
)
.into_response(),
} }
} }

View File

@@ -68,3 +68,19 @@ pub(crate) enum LoadError {
Forbidden, Forbidden,
Backend(String), Backend(String),
} }
impl LoadError {
/// Hands the page load's failure to the one place that decides statuses.
/// `permission_key` names the message shown when the load was refused.
pub(crate) fn into_form_error(
self,
locale: crate::i18n::Locale,
permission_key: &str,
) -> crate::ui::FormError {
match self {
Self::Unauthenticated => crate::ui::FormError::Unauthenticated,
Self::Forbidden => crate::ui::FormError::Forbidden(locale.lookup(permission_key)),
Self::Backend(message) => crate::ui::FormError::Unavailable(message),
}
}
}

View File

@@ -1,14 +1,15 @@
use axum::{ use axum::{
Form, Form,
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::HeaderMap,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use crate::{ use crate::{
AppState, AppState,
{i18n::Locale, tr}, i18n::Locale,
services::{authenticated_request, reject_cross_site}, services::{authenticated_request, reject_cross_site},
ui::FormError,
}; };
use super::{ use super::{
@@ -17,6 +18,9 @@ use super::{
ui, ui,
}; };
/// The message shown when the page load itself was refused.
const PERMISSION_KEY: &str = "validation-err-permission";
pub(crate) async fn new_field_validation_page(State(state): State<AppState>, headers: HeaderMap) -> Response { pub(crate) async fn new_field_validation_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
render_loaded(&headers, load_page(state, &headers, ValidationForm::default(), false, None).await) render_loaded(&headers, load_page(state, &headers, ValidationForm::default(), false, None).await)
} }
@@ -36,85 +40,51 @@ pub(crate) async fn save_field_validation(
return rejection; return rejection;
} }
let submitted = form.clone(); let submitted = form.clone();
if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), false, None).await { if let Err(error) = load_page(state.clone(), &headers, submitted, false, None).await {
return render_loaded(&headers, Err(error)); return load_failed(&headers, error);
} }
// A set name turns this into "apply that set to this column" instead of
// "save these checks on this column".
if !form.validation_set_name.trim().is_empty() { if !form.validation_set_name.trim().is_empty() {
let request = match form.apply_set_request(Locale::from_headers(&headers)) { let request = match form.apply_set_request(Locale::from_headers(&headers)) {
Ok(request) => request, Ok(request) => request,
Err(message) => { Err(message) => return reject(&headers, message),
return render_loaded(
&headers,
load_page(state, &headers, submitted, false, Some(message)).await,
)
}
}; };
let mut validations = state.validations; let mut validations = state.validations;
let request = match authenticated_request(&headers, request) { let request = match authenticated_request(&headers, request) {
Ok(request) => request, Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(), Err(_) => return Redirect::to("/login").into_response(),
}; };
return match validations.apply_validation_set(request).await { return answer(
Ok(response) if response.get_ref().success => Html(ui::render_success( &headers,
Locale::from_headers(&headers), validations
&response.get_ref().message, .apply_validation_set(request)
)) .await
.into_response(), .map(|response| {
Ok(response) => ( let response = response.into_inner();
StatusCode::UNPROCESSABLE_ENTITY, (response.success, response.message)
Html(ui::render_error( }),
Locale::from_headers(&headers), );
&response.get_ref().message,
)),
)
.into_response(),
Err(error) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_error(
Locale::from_headers(&headers),
error.message(),
)),
)
.into_response(),
};
} }
let request = match form.field_request(Locale::from_headers(&headers)) { let request = match form.field_request(Locale::from_headers(&headers)) {
Ok(request) => request, Ok(request) => request,
Err(message) => { Err(message) => return reject(&headers, message),
return render_loaded(
&headers,
load_page(state, &headers, submitted, false, Some(message)).await,
)
}
}; };
let mut validations = state.validations; let mut validations = state.validations;
let request = match authenticated_request(&headers, request) { let request = match authenticated_request(&headers, request) {
Ok(request) => request, Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(), Err(_) => return Redirect::to("/login").into_response(),
}; };
match validations.update_field_validation(request).await { answer(
Ok(response) if response.get_ref().success => Html(ui::render_success( &headers,
Locale::from_headers(&headers), validations
&response.get_ref().message, .update_field_validation(request)
)) .await
.into_response(), .map(|response| {
Ok(response) => ( let response = response.into_inner();
StatusCode::UNPROCESSABLE_ENTITY, (response.success, response.message)
Html(ui::render_error( }),
Locale::from_headers(&headers), )
&response.get_ref().message,
)),
)
.into_response(),
Err(error) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_error(
Locale::from_headers(&headers),
error.message(),
)),
)
.into_response(),
}
} }
pub(crate) async fn save_validation_rule( pub(crate) async fn save_validation_rule(
@@ -124,46 +94,28 @@ pub(crate) async fn save_validation_rule(
return rejection; return rejection;
} }
let submitted = form.clone(); let submitted = form.clone();
if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), true, None).await { if let Err(error) = load_page(state.clone(), &headers, submitted, true, None).await {
return render_loaded(&headers, Err(error)); return load_failed(&headers, error);
} }
let request = match form.rule_request(Locale::from_headers(&headers)) { let request = match form.rule_request(Locale::from_headers(&headers)) {
Ok(request) => request, Ok(request) => request,
Err(message) => { Err(message) => return reject(&headers, message),
return render_loaded(
&headers,
load_page(state, &headers, submitted, true, Some(message)).await,
)
}
}; };
let mut validations = state.validations; let mut validations = state.validations;
let request = match authenticated_request(&headers, request) { let request = match authenticated_request(&headers, request) {
Ok(request) => request, Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(), Err(_) => return Redirect::to("/login").into_response(),
}; };
match validations.upsert_validation_rule(request).await { answer(
Ok(response) if response.get_ref().success => Html(ui::render_success( &headers,
Locale::from_headers(&headers), validations
&response.get_ref().message, .upsert_validation_rule(request)
)) .await
.into_response(), .map(|response| {
Ok(response) => ( let response = response.into_inner();
StatusCode::UNPROCESSABLE_ENTITY, (response.success, response.message)
Html(ui::render_error( }),
Locale::from_headers(&headers), )
&response.get_ref().message,
)),
)
.into_response(),
Err(error) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_error(
Locale::from_headers(&headers),
error.message(),
)),
)
.into_response(),
}
} }
pub(crate) async fn save_validation_set( pub(crate) async fn save_validation_set(
@@ -173,48 +125,59 @@ pub(crate) async fn save_validation_set(
return rejection; return rejection;
} }
let submitted = form.clone(); let submitted = form.clone();
if let Err(error) = load_set_page(state.clone(), &headers, submitted.clone(), None).await { if let Err(error) = load_set_page(state.clone(), &headers, submitted, None).await {
return render_set_loaded(&headers, Err(error)); return load_failed(&headers, error);
} }
let request = match form.request(Locale::from_headers(&headers)) { let request = match form.request(Locale::from_headers(&headers)) {
Ok(request) => request, Ok(request) => request,
Err(message) => { Err(message) => return reject(&headers, message),
return render_set_loaded(
&headers,
load_set_page(state, &headers, submitted, Some(message)).await,
)
}
}; };
let mut validations = state.validations; let mut validations = state.validations;
let request = match authenticated_request(&headers, request) { let request = match authenticated_request(&headers, request) {
Ok(request) => request, Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(), Err(_) => return Redirect::to("/login").into_response(),
}; };
match validations.upsert_validation_set(request).await { answer(
Ok(response) if response.get_ref().success => Html(ui::render_success( &headers,
Locale::from_headers(&headers), validations
&response.get_ref().message, .upsert_validation_set(request)
)) .await
.into_response(), .map(|response| {
Ok(response) => ( let response = response.into_inner();
StatusCode::UNPROCESSABLE_ENTITY, (response.success, response.message)
Html(ui::render_error( }),
Locale::from_headers(&headers), )
&response.get_ref().message, }
)),
) /// What every one of these three POSTs does with the backend's answer. The
.into_response(), /// endpoints report a refusal in the body (`success: false`) as readily as
Err(error) => ( /// through a gRPC status, and both are the same thing to the user.
StatusCode::UNPROCESSABLE_ENTITY, fn answer(headers: &HeaderMap, outcome: Result<(bool, String), tonic::Status>) -> Response {
Html(ui::render_error( let locale = Locale::from_headers(headers);
Locale::from_headers(&headers), match outcome {
error.message(), Ok((true, message)) => Html(ui::render_success(locale, &message)).into_response(),
)), Ok((false, message)) => {
) FormError::Rejected(message).into_response(locale, ui::render_error)
.into_response(), }
Err(status) => FormError::from_status(&status).into_response(locale, ui::render_error),
} }
} }
/// A submission this crate rejected before it reached the backend — a pattern
/// line in the wrong shape, a missing column. It answers exactly like a
/// backend rejection: the form's `hx-target` is the status block either way,
/// and the user has to change something either way.
fn reject(headers: &HeaderMap, message: String) -> Response {
FormError::Rejected(message).into_response(Locale::from_headers(headers), ui::render_error)
}
fn load_failed(headers: &HeaderMap, error: LoadError) -> Response {
let locale = Locale::from_headers(headers);
error
.into_form_error(locale, PERMISSION_KEY)
.into_response(locale, ui::render_error)
}
fn render_set_loaded( fn render_set_loaded(
headers: &HeaderMap, headers: &HeaderMap,
result: Result<(crate::ui::Nav, ValidationSetForm, Option<String>), LoadError>, result: Result<(crate::ui::Nav, ValidationSetForm, Option<String>), LoadError>,
@@ -223,23 +186,7 @@ fn render_set_loaded(
Ok((nav, form, error)) => { Ok((nav, form, error)) => {
Html(ui::render_set_page(nav, &form, error.as_deref())).into_response() Html(ui::render_set_page(nav, &form, error.as_deref())).into_response()
} }
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), Err(error) => load_failed(headers, error),
Err(LoadError::Forbidden) => (
StatusCode::FORBIDDEN,
Html(ui::render_error(
Locale::from_headers(headers),
&tr!(
Locale::from_headers(headers),
"validation-err-permission"
),
)),
)
.into_response(),
Err(LoadError::Backend(message)) => (
StatusCode::BAD_GATEWAY,
Html(ui::render_error(Locale::from_headers(headers), &message)),
)
.into_response(),
} }
} }
@@ -249,22 +196,6 @@ fn render_loaded(
) -> Response { ) -> Response {
match result { match result {
Ok(page) => Html(ui::render_page(&page)).into_response(), Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), Err(error) => load_failed(headers, error),
Err(LoadError::Forbidden) => (
StatusCode::FORBIDDEN,
Html(ui::render_error(
Locale::from_headers(headers),
&tr!(
Locale::from_headers(headers),
"validation-err-permission"
),
)),
)
.into_response(),
Err(LoadError::Backend(message)) => (
StatusCode::BAD_GATEWAY,
Html(ui::render_error(Locale::from_headers(headers), &message)),
)
.into_response(),
} }
} }

View File

@@ -113,3 +113,19 @@ pub(crate) enum LoadError {
Forbidden, Forbidden,
Backend(String), Backend(String),
} }
impl LoadError {
/// Hands the page load's failure to the one place that decides statuses.
/// `permission_key` names the message shown when the load was refused.
pub(crate) fn into_form_error(
self,
locale: crate::i18n::Locale,
permission_key: &str,
) -> crate::ui::FormError {
match self {
Self::Unauthenticated => crate::ui::FormError::Unauthenticated,
Self::Forbidden => crate::ui::FormError::Forbidden(locale.lookup(permission_key)),
Self::Backend(message) => crate::ui::FormError::Unavailable(message),
}
}
}

View File

@@ -1,7 +1,7 @@
use axum::{ use axum::{
Form, Form,
extract::State, extract::State,
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, header},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
@@ -53,39 +53,22 @@ pub(crate) async fn export_csv(
}; };
let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) { let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) {
Ok(targets) => targets, Ok(targets) => targets,
Err(message) => { Err(message) => return reject(&headers, message),
return (
StatusCode::BAD_REQUEST,
Html(ui::render_error(
Locale::from_headers(&headers),
&message,
)),
)
.into_response()
}
}; };
let Some(profile) = catalog.profiles.iter().find(|profile| profile.name == profile_name) else { let Some(profile) = catalog.profiles.iter().find(|profile| profile.name == profile_name) else {
return ( return reject(
StatusCode::BAD_REQUEST, &headers,
Html(ui::render_error( tr!(Locale::from_headers(&headers), "export-err-unknown-profile"),
Locale::from_headers(&headers), );
&tr!(Locale::from_headers(&headers), "export-err-unknown-profile"),
)),
)
.into_response();
}; };
if table_names.iter().any(|name| !profile.tables.contains(name)) { if table_names.iter().any(|name| !profile.tables.contains(name)) {
return ( return reject(
StatusCode::BAD_REQUEST, &headers,
Html(ui::render_error( tr!(
Locale::from_headers(&headers), Locale::from_headers(&headers),
&tr!( "export-err-tables-not-in-profile"
Locale::from_headers(&headers), ),
"export-err-tables-not-in-profile" );
),
)),
)
.into_response();
} }
let mut tables = Vec::new(); let mut tables = Vec::new();
@@ -103,7 +86,7 @@ pub(crate) async fn export_csv(
.await .await
{ {
Ok(response) => response.into_inner().table_structures.remove(table_name), Ok(response) => response.into_inner().table_structures.remove(table_name),
Err(error) => return backend_error(&headers, error.message()), Err(error) => return grpc_error(&headers, &error),
}; };
let Some(structure) = structure else { let Some(structure) = structure else {
return backend_error( return backend_error(
@@ -127,7 +110,7 @@ pub(crate) async fn export_csv(
.await .await
{ {
Ok(response) => response.into_inner().count, Ok(response) => response.into_inner().count,
Err(error) => return backend_error(&headers, error.message()), Err(error) => return grpc_error(&headers, &error),
}; };
let Ok(count) = u64::try_from(count) else { let Ok(count) = u64::try_from(count) else {
return backend_error( return backend_error(
@@ -170,7 +153,7 @@ pub(crate) async fn export_csv(
Err(_) => return Redirect::to("/login").into_response(), Err(_) => return Redirect::to("/login").into_response(),
}).await { }).await {
Ok(response) => response.into_inner(), Ok(response) => response.into_inner(),
Err(error) => return backend_error(&headers, error.message()), Err(error) => return grpc_error(&headers, &error),
}; };
row.extend(table.columns.iter().map(|column| response.data.get(column).cloned().unwrap_or_default())); row.extend(table.columns.iter().map(|column| response.data.get(column).cloned().unwrap_or_default()));
} }
@@ -187,27 +170,29 @@ pub(crate) async fn export_csv(
} }
fn load_error(headers: &HeaderMap, error: LoadError) -> Response { fn load_error(headers: &HeaderMap, error: LoadError) -> Response {
match error { let locale = Locale::from_headers(headers);
LoadError::Unauthenticated => Redirect::to("/login").into_response(), error
LoadError::Forbidden => ( .into_form_error(locale, "export-err-permission")
StatusCode::FORBIDDEN, .into_response(locale, ui::render_error)
Html(ui::render_error(
Locale::from_headers(headers),
&tr!(
Locale::from_headers(headers),
"export-err-permission"
),
)),
)
.into_response(),
LoadError::Backend(message) => backend_error(headers, &message),
}
} }
fn backend_error(headers: &HeaderMap, message: &str) -> Response { /// An export the user has to change: a profile that is not theirs to read, a
( /// table that is not in it.
StatusCode::BAD_GATEWAY, fn reject(headers: &HeaderMap, message: String) -> Response {
Html(ui::render_error(Locale::from_headers(headers), message)), crate::ui::FormError::Rejected(message)
) .into_response(Locale::from_headers(headers), ui::render_error)
.into_response() }
/// A gRPC failure, classified by its code rather than assumed to be a dead
/// backend.
fn grpc_error(headers: &HeaderMap, error: &tonic::Status) -> Response {
crate::ui::FormError::from_status(error)
.into_response(Locale::from_headers(headers), ui::render_error)
}
/// The backend answered, with something the export cannot use — a structure
/// it does not know, a row count that is negative or past `i32`.
fn backend_error(headers: &HeaderMap, message: &str) -> Response {
crate::ui::FormError::Unavailable(message.to_string())
.into_response(Locale::from_headers(headers), ui::render_error)
} }

View File

@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use axum::{ use axum::{
Form, Form,
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::HeaderMap,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
@@ -53,61 +53,31 @@ pub(crate) async fn import_csv(
}; };
let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) { let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) {
Ok(targets) => targets, Ok(targets) => targets,
Err(message) => { Err(message) => return reject(&headers, message),
return render_loaded(
&headers,
load_page(state, &headers, submitted, Some(message)).await,
)
}
}; };
let Some(profile) = page.catalog.profiles.iter().find(|profile| profile.name == profile_name) else { let Some(profile) = page.catalog.profiles.iter().find(|profile| profile.name == profile_name) else {
return render_loaded( return reject(
&headers, &headers,
load_page( tr!(Locale::from_headers(&headers), "import-err-unknown-profile"),
state,
&headers,
submitted,
Some(tr!(
Locale::from_headers(&headers),
"import-err-unknown-profile"
)),
)
.await,
); );
}; };
if table_names.iter().any(|name| !profile.tables.contains(name)) { if table_names.iter().any(|name| !profile.tables.contains(name)) {
return render_loaded( return reject(
&headers, &headers,
load_page( tr!(
state, Locale::from_headers(&headers),
&headers, "import-err-tables-not-in-profile"
submitted, ),
Some(tr!(
Locale::from_headers(&headers),
"import-err-tables-not-in-profile"
)),
)
.await,
); );
} }
let rows = match parse_csv(Locale::from_headers(&headers), &form.csv_data) { let rows = match parse_csv(Locale::from_headers(&headers), &form.csv_data) {
Ok(rows) => rows, Ok(rows) => rows,
Err(message) => { Err(message) => return reject(&headers, message),
return render_loaded(
&headers,
load_page(state, &headers, submitted, Some(message)).await,
)
}
}; };
let (table_headers, columns, data_rows) = let (table_headers, columns, data_rows) =
match split_headers(Locale::from_headers(&headers), rows, &table_names) { match split_headers(Locale::from_headers(&headers), rows, &table_names) {
Ok(parts) => parts, Ok(parts) => parts,
Err(message) => { Err(message) => return reject(&headers, message),
return render_loaded(
&headers,
load_page(state, &headers, submitted, Some(message)).await,
)
}
}; };
let mut tables = Vec::new(); let mut tables = Vec::new();
@@ -122,10 +92,10 @@ pub(crate) async fn import_csv(
Err(_) => return Redirect::to("/login").into_response(), Err(_) => return Redirect::to("/login").into_response(),
}).await { }).await {
Ok(response) => response.into_inner().table_structures.remove(table_name), Ok(response) => response.into_inner().table_structures.remove(table_name),
Err(error) => return backend_error(&headers, error.message().to_string()), Err(error) => return grpc_error(&headers, &error),
}; };
let Some(structure) = structure else { let Some(structure) = structure else {
return backend_error( return unavailable(
&headers, &headers,
tr!( tr!(
Locale::from_headers(&headers), Locale::from_headers(&headers),
@@ -153,38 +123,26 @@ pub(crate) async fn import_csv(
.map(|(index, column)| (index, column.clone())) .map(|(index, column)| (index, column.clone()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if positions.is_empty() { if positions.is_empty() {
return render_loaded( return reject(
&headers, &headers,
load_page( tr!(
state, Locale::from_headers(&headers),
&headers, "import-err-no-importable-columns",
submitted, "table" => table.name.clone(),
Some(tr!( ),
Locale::from_headers(&headers),
"import-err-no-importable-columns",
"table" => table.name.clone(),
)),
)
.await,
); );
} }
for (index, column) in columns.iter().enumerate() { for (index, column) in columns.iter().enumerate() {
let belongs = table_headers.as_ref().map_or(table_names.len() == 1, |headers| headers.get(index).is_some_and(|name| name == &table.name)); let belongs = table_headers.as_ref().map_or(table_names.len() == 1, |headers| headers.get(index).is_some_and(|name| name == &table.name));
if belongs && !table.columns.contains(column) { if belongs && !table.columns.contains(column) {
return render_loaded( return reject(
&headers, &headers,
load_page( tr!(
state, Locale::from_headers(&headers),
&headers, "import-err-column-not-importable",
submitted, "column" => column.clone(),
Some(tr!( "table" => table.name.clone(),
Locale::from_headers(&headers), ),
"import-err-column-not-importable",
"column" => column.clone(),
"table" => table.name.clone(),
)),
)
.await,
); );
} }
} }
@@ -206,12 +164,7 @@ pub(crate) async fn import_csv(
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
{ {
Ok(rows) => rows, Ok(rows) => rows,
Err(message) => { Err(message) => return reject(&headers, message),
return render_loaded(
&headers,
load_page(state, &headers, submitted, Some(message)).await,
)
}
}; };
for chunk in converted.chunks(1_000) { for chunk in converted.chunks(1_000) {
let request = PostTableDataBulkRequest { let request = PostTableDataBulkRequest {
@@ -225,7 +178,7 @@ pub(crate) async fn import_csv(
Err(_) => return Redirect::to("/login").into_response(), Err(_) => return Redirect::to("/login").into_response(),
}).await { }).await {
Ok(response) => response.into_inner(), Ok(response) => response.into_inner(),
Err(error) => return backend_error(&headers, error.message().to_string()), Err(error) => return grpc_error(&headers, &error),
}; };
inserted += response.responses.iter().filter(|row| row.inserted_id > 0).count(); inserted += response.responses.iter().filter(|row| row.inserted_id > 0).count();
} }
@@ -309,30 +262,38 @@ fn render_loaded(headers: &HeaderMap, result: Result<super::state::ImportPageSta
} }
} }
fn load_error(headers: &HeaderMap, error: LoadError) -> Response { /// A CSV this crate rejected before any of it reached the backend: an unknown
match error { /// profile, a column the table has no place for, an unparseable cell. The
LoadError::Unauthenticated => Redirect::to("/login").into_response(), /// form's `hx-target` is `#submission-status`, so the answer is the alert
LoadError::Forbidden => ( /// fragment — re-rendering the page here sent a whole `<html>` document to be
StatusCode::FORBIDDEN, /// swapped into a status block.
Html(ui::render_error( fn reject(headers: &HeaderMap, message: String) -> Response {
Locale::from_headers(headers), crate::ui::FormError::Rejected(message)
&tr!( .into_response(Locale::from_headers(headers), ui::render_error)
Locale::from_headers(headers),
"import-err-permission"
),
)),
)
.into_response(),
LoadError::Backend(message) => backend_error(headers, message),
}
} }
fn backend_error(headers: &HeaderMap, message: String) -> Response { /// A gRPC failure, classified by its code. An import that the server refused
( /// because of what was *in* the CSV — a script-consistency mismatch, a
StatusCode::BAD_GATEWAY, /// validation pattern the value does not satisfy — is the user's to fix and
Html(ui::render_error(Locale::from_headers(headers), &message)), /// answers 422; only an actually unreachable backend answers 502.
) fn grpc_error(headers: &HeaderMap, error: &tonic::Status) -> Response {
.into_response() crate::ui::FormError::from_status(error)
.into_response(Locale::from_headers(headers), ui::render_error)
}
fn load_error(headers: &HeaderMap, error: LoadError) -> Response {
let locale = Locale::from_headers(headers);
error
.into_form_error(locale, "import-err-permission")
.into_response(locale, ui::render_error)
}
/// The backend answered, but not with what the import needs — a table the
/// catalogue lists and the structure service does not know. Nothing the user
/// can fix from the form.
fn unavailable(headers: &HeaderMap, message: String) -> Response {
crate::ui::FormError::Unavailable(message)
.into_response(Locale::from_headers(headers), ui::render_error)
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -84,9 +84,11 @@ pub(crate) async fn login(
Locale::from_headers(&headers), Locale::from_headers(&headers),
"login-error-identifier-required" "login-error-identifier-required"
); );
// 422, not 400: the form parsed fine, its contents were refused —
// the same class as every other rejected submission in the app.
return error( return error(
Locale::from_headers(&headers), Locale::from_headers(&headers),
StatusCode::BAD_REQUEST, StatusCode::UNPROCESSABLE_ENTITY,
&message, &message,
); );
} }

View File

@@ -1,7 +1,7 @@
use axum::{ use axum::{
Form, Form,
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::HeaderMap,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use tonic::Request; use tonic::Request;
@@ -44,14 +44,15 @@ pub(crate) async fn register(
{ {
Ok(response) => response.into_inner(), Ok(response) => response.into_inner(),
Err(status) => { Err(status) => {
return ( // /register is public, so `Unauthenticated` here is the backend
StatusCode::BAD_REQUEST, // refusing this submission, not a lost session to redirect on.
Html(ui::render_error( let failure = match crate::ui::FormError::from_status(&status) {
Locale::from_headers(&headers), crate::ui::FormError::Unauthenticated => {
status.message(), crate::ui::FormError::Rejected(status.message().to_string())
)), }
) other => other,
.into_response(); };
return failure.into_response(Locale::from_headers(&headers), ui::render_error);
} }
}; };

View File

@@ -5,10 +5,96 @@
//! `templates/<the page's path under src>/`. //! `templates/<the page's path under src>/`.
use askama::Template; use askama::Template;
use axum::http::HeaderMap; use axum::{
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
};
pub(crate) const SESSION_COOKIE: &str = "analytics_token"; pub(crate) const SESSION_COOKIE: &str = "analytics_token";
/// Why a form submission failed.
///
/// The status a failure answers with is decided here, once, by *what went
/// wrong* — never by which handler happened to notice. Handlers used to pick
/// their own, and the same class of failure ended up as 200 on one form, 422
/// on another and 502 on a third; the browser hid it, because
/// `ui/base.html` swaps every response body regardless of status.
pub(crate) enum FormError {
/// The submission was understood and rejected: the user has to change
/// something before it can succeed. Whether this crate spotted it while
/// building the request or the backend spotted it afterwards is an
/// implementation detail of *when*, not a difference in kind.
Rejected(String),
/// The session is gone. Nothing is rendered; the browser goes to /login.
Unauthenticated,
/// Signed in, but not allowed to do this.
Forbidden(String),
/// Nothing wrong with the submission — the backend could not answer.
Unavailable(String),
}
impl FormError {
/// Classifies a gRPC failure by its code. The blanket `Err(_) =>` arms
/// this replaces reported an unreachable backend as a rejected submission
/// on some pages and a rejected submission as an unreachable backend on
/// others.
pub(crate) fn from_status(status: &tonic::Status) -> Self {
let message = status.message().to_string();
match status.code() {
tonic::Code::Unauthenticated => Self::Unauthenticated,
tonic::Code::PermissionDenied => Self::Forbidden(message),
// Everything the caller can fix by editing the form: a value the
// server rejected, a name already taken, a row that does not
// satisfy the table's own rules.
tonic::Code::InvalidArgument
| tonic::Code::FailedPrecondition
| tonic::Code::OutOfRange
| tonic::Code::AlreadyExists
| tonic::Code::NotFound => Self::Rejected(message),
_ => Self::Unavailable(message),
}
}
pub(crate) fn status_code(&self) -> StatusCode {
match self {
// Understood, and refused. Not 400: the request parsed fine.
Self::Rejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
Self::Unauthenticated => StatusCode::UNAUTHORIZED,
Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::Unavailable(_) => StatusCode::BAD_GATEWAY,
}
}
pub(crate) fn message(&self) -> &str {
match self {
Self::Rejected(message) | Self::Forbidden(message) | Self::Unavailable(message) => {
message
}
Self::Unauthenticated => "",
}
}
/// The response, rendered through the page's own alert renderer so each
/// form keeps its own error title.
///
/// The body is always the alert *fragment*, because that is what a form's
/// `hx-target` is: a status block. Re-rendering the whole page here — as
/// the locally-detected errors used to — sends a complete `<html>`
/// document to be swapped into a `<div>` inside the page that is already
/// open.
pub(crate) fn into_response(
self,
locale: crate::i18n::Locale,
render_alert: fn(crate::i18n::Locale, &str) -> String,
) -> Response {
if matches!(self, Self::Unauthenticated) {
return Redirect::to("/login").into_response();
}
let status = self.status_code();
(status, Html(render_alert(locale, self.message()))).into_response()
}
}
/// Navbar state. Every full-page template struct carries one of these, because /// Navbar state. Every full-page template struct carries one of these, because
/// `ui/base.html` renders `ui/navbar.html` unconditionally. /// `ui/base.html` renders `ui/navbar.html` unconditionally.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -268,6 +354,52 @@ pub(crate) fn render<T: Template>(template: &T) -> String {
mod tests { mod tests {
use super::*; use super::*;
/// The status is a property of what went wrong, and a gRPC failure knows
/// which it was. The blanket arms this replaced answered 422 for an
/// unreachable backend on one page and 502 for a rejected row on another.
#[test]
fn a_backend_failure_is_classified_by_its_code() {
let rejected = FormError::from_status(&tonic::Status::invalid_argument(
"Script calculated '3', but user provided '99'",
));
assert_eq!(rejected.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
let unreachable =
FormError::from_status(&tonic::Status::unavailable("connection refused"));
assert_eq!(unreachable.status_code(), StatusCode::BAD_GATEWAY);
let forbidden =
FormError::from_status(&tonic::Status::permission_denied("not your profile"));
assert_eq!(forbidden.status_code(), StatusCode::FORBIDDEN);
assert!(matches!(
FormError::from_status(&tonic::Status::unauthenticated("expired")),
FormError::Unauthenticated
));
}
/// A submission refused here and a submission refused by the backend are
/// the same event to the user, so they are the same response: 422, and the
/// alert *fragment* the form's `hx-target` expects — never a whole page.
#[test]
fn a_rejection_answers_with_a_fragment_and_not_a_document() {
fn alert(locale: crate::i18n::Locale, message: &str) -> String {
render(&Alert::error(locale, "Could not save", message))
}
let response = FormError::Rejected("Pattern line 1 is malformed.".to_string())
.into_response(crate::i18n::Locale::default(), alert);
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
// What that response carries. A form targets a status block, so the
// body has to be a fragment: the locally-detected errors used to
// answer with a re-rendered page, i.e. a whole document to be swapped
// into a `<div>` inside the document already open.
let body = alert(crate::i18n::Locale::default(), "Pattern line 1 is malformed.");
assert!(!body.contains("<html"), "{body}");
assert!(!body.contains("<!doctype"), "{body}");
}
/// The messages rendered `|safe` carry their own markup, so Askama does no /// The messages rendered `|safe` carry their own markup, so Askama does no
/// escaping for them at all — which would make an interpolated value a way /// escaping for them at all — which would make an interpolated value a way
/// to write HTML into the page. The value is escaped here instead. /// to write HTML into the page. The value is escaped here instead.