use crate::messages::Messages;
use rustlavel_core::Json;
use rustlavel_http::{IntoResponse, Response, Status};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Errors {
fields: BTreeMap<String, Vec<String>>,
wants_json: bool,
}
impl Errors {
pub fn new() -> Self {
Errors::default()
}
pub const STATUS: Status = Status::UNPROCESSABLE;
pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
self.fields.entry(field.into()).or_default().push(message.into());
}
pub fn has(&self, field: &str) -> bool {
self.fields.contains_key(field)
}
pub fn first(&self, field: &str) -> Option<&str> {
self.fields.get(field)?.first().map(String::as_str)
}
pub fn get(&self, field: &str) -> &[String] {
self.fields.get(field).map_or(&[], Vec::as_slice)
}
pub fn all(&self) -> &BTreeMap<String, Vec<String>> {
&self.fields
}
pub fn messages(&self) -> impl Iterator<Item = &str> {
self.fields.values().flatten().map(String::as_str)
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
pub fn len(&self) -> usize {
self.fields.values().map(Vec::len).sum()
}
pub fn fields(&self) -> impl Iterator<Item = &str> {
self.fields.keys().map(String::as_str)
}
pub fn wants_json(&self) -> bool {
self.wants_json
}
pub fn with_json(mut self, wants_json: bool) -> Self {
self.wants_json = wants_json;
self
}
pub fn with(mut self, field: impl Into<String>, message: impl Into<String>) -> Self {
self.add(field, message);
self
}
pub fn add_interpolated(&mut self, messages: &Messages, field: &str, template: &str) {
let rendered =
crate::messages::interpolate(template, &[("attribute", messages.label(field))]);
self.add(field, rendered);
}
pub fn summary(&self) -> String {
let Some(first) = self.messages().next() else {
return "The given data was invalid.".to_string();
};
match self.len() - 1 {
0 => first.to_string(),
1 => format!("{first} (and 1 more error)"),
more => format!("{first} (and {more} more errors)"),
}
}
pub fn to_json(&self) -> Json {
let errors = self.fields.iter().map(|(field, messages)| {
let messages = messages.iter().map(|m| Json::from(m.as_str())).collect();
(field.clone(), Json::Array(messages))
});
Json::object([
("message", Json::from(self.summary())),
("errors", Json::Object(errors.collect())),
])
}
}
impl std::fmt::Display for Errors {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.summary())
}
}
impl std::error::Error for Errors {}
impl From<&Errors> for Json {
fn from(errors: &Errors) -> Self {
errors.to_json()
}
}
impl From<Errors> for Json {
fn from(errors: Errors) -> Self {
errors.to_json()
}
}
impl IntoResponse for Errors {
fn into_response(self) -> Response {
if self.wants_json {
return Response::new(Errors::STATUS).with_json(self.to_json());
}
let mut body = self.summary();
for message in self.messages().skip(1) {
body.push('\n');
body.push_str(message);
}
Response::new(Errors::STATUS).with_text(body)
}
}
impl From<Errors> for Response {
fn from(errors: Errors) -> Self {
errors.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Errors {
Errors::new()
.with("email", "The email field is required.")
.with("email", "The email field must be a valid email address.")
.with("age", "The age field must be at least 18.")
}
#[test]
fn an_empty_bag_reports_itself_as_empty() {
let errors = Errors::new();
assert!(errors.is_empty());
assert_eq!(errors.len(), 0);
assert!(!errors.has("email"));
assert_eq!(errors.first("email"), None);
assert!(errors.get("email").is_empty());
}
#[test]
fn messages_are_grouped_by_field_and_kept_in_order() {
let errors = sample();
assert!(errors.has("email"));
assert_eq!(errors.first("email"), Some("The email field is required."));
assert_eq!(errors.get("email").len(), 2);
assert_eq!(errors.all().len(), 2, "two fields failed");
assert_eq!(errors.len(), 3, "three messages in total");
assert_eq!(errors.fields().collect::<Vec<_>>(), ["age", "email"]);
}
#[test]
fn the_summary_counts_the_failures_it_did_not_show() {
assert_eq!(Errors::new().summary(), "The given data was invalid.");
assert_eq!(Errors::new().with("a", "One.").summary(), "One.");
assert_eq!(
Errors::new().with("a", "One.").with("a", "Two.").summary(),
"One. (and 1 more error)"
);
assert_eq!(sample().summary(), "The age field must be at least 18. (and 2 more errors)");
}
#[test]
fn the_body_has_laravels_422_shape() {
let body = Errors::new()
.with("email", "The email field is required.")
.to_json();
assert_eq!(
body.to_string(),
r#"{"errors":{"email":["The email field is required."]},"message":"The email field is required."}"#
);
assert_eq!(body.get("errors.email.0").unwrap().as_str(), Some("The email field is required."));
}
#[test]
fn a_json_client_gets_the_422_envelope() {
let response = sample().with_json(true).into_response();
assert_eq!(response.status, Status::UNPROCESSABLE);
assert_eq!(response.headers.content_type(), Some("application/json"));
assert!(response.body_string().contains(r#""errors":{"age":["#));
}
#[test]
fn a_browser_gets_a_plain_body_it_can_read() {
let response = sample().into_response();
assert_eq!(response.status, Status::UNPROCESSABLE);
assert_eq!(response.headers.content_type(), Some("text/plain"));
assert!(response.body_string().contains("The email field is required."));
}
#[test]
fn a_hand_written_message_interpolates_the_attribute() {
let messages = Messages::new().attribute("dob", "date of birth");
let mut errors = Errors::new();
errors.add_interpolated(&messages, "dob", "The :attribute field is in the future.");
assert_eq!(errors.first("dob"), Some("The date of birth field is in the future."));
}
#[test]
fn errors_display_as_their_summary() {
assert_eq!(sample().to_string(), sample().summary());
}
}