reinhardt_forms/fields/
url_field.rs1use crate::field::{FieldError, FieldResult, FormField, Widget};
2use regex::Regex;
3use std::sync::LazyLock;
4
5const URL_PATTERN: &str = r"^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|localhost|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::\d+)?(?:/[^\s]*)?$";
7
8static URL_REGEX: LazyLock<Regex> =
10 LazyLock::new(|| Regex::new(URL_PATTERN).expect("URL regex pattern is valid"));
11
12#[derive(Debug, Clone)]
14pub struct URLField {
15 pub name: String,
17 pub label: Option<String>,
19 pub required: bool,
21 pub help_text: Option<String>,
23 pub widget: Widget,
25 pub initial: Option<serde_json::Value>,
27 pub max_length: Option<usize>,
29}
30
31impl URLField {
32 pub fn new(name: String) -> Self {
43 Self {
44 name,
45 label: None,
46 required: true,
47 help_text: None,
48 widget: Widget::TextInput,
49 initial: None,
50 max_length: Some(200),
51 }
52 }
53
54 fn validate_url(url: &str) -> bool {
55 URL_REGEX.is_match(url)
56 }
57}
58
59impl FormField for URLField {
60 fn name(&self) -> &str {
61 &self.name
62 }
63
64 fn label(&self) -> Option<&str> {
65 self.label.as_deref()
66 }
67
68 fn required(&self) -> bool {
69 self.required
70 }
71
72 fn help_text(&self) -> Option<&str> {
73 self.help_text.as_deref()
74 }
75
76 fn widget(&self) -> &Widget {
77 &self.widget
78 }
79
80 fn initial(&self) -> Option<&serde_json::Value> {
81 self.initial.as_ref()
82 }
83
84 fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
85 match value {
86 None if self.required => Err(FieldError::required(None)),
87 None => Ok(serde_json::Value::String(String::new())),
88 Some(v) => {
89 let s = v
90 .as_str()
91 .ok_or_else(|| FieldError::Invalid("Expected string".to_string()))?;
92
93 let s = s.trim();
94
95 if s.is_empty() {
96 if self.required {
97 return Err(FieldError::required(None));
98 }
99 return Ok(serde_json::Value::String(String::new()));
100 }
101
102 let char_count = s.chars().count();
105 if let Some(max) = self.max_length
106 && char_count > max
107 {
108 return Err(FieldError::Validation(format!(
109 "Ensure this value has at most {} characters (it has {})",
110 max, char_count
111 )));
112 }
113
114 if !Self::validate_url(s) {
116 return Err(FieldError::Validation("Enter a valid URL".to_string()));
117 }
118
119 Ok(serde_json::Value::String(s.to_string()))
120 }
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn test_urlfield_valid() {
131 let field = URLField::new("website".to_string());
132
133 assert_eq!(
134 field
135 .clean(Some(&serde_json::json!("https://example.com")))
136 .unwrap(),
137 serde_json::json!("https://example.com")
138 );
139 assert_eq!(
140 field
141 .clean(Some(&serde_json::json!("http://test.org/path")))
142 .unwrap(),
143 serde_json::json!("http://test.org/path")
144 );
145 }
146
147 #[test]
148 fn test_urlfield_invalid() {
149 let field = URLField::new("website".to_string());
150
151 assert!(matches!(
152 field.clean(Some(&serde_json::json!("not a url"))),
153 Err(FieldError::Validation(_))
154 ));
155 assert!(matches!(
156 field.clean(Some(&serde_json::json!("ftp://example.com"))),
157 Err(FieldError::Validation(_))
158 ));
159 }
160
161 #[test]
162 fn test_urlfield_optional() {
163 let mut field = URLField::new("website".to_string());
164 field.required = false;
165
166 assert_eq!(field.clean(None).unwrap(), serde_json::json!(""));
167 assert_eq!(
168 field.clean(Some(&serde_json::json!(""))).unwrap(),
169 serde_json::json!("")
170 );
171 }
172}