backbone_core/
violation.rs1use serde::{Deserialize, Serialize};
18
19pub const INVALID_QUERY: &str = "invalid_query";
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25pub struct Violation {
26 pub path: String,
29 pub code: String,
31 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
33 pub params: serde_json::Map<String, serde_json::Value>,
34 pub message: String,
36}
37
38impl Violation {
39 pub fn new(path: impl Into<String>, code: impl Into<String>, message: impl Into<String>) -> Self {
40 Self { path: path.into(), code: code.into(), params: serde_json::Map::new(), message: message.into() }
41 }
42
43 pub fn invalid_query(parameter: &str, reason: &str, message: impl Into<String>) -> Self {
46 Self::new(parameter, INVALID_QUERY, message)
47 .with_param("parameter", parameter)
48 .with_param("reason", reason)
49 }
50
51 pub fn with_param(mut self, name: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
53 self.params.insert(name.into(), value.into());
54 self
55 }
56}
57
58pub fn batch_item_path(index: usize, path: &str) -> String {
61 if path.is_empty() {
62 index.to_string()
63 } else {
64 format!("{index}/{path}")
65 }
66}
67
68pub fn code_from_constraint(constraint: &str) -> String {
77 for prefix in ["ck_", "uq_", "ex_", "tg_"] {
78 if let Some(rest) = constraint.strip_prefix(prefix) {
79 if let Some((_, code)) = rest.split_once("__") {
80 if !code.is_empty() {
81 return code.to_string();
82 }
83 }
84 }
85 }
86 constraint.to_string()
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn a_generated_constraint_name_carries_its_rule_code() {
95 assert_eq!(code_from_constraint("ck_journals__unbalanced"), "unbalanced");
96 assert_eq!(code_from_constraint("uq_accounts__account_code_taken"), "account_code_taken");
97 assert_eq!(code_from_constraint("ex_tax_rates__overlapping_validity"), "overlapping_validity");
98 assert_eq!(code_from_constraint("fiscal_period_status_transition"), "fiscal_period_status_transition");
100 assert_eq!(code_from_constraint("ck_legacy"), "ck_legacy");
102 }
103
104 #[test]
105 fn a_refused_query_parameter_names_itself_and_its_reason() {
106 let v = Violation::invalid_query("sort_by", "unknown_column", "`sort_by`: `x` is not a field");
107 assert_eq!(
108 serde_json::to_value(&v).unwrap(),
109 serde_json::json!({
110 "path": "sort_by",
111 "code": "invalid_query",
112 "params": { "parameter": "sort_by", "reason": "unknown_column" },
113 "message": "`sort_by`: `x` is not a field"
114 })
115 );
116 }
117
118 #[test]
119 fn a_violation_serializes_without_empty_params() {
120 let v = Violation::new("status", "field_not_writable", "`status` changes only through its verbs");
121 assert_eq!(
122 serde_json::to_value(&v).unwrap(),
123 serde_json::json!({ "path": "status", "code": "field_not_writable", "message": "`status` changes only through its verbs" })
124 );
125 let v = Violation::new("name", "max_length", "too long").with_param("max", 255);
126 assert_eq!(serde_json::to_value(&v).unwrap()["params"], serde_json::json!({ "max": 255 }));
127 }
128}