Skip to main content

minco_contract/
request.rs

1use std::{collections::BTreeMap, fmt::Write as _};
2
3use serde::{Deserialize, Deserializer};
4
5/// Maximum number of public validation paths, including the omission sentinel.
6pub const CONTRACT_VALIDATION_MAX_FIELD_PATHS: usize = 32;
7/// Maximum number of public messages retained for one validation path.
8pub const CONTRACT_VALIDATION_MAX_MESSAGES_PER_PATH: usize = 4;
9/// Maximum materialized byte length of a public validation path.
10pub const CONTRACT_VALIDATION_MAX_PATH_BYTES: usize = 256;
11/// Maximum byte length accepted for a public validation message.
12pub const CONTRACT_VALIDATION_MAX_MESSAGE_BYTES: usize = 256;
13/// Maximum generated validation nesting depth.
14pub const CONTRACT_VALIDATION_MAX_PATH_DEPTH: usize = 16;
15
16const TRUNCATED_PATH: &str = "$._truncated";
17const TRUNCATED_MESSAGE: &str = "additional validation errors omitted";
18const SAFE_FALLBACK_MESSAGE: &str = "validation rule failed";
19
20/// A statically dispatched semantic request validator generated from a contract.
21pub trait ContractValidate {
22    /// Append public-safe validation failures to `errors`.
23    fn validate_contract(&self, errors: &mut ContractValidationErrors);
24}
25
26/// Deserialize a present optional property as a non-null `T`.
27///
28/// Combined with Serde's field-level `default`, a missing property remains
29/// `None`, while a present JSON `null` must deserialize as `T` and is rejected.
30pub fn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
31where
32    D: Deserializer<'de>,
33    T: Deserialize<'de>,
34{
35    T::deserialize(deserializer).map(Some)
36}
37
38/// Deserialize a required nullable property while retaining `Option<T>`.
39///
40/// Generated fields deliberately omit `default`, so a missing property is
41/// rejected by Serde while an explicit JSON `null` becomes `None`.
42pub fn deserialize_required_nullable<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
43where
44    D: Deserializer<'de>,
45    T: Deserialize<'de>,
46{
47    Option::<T>::deserialize(deserializer)
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum PathSegment {
52    Field(&'static str),
53    Index(usize),
54}
55
56/// Deterministic, bounded public validation failures.
57///
58/// The empty value contains only inline path state. Heap-backed field paths and
59/// messages are created only when [`Self::add`] records a failure.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ContractValidationErrors {
62    fields: BTreeMap<String, Vec<String>>,
63    path: [Option<PathSegment>; CONTRACT_VALIDATION_MAX_PATH_DEPTH],
64    depth: usize,
65    overflow_depth: usize,
66    truncated: bool,
67}
68
69impl ContractValidationErrors {
70    /// Create an empty validation result.
71    #[must_use]
72    pub const fn new() -> Self {
73        Self {
74            fields: BTreeMap::new(),
75            path: [None; CONTRACT_VALIDATION_MAX_PATH_DEPTH],
76            depth: 0,
77            overflow_depth: 0,
78            truncated: false,
79        }
80    }
81
82    /// Validate within a generated object field without allocating a path.
83    pub fn at_field(&mut self, field: &'static str, validate: impl FnOnce(&mut Self)) {
84        self.at_segment(PathSegment::Field(field), validate);
85    }
86
87    /// Validate within a generated array index without allocating a path.
88    pub fn at_index(&mut self, index: usize, validate: impl FnOnce(&mut Self)) {
89        self.at_segment(PathSegment::Index(index), validate);
90    }
91
92    /// Record one public-safe validation rule failure at the current path.
93    pub fn add(&mut self, message: &'static str) {
94        if self.truncated {
95            return;
96        }
97        if self.overflow_depth > 0 {
98            self.mark_truncated();
99            return;
100        }
101        let Some(path) = self.materialize_path() else {
102            self.mark_truncated();
103            return;
104        };
105        let message = if message.len() <= CONTRACT_VALIDATION_MAX_MESSAGE_BYTES {
106            message
107        } else {
108            SAFE_FALLBACK_MESSAGE
109        };
110
111        if let Some(messages) = self.fields.get_mut(&path) {
112            if messages.len() < CONTRACT_VALIDATION_MAX_MESSAGES_PER_PATH {
113                messages.push(message.to_owned());
114            } else {
115                self.mark_truncated();
116            }
117            return;
118        }
119
120        // Reserve one path for a deterministic indication that output was omitted.
121        if self.fields.len() >= CONTRACT_VALIDATION_MAX_FIELD_PATHS.saturating_sub(1) {
122            self.mark_truncated();
123            return;
124        }
125        self.fields.insert(path, vec![message.to_owned()]);
126    }
127
128    /// Return retained field paths in deterministic order.
129    #[must_use]
130    pub const fn fields(&self) -> &BTreeMap<String, Vec<String>> {
131        &self.fields
132    }
133
134    /// Consume the collector and return retained field paths.
135    #[must_use]
136    pub fn into_fields(self) -> BTreeMap<String, Vec<String>> {
137        self.fields
138    }
139
140    /// Return the number of retained field paths.
141    #[must_use]
142    pub fn len(&self) -> usize {
143        self.fields.len()
144    }
145
146    /// Return whether no validation failures were retained.
147    #[must_use]
148    pub fn is_empty(&self) -> bool {
149        self.fields.is_empty()
150    }
151
152    /// Return whether the bounded collector has omitted further validation work.
153    #[must_use]
154    pub const fn is_truncated(&self) -> bool {
155        self.truncated
156    }
157
158    fn at_segment(&mut self, segment: PathSegment, validate: impl FnOnce(&mut Self)) {
159        if self.truncated {
160            return;
161        }
162        if self.depth == CONTRACT_VALIDATION_MAX_PATH_DEPTH {
163            self.overflow_depth += 1;
164            validate(self);
165            self.overflow_depth -= 1;
166            return;
167        }
168        self.path[self.depth] = Some(segment);
169        self.depth += 1;
170        validate(self);
171        self.depth -= 1;
172        self.path[self.depth] = None;
173    }
174
175    fn materialize_path(&self) -> Option<String> {
176        if self.depth == 0 {
177            return Some("$".to_owned());
178        }
179        let mut output = String::new();
180        for (position, segment) in self.path[..self.depth].iter().flatten().enumerate() {
181            if position > 0 {
182                output.push('.');
183            }
184            match segment {
185                PathSegment::Field(field) => output.push_str(field),
186                PathSegment::Index(index) => {
187                    write!(output, "{index}").expect("writing to String cannot fail");
188                }
189            }
190            if output.len() > CONTRACT_VALIDATION_MAX_PATH_BYTES {
191                return None;
192            }
193        }
194        Some(output)
195    }
196
197    fn mark_truncated(&mut self) {
198        if self.truncated {
199            return;
200        }
201        self.truncated = true;
202        self.fields.insert(
203            TRUNCATED_PATH.to_owned(),
204            vec![TRUNCATED_MESSAGE.to_owned()],
205        );
206    }
207}
208
209impl Default for ContractValidationErrors {
210    fn default() -> Self {
211        Self::new()
212    }
213}