Skip to main content

backbone_core/
violation.rs

1//! The shape of a refused write: one [`Violation`] per broken rule, each with
2//! the field it concerns, a stable code, the values the rule was checked
3//! against, and a sentence a person can act on.
4//!
5//! ```json
6//! { "success": false,
7//!   "error": "validation failed: field_not_writable: `status` …",
8//!   "violations": [
9//!     { "path": "status", "code": "field_not_writable", "params": {}, "message": "…" } ] }
10//! ```
11//!
12//! The `error` string stays, so a client that reads only it keeps working; a
13//! form reads `violations` to mark each field by its code. A violation the
14//! database raises carries the same code as the application-side check, read
15//! from the constraint's name (see [`code_from_constraint`]).
16
17use serde::{Deserialize, Serialize};
18
19/// The code of a violation that refuses one query parameter of a list-shaped request.
20pub const INVALID_QUERY: &str = "invalid_query";
21
22/// One broken rule.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25pub struct Violation {
26    /// The field it concerns, in the casing the entity serializes, with list
27    /// indices for child rows (`lines/3/amount`); empty for a whole-record rule.
28    pub path: String,
29    /// A stable snake_case code, the same on every layer that enforces it.
30    pub code: String,
31    /// The values the rule was checked against (`{ "max": 255 }`).
32    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
33    pub params: serde_json::Map<String, serde_json::Value>,
34    /// What went wrong, said so a person can fix it.
35    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    /// A refused query parameter: the code [`INVALID_QUERY`] on the parameter's path, with the
44    /// parameter and the stable `reason` it was refused for in `params`.
45    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    /// Add one checked-against value.
52    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
58/// The path of a violation on item `index` of a batch: the index becomes the first segment, so
59/// `name` on the fourth item reads `3/name` and a whole-record rule reads `3`.
60pub 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
68/// The rule code a database constraint stands for.
69///
70/// Generated constraints are named `ck_<table>__<code>` (CHECK),
71/// `uq_<table>__<code>` (unique index), `ex_<table>__<code>` (exclusion) and
72/// `tg_<table>__<code>` (constraint trigger), so a violation the database
73/// raises maps back to the code of the rule it enforces. A constraint named
74/// otherwise is its own code: hand-written triggers raise with a constraint
75/// name chosen to be read (`fiscal_period_status_transition`).
76pub 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        // A constraint named for reading is its own code.
99        assert_eq!(code_from_constraint("fiscal_period_status_transition"), "fiscal_period_status_transition");
100        // A prefix without the separator is not a generated name.
101        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}