1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use thiserror::Error;
9
10pub trait HttpError: std::error::Error + Send + Sync + 'static {
36 fn status_code(&self) -> u16 {
38 500
39 }
40
41 fn error_message(&self) -> String {
43 self.to_string()
44 }
45}
46
47#[derive(Debug, Clone)]
65pub struct AppError {
66 message: String,
67 status_code: u16,
68}
69
70impl AppError {
71 pub fn new(message: impl Into<String>) -> Self {
73 Self {
74 message: message.into(),
75 status_code: 500,
76 }
77 }
78
79 pub fn status(mut self, code: u16) -> Self {
81 self.status_code = code;
82 self
83 }
84
85 pub fn not_found(message: impl Into<String>) -> Self {
87 Self::new(message).status(404)
88 }
89
90 pub fn bad_request(message: impl Into<String>) -> Self {
92 Self::new(message).status(400)
93 }
94
95 pub fn unauthorized(message: impl Into<String>) -> Self {
97 Self::new(message).status(401)
98 }
99
100 pub fn forbidden(message: impl Into<String>) -> Self {
102 Self::new(message).status(403)
103 }
104
105 pub fn unprocessable(message: impl Into<String>) -> Self {
107 Self::new(message).status(422)
108 }
109
110 pub fn conflict(message: impl Into<String>) -> Self {
112 Self::new(message).status(409)
113 }
114}
115
116impl std::fmt::Display for AppError {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 write!(f, "{}", self.message)
119 }
120}
121
122impl std::error::Error for AppError {}
123
124impl HttpError for AppError {
125 fn status_code(&self) -> u16 {
126 self.status_code
127 }
128
129 fn error_message(&self) -> String {
130 self.message.clone()
131 }
132}
133
134impl From<AppError> for FrameworkError {
135 fn from(e: AppError) -> Self {
136 FrameworkError::Domain {
137 message: e.message,
138 status_code: e.status_code,
139 }
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ValidationErrors {
163 #[serde(flatten)]
165 pub errors: HashMap<String, Vec<String>>,
166}
167
168impl ValidationErrors {
169 pub fn new() -> Self {
171 Self {
172 errors: HashMap::new(),
173 }
174 }
175
176 pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
178 self.errors
179 .entry(field.into())
180 .or_default()
181 .push(message.into());
182 }
183
184 pub fn is_empty(&self) -> bool {
186 self.errors.is_empty()
187 }
188
189 pub fn from_validator(errors: validator::ValidationErrors) -> Self {
191 let mut result = Self::new();
192 for (field, field_errors) in errors.field_errors() {
193 for error in field_errors {
194 let message = error
195 .message
196 .as_ref()
197 .map(|m| m.to_string())
198 .unwrap_or_else(|| format!("Validation failed for field '{field}'"));
199 result.add(field.to_string(), message);
200 }
201 }
202 result
203 }
204
205 pub fn to_json(&self) -> serde_json::Value {
207 serde_json::json!({
208 "message": "The given data was invalid.",
209 "errors": self.errors
210 })
211 }
212}
213
214impl Default for ValidationErrors {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220impl std::fmt::Display for ValidationErrors {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 write!(f, "Validation failed: {:?}", self.errors)
223 }
224}
225
226impl std::error::Error for ValidationErrors {}
227
228#[derive(Debug, Clone, Error)]
260pub enum FrameworkError {
261 #[error("Service '{type_name}' not registered in container")]
263 ServiceNotFound {
264 type_name: &'static str,
266 },
267
268 #[error("Missing required parameter: {param_name}")]
270 ParamError {
271 param_name: String,
273 },
274
275 #[error("Validation error for '{field}': {message}")]
277 ValidationError {
278 field: String,
280 message: String,
282 },
283
284 #[error("Database error: {0}")]
286 Database(String),
287
288 #[error("Internal server error: {message}")]
290 Internal {
291 message: String,
293 },
294
295 #[error("{message}")]
299 Domain {
300 message: String,
302 status_code: u16,
304 },
305
306 #[error("Validation failed")]
310 Validation(ValidationErrors),
311
312 #[error("This action is unauthorized.")]
316 Unauthorized,
317
318 #[error("{model_name} not found")]
322 ModelNotFound {
323 model_name: String,
325 },
326
327 #[error("Invalid parameter '{param}': expected {expected_type}")]
331 ParamParse {
332 param: String,
334 expected_type: &'static str,
336 },
337}
338
339impl FrameworkError {
340 pub fn service_not_found<T: ?Sized>() -> Self {
342 Self::ServiceNotFound {
343 type_name: std::any::type_name::<T>(),
344 }
345 }
346
347 pub fn param(name: impl Into<String>) -> Self {
349 Self::ParamError {
350 param_name: name.into(),
351 }
352 }
353
354 pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
356 Self::ValidationError {
357 field: field.into(),
358 message: message.into(),
359 }
360 }
361
362 pub fn database(message: impl Into<String>) -> Self {
364 Self::Database(message.into())
365 }
366
367 pub fn internal(message: impl Into<String>) -> Self {
369 Self::Internal {
370 message: message.into(),
371 }
372 }
373
374 pub fn domain(message: impl Into<String>, status_code: u16) -> Self {
376 Self::Domain {
377 message: message.into(),
378 status_code,
379 }
380 }
381
382 pub fn status_code(&self) -> u16 {
384 match self {
385 Self::ServiceNotFound { .. } => 500,
386 Self::ParamError { .. } => 400,
387 Self::ValidationError { .. } => 422,
388 Self::Database(_) => 500,
389 Self::Internal { .. } => 500,
390 Self::Domain { status_code, .. } => *status_code,
391 Self::Validation(_) => 422,
392 Self::Unauthorized => 403,
393 Self::ModelNotFound { .. } => 404,
394 Self::ParamParse { .. } => 400,
395 }
396 }
397
398 pub fn validation_errors(errors: ValidationErrors) -> Self {
400 Self::Validation(errors)
401 }
402
403 pub fn model_not_found(name: impl Into<String>) -> Self {
405 Self::ModelNotFound {
406 model_name: name.into(),
407 }
408 }
409
410 pub fn param_parse(param: impl Into<String>, expected_type: &'static str) -> Self {
412 Self::ParamParse {
413 param: param.into(),
414 expected_type,
415 }
416 }
417
418 pub fn hint(&self) -> Option<String> {
425 match self {
426 Self::ServiceNotFound { type_name } => Some(format!(
427 "Register with App::bind::<{type_name}>() in your bootstrap.rs or a service provider"
428 )),
429 Self::ParamError { param_name } => Some(format!(
430 "Check your route definition includes :{param_name} or verify the request body contains this field"
431 )),
432 Self::ModelNotFound { model_name } => Some(format!(
433 "Verify the {model_name} exists in the database, or check that the route parameter matches a valid ID"
434 )),
435 Self::ParamParse {
436 param,
437 expected_type,
438 } => Some(format!(
439 "Route received '{param}' but expected a valid {expected_type}. Check the URL parameter format."
440 )),
441 Self::Database(_) => Some(
442 "Check DATABASE_URL in .env and verify the database is running".to_string(),
443 ),
444 Self::Unauthorized => Some(
445 "Check that the handler's form request authorize() returns true, or verify the user has the required permissions".to_string(),
446 ),
447 Self::Internal { .. } | Self::Domain { .. } | Self::ValidationError { .. } | Self::Validation(_) => None,
449 }
450 }
451}
452
453impl From<sea_orm::DbErr> for FrameworkError {
455 fn from(e: sea_orm::DbErr) -> Self {
456 Self::Database(e.to_string())
457 }
458}
459
460#[cfg(feature = "projections")]
462impl From<ferro_projections::Error> for FrameworkError {
463 fn from(e: ferro_projections::Error) -> Self {
464 Self::Internal {
465 message: e.to_string(),
466 }
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::http::HttpResponse;
474
475 fn error_to_json(err: FrameworkError) -> serde_json::Value {
477 let resp: HttpResponse = err.into();
478 serde_json::from_str(resp.body()).expect("response body should be valid JSON")
479 }
480
481 #[test]
482 fn service_not_found_includes_hint() {
483 let err = FrameworkError::service_not_found::<String>();
484 let json = error_to_json(err);
485
486 assert!(json.get("message").is_some(), "should have 'message' key");
487 let hint = json
488 .get("hint")
489 .and_then(|v| v.as_str())
490 .expect("should have 'hint' key");
491 assert!(
492 hint.contains("App::bind"),
493 "hint should mention App::bind, got: {hint}"
494 );
495 }
496
497 #[test]
498 fn param_error_includes_hint() {
499 let err = FrameworkError::param("user_id");
500 let json = error_to_json(err);
501
502 assert!(json.get("message").is_some());
503 let hint = json
504 .get("hint")
505 .and_then(|v| v.as_str())
506 .expect("should have hint");
507 assert!(
508 hint.contains(":user_id"),
509 "hint should reference param name, got: {hint}"
510 );
511 }
512
513 #[test]
514 fn model_not_found_includes_hint() {
515 let err = FrameworkError::model_not_found("User");
516 let json = error_to_json(err);
517
518 assert_eq!(json["message"], "User not found");
519 let hint = json
520 .get("hint")
521 .and_then(|v| v.as_str())
522 .expect("should have hint");
523 assert!(hint.contains("User"), "hint should reference model name");
524 }
525
526 #[test]
527 fn param_parse_includes_hint() {
528 let err = FrameworkError::param_parse("abc", "i32");
529 let json = error_to_json(err);
530
531 let hint = json
532 .get("hint")
533 .and_then(|v| v.as_str())
534 .expect("should have hint");
535 assert!(hint.contains("abc"), "hint should include received value");
536 assert!(hint.contains("i32"), "hint should include expected type");
537 }
538
539 #[test]
540 fn database_error_includes_hint() {
541 let err = FrameworkError::database("connection refused");
542 let json = error_to_json(err);
543
544 let hint = json
545 .get("hint")
546 .and_then(|v| v.as_str())
547 .expect("should have hint");
548 assert!(
549 hint.contains("DATABASE_URL"),
550 "hint should mention DATABASE_URL"
551 );
552 }
553
554 #[test]
555 fn unauthorized_includes_hint() {
556 let err = FrameworkError::Unauthorized;
557 let json = error_to_json(err);
558
559 assert_eq!(json["message"], "This action is unauthorized.");
560 let hint = json
561 .get("hint")
562 .and_then(|v| v.as_str())
563 .expect("should have hint");
564 assert!(
565 hint.contains("authorize()"),
566 "hint should mention authorize()"
567 );
568 }
569
570 #[test]
571 fn internal_error_has_no_hint() {
572 let err = FrameworkError::internal("something broke");
573 let json = error_to_json(err);
574
575 assert!(json.get("message").is_some());
576 assert!(
577 json.get("hint").is_none(),
578 "Internal errors should not have hints"
579 );
580 }
581
582 #[test]
583 fn domain_error_has_no_hint() {
584 let err = FrameworkError::domain("custom message", 409);
585 let json = error_to_json(err);
586
587 assert_eq!(json["message"], "custom message");
588 assert!(
589 json.get("hint").is_none(),
590 "Domain errors should not have hints"
591 );
592 }
593
594 #[test]
595 fn validation_errors_have_no_hint() {
596 let mut errors = ValidationErrors::new();
597 errors.add("email", "Email is required");
598 let err = FrameworkError::validation_errors(errors);
599 let json = error_to_json(err);
600
601 assert!(
602 json.get("hint").is_none(),
603 "Validation errors should not have hints"
604 );
605 assert!(json.get("errors").is_some(), "should have errors field");
606 }
607
608 #[cfg(feature = "projections")]
609 #[test]
610 fn projection_error_converts_to_500() {
611 let err = FrameworkError::from(ferro_projections::Error::Definition(
612 "missing field".to_string(),
613 ));
614 assert_eq!(err.status_code(), 500);
615
616 let json = error_to_json(err);
617 let msg = json["message"].as_str().unwrap();
618 assert!(
619 msg.contains("missing field"),
620 "error message should contain original text, got: {msg}"
621 );
622 }
623
624 #[test]
625 fn status_codes_are_correct() {
626 assert_eq!(
627 FrameworkError::service_not_found::<String>().status_code(),
628 500
629 );
630 assert_eq!(FrameworkError::param("x").status_code(), 400);
631 assert_eq!(FrameworkError::model_not_found("X").status_code(), 404);
632 assert_eq!(FrameworkError::param_parse("x", "i32").status_code(), 400);
633 assert_eq!(FrameworkError::database("err").status_code(), 500);
634 assert_eq!(FrameworkError::internal("err").status_code(), 500);
635 assert_eq!(FrameworkError::domain("err", 409).status_code(), 409);
636 assert_eq!(FrameworkError::Unauthorized.status_code(), 403);
637 }
638}