tablo_core/schema/
validation.rs1use email_address::{EmailAddress, Options};
4
5use crate::form::{FieldError, FormScalar};
6
7pub trait TypedValue: std::fmt::Display + std::str::FromStr {
9 const NOUN: &'static str;
11
12 const INPUT_TYPE: &'static str = "text";
14
15 fn parse_input(value: &str) -> Option<Self> {
17 value.parse().ok()
18 }
19}
20
21macro_rules! typed_whole_number {
23 ($($ty:ty),* $(,)?) => {
24 $(
25 impl TypedValue for $ty {
26 const NOUN: &'static str = "whole number";
27 }
28 )*
29 };
30}
31
32typed_whole_number!(
33 i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize
34);
35
36impl TypedValue for bool {
37 const NOUN: &'static str = "yes/no value";
38}
39
40impl TypedValue for f32 {
42 const NOUN: &'static str = "number";
43
44 fn parse_input(value: &str) -> Option<Self> {
45 value
46 .parse::<f32>()
47 .ok()
48 .filter(|parsed| parsed.is_finite())
49 }
50}
51
52impl TypedValue for f64 {
53 const NOUN: &'static str = "number";
54
55 fn parse_input(value: &str) -> Option<Self> {
56 value
57 .parse::<f64>()
58 .ok()
59 .filter(|parsed| parsed.is_finite())
60 }
61}
62
63impl TypedValue for uuid::Uuid {
64 const NOUN: &'static str = "identifier";
65}
66
67impl TypedValue for crate::TenantId {
68 const NOUN: &'static str = "identifier";
69}
70
71impl TypedValue for jiff::Timestamp {
73 const NOUN: &'static str = "timestamp";
74 const INPUT_TYPE: &'static str = "datetime-local";
75
76 fn parse_input(value: &str) -> Option<Self> {
78 if let Ok(parsed) = value.parse::<jiff::Timestamp>() {
79 return Some(parsed);
80 }
81 normalize_datetime_local(value)?.parse().ok()
82 }
83}
84
85fn normalize_datetime_local(value: &str) -> Option<String> {
88 let t = value.find('T')?;
89 let after_t = &value[t + 1..];
90 if after_t.contains('+') || after_t.contains('-') {
91 return None;
92 }
93 if value.ends_with(['Z', 'z']) {
94 return None;
95 }
96 if value.len() == 16 {
97 Some(format!("{value}:00Z"))
98 } else {
99 Some(format!("{value}Z"))
100 }
101}
102
103pub(crate) fn format_timestamp_input(storage: &str) -> String {
106 let trimmed = storage.trim();
107 if trimmed.is_empty() {
108 return String::new();
109 }
110 match trimmed.parse::<jiff::Timestamp>() {
111 Ok(parsed) => parsed.strftime("%Y-%m-%dT%H:%M").to_string(),
112 Err(_) => String::new(),
113 }
114}
115
116type ValueParser = fn(&str) -> Result<String, String>;
118
119fn scalar_parser<T: FormScalar>(value: &str) -> Result<String, String> {
122 T::parse_form(value).map(|parsed| parsed.to_form())
123}
124
125#[derive(Clone, Copy, Default)]
128pub(crate) struct Rules {
129 email: bool,
130 parser: Option<ValueParser>,
131}
132
133impl Rules {
134 pub(crate) fn new() -> Self {
136 Self::default()
137 }
138
139 pub(crate) fn scalar<T: FormScalar>(mut self) -> Self {
141 self.parser = Some(scalar_parser::<T>);
142 self
143 }
144
145 pub(crate) fn set_email(&mut self) {
147 self.email = true;
148 }
149
150 pub(crate) fn is_email(&self) -> bool {
152 self.email
153 }
154
155 pub(crate) fn validate(
158 &self,
159 key: &str,
160 label: &str,
161 required: bool,
162 value: &str,
163 ) -> Vec<FieldError> {
164 let v = value.trim();
165 let mut errs = Vec::new();
166 if required && v.is_empty() {
167 errs.push(FieldError::unanswered(key, required_error(label)));
168 }
169 if self.email && !v.is_empty() && !is_email(v) {
170 errs.push(FieldError::invalid(
171 key,
172 format!("{label} must be a valid email"),
173 ));
174 }
175 if !v.is_empty()
176 && errs.is_empty()
177 && let Some(parser) = &self.parser
178 && let Err(message) = parser(v)
179 {
180 errs.push(FieldError::invalid(key, message));
181 }
182 errs
183 }
184
185 pub(crate) fn normalize(&self, value: &str) -> Result<String, String> {
188 let v = value.trim();
189 match &self.parser {
190 Some(parser) => parser(v),
191 None => Ok(v.to_string()),
192 }
193 }
194}
195
196pub(crate) fn required_error(label: &str) -> String {
198 format!("{label} is required")
199}
200
201const EMAIL_MAX_LENGTH: usize = 254;
203
204fn is_email(value: &str) -> bool {
207 value.len() <= EMAIL_MAX_LENGTH
208 && EmailAddress::parse_with_options(
209 value,
210 Options::default()
211 .with_required_tld()
212 .without_display_text(),
213 )
214 .is_ok()
215}
216
217#[cfg(test)]
218mod tests;