mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4c3593ee | ||
|
|
a2efe1eeb3 | ||
|
|
2d7a24315f | ||
|
|
6f9ea92106 |
Generated
+1
@@ -11542,6 +11542,7 @@ dependencies = [
|
||||
"elasticsearch",
|
||||
"elasticsearch_schema",
|
||||
"enums",
|
||||
"erased-serde",
|
||||
"error_utils",
|
||||
"errors",
|
||||
"fal_client",
|
||||
|
||||
@@ -334,6 +334,7 @@ regex = "1.12.3"
|
||||
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls", "gzip" ] }
|
||||
reqwest-websocket = { version = "0.5.0" }
|
||||
rust-s3 = { version = "0.36.0-beta.2", default-features = false, features = ["fail-on-err", "tags", "tokio-rustls-tls"] }
|
||||
erased-serde = "0.4"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_derive = "1.0.228"
|
||||
serde_json = "1.0.149"
|
||||
|
||||
@@ -175,6 +175,7 @@ magic-crypt = "3.1.13" # encrypting sort IDs for the frontend so we don't leak i
|
||||
md-5 = "0.10.6" # gravatar
|
||||
|
||||
# Serialization
|
||||
erased-serde.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
+73
@@ -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,18 @@ pub enum CommonWebError {
|
||||
/// 400 Bad Request with a user-facing message.
|
||||
BadInputWithSimpleMessage(String),
|
||||
|
||||
/// 400 Bad Request with an endpoint-specific JSON body.
|
||||
/// The caller hands over any `Serialize` struct; the middleware writes it
|
||||
/// directly as the response body with `Content-Type: application/json`.
|
||||
/// Construct via [`CommonWebError::bad_input_tailored_response`].
|
||||
///
|
||||
/// Stored as `Arc<dyn erased_serde::Serialize + Send + Sync + 'static>`:
|
||||
/// `serde::Serialize` isn't object-safe (its method is generic over
|
||||
/// `Serializer`), so we use `erased_serde::Serialize` — a sibling trait
|
||||
/// with a non-generic method, auto-implemented for every `T: Serialize`.
|
||||
/// `Arc` (instead of `Box`) keeps the enum `Clone`.
|
||||
BadInputTailoredResponse(Arc<dyn erased_serde::Serialize + Send + Sync + 'static>),
|
||||
|
||||
/// 401 Unauthorized.
|
||||
NotAuthorized,
|
||||
|
||||
@@ -96,6 +109,24 @@ 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`]. The caller passes its own
|
||||
/// `Serialize` type directly — no envelope, no `serde_json::Value` boxing
|
||||
/// at the callsite.
|
||||
/// Build a 400 Bad Request whose body is the JSON-serialized form of `body`.
|
||||
///
|
||||
/// The caller passes its typed struct directly. Serialization is deferred to
|
||||
/// response time, when the middleware writes the JSON straight onto the wire.
|
||||
pub fn bad_input_tailored_response<T>(body: T) -> Self
|
||||
where
|
||||
T: Serialize + Send + Sync + 'static,
|
||||
{
|
||||
Self::BadInputTailoredResponse(Arc::new(body))
|
||||
}
|
||||
|
||||
/// 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 +162,12 @@ 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(payload) => {
|
||||
match serde_json::to_string(payload) {
|
||||
Ok(s) => write!(f, "Bad input (tailored): {}", s),
|
||||
Err(_) => write!(f, "Bad input (tailored): <unserializable payload>"),
|
||||
}
|
||||
}
|
||||
Self::NotAuthorized => write!(f, "Not authorized"),
|
||||
Self::NotFound => write!(f, "Not found"),
|
||||
Self::PaymentRequired => write!(f, "Payment required"),
|
||||
@@ -149,6 +186,12 @@ 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(payload) => {
|
||||
match serde_json::to_string(payload) {
|
||||
Ok(s) => write!(f, "BadInputTailoredResponse({})", s),
|
||||
Err(_) => write!(f, "BadInputTailoredResponse(<unserializable>)"),
|
||||
}
|
||||
}
|
||||
Self::NotAuthorized => write!(f, "NotAuthorized"),
|
||||
Self::NotFound => write!(f, "NotFound"),
|
||||
Self::PaymentRequired => write!(f, "PaymentRequired"),
|
||||
@@ -179,6 +222,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 +247,9 @@ impl ResponseError for CommonWebError {
|
||||
message: msg,
|
||||
})
|
||||
}
|
||||
Self::BadInputTailoredResponse(payload) => {
|
||||
HttpResponse::BadRequest().json(payload)
|
||||
}
|
||||
Self::ContentPolicyRejected => {
|
||||
HttpResponseBuilder::new(status)
|
||||
.json(JsonErrorWithMessage {
|
||||
@@ -387,6 +434,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;
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
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.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Dev",
|
||||
path = "/v1/dev/basic_input_error",
|
||||
responses(
|
||||
(status = 400, description = "Always returns this — that's the point"),
|
||||
),
|
||||
)]
|
||||
pub async fn dev_basic_input_error_handler() -> Result<HttpResponse, CommonWebError> {
|
||||
Err(CommonWebError::BadInputWithSimpleMessage("testing".to_string()))
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
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 `BadInputTailoredResponse`
|
||||
/// carrying a per-field validation payload, so we can exercise the tailored
|
||||
/// 400 response shape end-to-end.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Dev",
|
||||
path = "/v1/dev/input_error",
|
||||
responses(
|
||||
(status = 400, description = "Always returns this — that's the point"),
|
||||
),
|
||||
)]
|
||||
pub async fn dev_input_error_handler() -> Result<HttpResponse, CommonWebError> {
|
||||
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<BadFieldMessage>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BadFieldMessage {
|
||||
field_name: String,
|
||||
message: String,
|
||||
max_length: Option<usize>,
|
||||
min_length: Option<usize>,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod dev_basic_input_error_handler;
|
||||
pub mod dev_input_error_handler;
|
||||
@@ -5,6 +5,7 @@ pub mod billing_fakeyou;
|
||||
pub mod characters;
|
||||
pub mod comments;
|
||||
pub mod credits;
|
||||
pub mod dev;
|
||||
pub mod download_job;
|
||||
pub mod featured_items;
|
||||
pub mod generate;
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ use crate::http_server::routes::application_routes::billing_fakeyou_routes::add_
|
||||
use crate::http_server::routes::application_routes::character_routes::add_character_routes;
|
||||
use crate::http_server::routes::application_routes::comments_routes::add_comments_routes;
|
||||
use crate::http_server::routes::application_routes::credits_routes::add_credits_routes;
|
||||
use crate::http_server::routes::application_routes::dev_routes::add_dev_routes;
|
||||
use crate::http_server::routes::application_routes::featured_item_routes::add_featured_item_routes;
|
||||
use crate::http_server::routes::application_routes::generate_routes::add_generate_routes;
|
||||
use crate::http_server::routes::application_routes::job_routes::add_job_routes;
|
||||
@@ -63,6 +64,7 @@ where
|
||||
// Other useful tools
|
||||
app = add_analytics_routes(app); // /v1/analytics/...
|
||||
app = add_web_referrals_routes(app); // /v1/web_referrals/...
|
||||
app = add_dev_routes(app); // /v1/dev/... — dev-only error/response shape probes
|
||||
|
||||
// User and user-adjacent routes
|
||||
app = add_comments_routes(app); // /v1/comments/...
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
use actix_http::body::MessageBody;
|
||||
use actix_service::ServiceFactory;
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::error::Error;
|
||||
use actix_web::{web, App, HttpResponse};
|
||||
|
||||
use crate::http_server::endpoints::dev::dev_basic_input_error_handler::dev_basic_input_error_handler;
|
||||
use crate::http_server::endpoints::dev::dev_input_error_handler::dev_input_error_handler;
|
||||
|
||||
pub fn add_dev_routes<T, B> (app: App<T>) -> App<T>
|
||||
where
|
||||
B: MessageBody,
|
||||
T: ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<B>,
|
||||
Error = Error,
|
||||
InitError = (),
|
||||
>,
|
||||
{
|
||||
app.service(web::scope("/v1/dev")
|
||||
.service(web::resource("/input_error")
|
||||
.route(web::get().to(dev_input_error_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/basic_input_error")
|
||||
.route(web::get().to(dev_basic_input_error_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod billing_fakeyou_routes;
|
||||
mod character_routes;
|
||||
mod comments_routes;
|
||||
mod credits_routes;
|
||||
mod dev_routes;
|
||||
mod featured_item_routes;
|
||||
mod generate_routes;
|
||||
mod job_routes;
|
||||
|
||||
Reference in New Issue
Block a user