From 2d7a24315fb31244b299206af751790ea4cecef3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 25 May 2026 16:24:10 -0400 Subject: [PATCH] wip --- .../common_responses/common_web_error.rs | 56 +++++++++++++++++++ .../endpoints/dev/dev_input_error_handler.rs | 44 ++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs b/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs index 704830f9f0..c66b384206 100644 --- a/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs +++ b/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs @@ -9,6 +9,7 @@ use actix_http::StatusCode; use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError}; use anyhow::anyhow; use mysql_queries::errors::mysql_error::{MysqlCrateErrorSubtype, MysqlError}; +use serde::Serialize; /// An error type for actix-web handlers that wraps causal errors for debugging /// and paging while presenting safe, generic HTTP responses to users. @@ -37,6 +38,11 @@ pub enum CommonWebError { /// 400 Bad Request with a user-facing message. BadInputWithSimpleMessage(String), + /// 400 Bad Request with an endpoint-specific JSON body. + /// The body is whatever the caller serialized (e.g. a per-field validation + /// failure list). Construct via [`CommonWebError::bad_input_tailored_response`]. + BadInputTailoredResponse(serde_json::Value), + /// 401 Unauthorized. NotAuthorized, @@ -96,6 +102,22 @@ impl CommonWebError { Self::from_anyhow_error(anyhow!("ServerErrorWithMessage: {:?}", msg)) } + /// Build a 400 Bad Request whose body is the JSON-serialized form of `body`. + /// + /// Use this when an endpoint needs to return a structured validation payload + /// (per-field errors, etc.) rather than the generic `{success, error_code, message}` + /// envelope used by [`BadInputWithSimpleMessage`]. + /// + /// If serialization fails (rare — usually only when a `Map` has non-string keys), + /// falls back to an [`UncaughtServerError`] so the failure is paged and the + /// caller never sees a corrupt response. + pub fn bad_input_tailored_response(body: &T) -> Self { + match serde_json::to_value(body) { + Ok(value) => Self::BadInputTailoredResponse(value), + Err(err) => Self::from_error(err), + } + } + /// Extract the wrapped causal error (only present for `UncaughtServerError`). pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> { match self { @@ -131,6 +153,7 @@ impl Display for CommonWebError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::BadInputWithSimpleMessage(msg) => write!(f, "Bad input: {}", msg), + Self::BadInputTailoredResponse(value) => write!(f, "Bad input (tailored): {}", value), Self::NotAuthorized => write!(f, "Not authorized"), Self::NotFound => write!(f, "Not found"), Self::PaymentRequired => write!(f, "Payment required"), @@ -149,6 +172,7 @@ impl std::fmt::Debug for CommonWebError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::BadInputWithSimpleMessage(msg) => write!(f, "BadInputWithSimpleMessage({:?})", msg), + Self::BadInputTailoredResponse(value) => write!(f, "BadInputTailoredResponse({})", value), Self::NotAuthorized => write!(f, "NotAuthorized"), Self::NotFound => write!(f, "NotFound"), Self::PaymentRequired => write!(f, "PaymentRequired"), @@ -179,6 +203,7 @@ impl ResponseError for CommonWebError { fn status_code(&self) -> StatusCode { match self { Self::BadInputWithSimpleMessage(_) => StatusCode::BAD_REQUEST, + Self::BadInputTailoredResponse(_) => StatusCode::BAD_REQUEST, Self::NotAuthorized => StatusCode::UNAUTHORIZED, Self::NotFound => StatusCode::NOT_FOUND, Self::PaymentRequired => StatusCode::PAYMENT_REQUIRED, @@ -203,6 +228,11 @@ impl ResponseError for CommonWebError { message: msg, }) } + Self::BadInputTailoredResponse(value) => { + // The body is whatever the caller serialized — no envelope. + // `.json(...)` sets Content-Type: application/json automatically. + HttpResponse::BadRequest().json(value) + } Self::ContentPolicyRejected => { HttpResponseBuilder::new(status) .json(JsonErrorWithMessage { @@ -387,6 +417,32 @@ mod tests { assert!(body.contains("\"error_code\":400")); } + #[test] + fn bad_input_tailored_response_serializes_payload_verbatim() { + #[derive(Serialize)] + struct Sample { + reason: &'static str, + count: u32, + } + + let error = CommonWebError::bad_input_tailored_response(&Sample { + reason: "no good", + count: 3, + }); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(!error.is_server_error()); + assert!(error.cause().is_none()); + + let response = error.error_response(); + let content_type = response.headers().get("content-type").unwrap().to_str().unwrap(); + assert!(content_type.starts_with("application/json")); + + let bytes = response.into_body().try_into_bytes().unwrap(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + // Body is the raw serialized payload — no envelope. + assert_eq!(body, r#"{"reason":"no good","count":3}"#); + } + #[test] fn not_found_returns_404() { let error = CommonWebError::NotFound; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/dev/dev_input_error_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/dev/dev_input_error_handler.rs index 6acf2a019d..ccdfd89ff0 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/dev/dev_input_error_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/dev/dev_input_error_handler.rs @@ -1,9 +1,11 @@ use actix_web::HttpResponse; +use serde_derive::Serialize; use crate::http_server::common_responses::common_web_error::CommonWebError; -/// Dev-only endpoint that unconditionally returns a `BadInputWithSimpleMessage` -/// so we can exercise error paths (paging, response shape, etc.) end-to-end. +/// Dev-only endpoint that unconditionally returns a `BadInputTailoredResponse` +/// carrying a per-field validation payload, so we can exercise the tailored +/// 400 response shape end-to-end. #[utoipa::path( get, tag = "Dev", @@ -13,5 +15,41 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; ), )] pub async fn dev_input_error_handler() -> Result { - Err(CommonWebError::BadInputWithSimpleMessage("testing".to_string())) + let body = BadInputError { + fields: vec![ + BadFieldMessage { + field_name: "username".to_string(), + message: "username is taken".to_string(), + max_length: Some(32), + min_length: Some(3), + }, + BadFieldMessage { + field_name: "email".to_string(), + message: "email is invalid".to_string(), + max_length: None, + min_length: None, + }, + BadFieldMessage { + field_name: "password".to_string(), + message: "password is too short".to_string(), + max_length: Some(128), + min_length: Some(8), + }, + ], + }; + + Err(CommonWebError::bad_input_tailored_response(&body)) +} + +#[derive(Serialize)] +struct BadInputError { + fields: Vec, +} + +#[derive(Serialize)] +struct BadFieldMessage { + field_name: String, + message: String, + max_length: Option, + min_length: Option, }