@@ -14,7 +14,9 @@ use cot::db::impl_mysql::MySqlValueRef;
1414use cot:: db:: impl_postgres:: PostgresValueRef ;
1515#[ cfg( feature = "sqlite" ) ]
1616use cot:: db:: impl_sqlite:: SqliteValueRef ;
17+ use cot:: form:: FormFieldValidationError ;
1718use email_address:: EmailAddress ;
19+ use thiserror:: Error ;
1820
1921#[ cfg( feature = "db" ) ]
2022use crate :: db:: { ColumnType , DatabaseField , DbValue , FromDbValue , SqlxValueRef , ToDbValue } ;
@@ -135,6 +137,219 @@ impl From<String> for Password {
135137 }
136138}
137139
140+ /// A validated URL wrapper.
141+ ///
142+ /// This structure ensures that the contained URL is correctly formatted and
143+ /// complies with standard URL syntax rules. It wraps [`url::Url`] to provide
144+ /// validation upon construction through methods like [`Url::new`] and
145+ /// [`Url::from_str`], and exposes useful methods for accessing URL components
146+ /// or converting the URL into different representations.
147+ ///
148+ /// # Behavior
149+ ///
150+ /// - **Validation**: Both `new` and `from_str` ensure the input is a
151+ /// syntactically correct URL as defined by the WHATWG URL specification via
152+ /// the underlying [`url::Url`] parser.
153+ /// - **Normalization**: The internal URL is normalized (e.g., trailing slash
154+ /// added for HTTP URLs) during construction.
155+ ///
156+ /// # Examples
157+ ///
158+ /// ## Creating a Validated URL Using `new`
159+ ///
160+ /// ```
161+ /// use cot::common_types::Url;
162+ ///
163+ /// // Successful URL creation
164+ /// let url = Url::new("https://example.com").unwrap();
165+ ///
166+ /// // Accessing the normalized URL string
167+ /// assert_eq!(url.as_str(), "https://example.com/");
168+ /// ```
169+ ///
170+ /// ## Parsing a URL from a [`String`] Using `from_str`
171+ ///
172+ /// ```
173+ /// use std::str::FromStr;
174+ ///
175+ /// use cot::common_types::Url;
176+ ///
177+ /// // Parse a valid URL string
178+ /// let url = Url::from_str("https://example.com").unwrap();
179+ ///
180+ /// // Convert into owned string representation
181+ /// let url_string = url.into_string();
182+ /// assert_eq!(url_string, "https://example.com/");
183+ /// ```
184+ #[ derive( Clone , Debug , PartialEq , Eq , PartialOrd , Ord , Hash ) ]
185+ pub struct Url ( url:: Url ) ;
186+
187+ impl Url {
188+ /// Creates a new `Url` by parsing the input string.
189+ ///
190+ /// # Errors
191+ ///
192+ /// Returns [`UrlParseError`] if the input string is not a valid URL.
193+ ///
194+ /// # Examples
195+ ///
196+ /// ```
197+ /// use cot::common_types::Url;
198+ ///
199+ /// let valid_url = Url::new("https://example.com").unwrap();
200+ /// ```
201+ pub fn new < S : AsRef < str > > ( s : S ) -> Result < Url , UrlParseError > {
202+ url:: Url :: from_str ( s. as_ref ( ) )
203+ . map ( Self )
204+ . map_err ( UrlParseError )
205+ }
206+
207+ /// Returns a string slice reference to the URL's string representation.
208+ #[ must_use]
209+ pub fn as_str ( & self ) -> & str {
210+ self . 0 . as_str ( )
211+ }
212+
213+ /// Converts the `Url` into a owned `String` representation.
214+ ///
215+ /// # Examples
216+ /// ```
217+ /// use cot::common_types::Url;
218+ ///
219+ /// let url = Url::new("https://example.com").unwrap();
220+ /// let url_string = url.into_string();
221+ /// assert_eq!(url_string, "https://example.com/");
222+ /// ```
223+ #[ must_use]
224+ pub fn into_string ( self ) -> String {
225+ self . 0 . into ( )
226+ }
227+ /// Returns the URL scheme (e.g., "http", "https").
228+ ///
229+ /// # Example
230+ /// ```
231+ /// use cot::common_types::Url;
232+ ///
233+ /// let url = Url::new("https://example.com").unwrap();
234+ /// assert_eq!(url.scheme(), "https");
235+ /// ```
236+ #[ must_use]
237+ pub fn scheme ( & self ) -> & str {
238+ self . 0 . scheme ( )
239+ }
240+
241+ /// Returns the host part of the URL, if present.
242+ ///
243+ /// This typically includes the domain name or IP address.
244+ /// # Example
245+ /// ```
246+ /// use cot::common_types::Url;
247+ ///
248+ /// let url = Url::new("https://example.com/path").unwrap();
249+ /// assert_eq!(url.host(), Some("example.com"));
250+ /// ```
251+ #[ must_use]
252+ pub fn host ( & self ) -> Option < & str > {
253+ self . 0 . host_str ( )
254+ }
255+
256+ /// Returns the path component of the URL.
257+ ///
258+ /// This includes everything after the host and before the query or
259+ /// fragment.
260+ ///
261+ /// # Example
262+ /// ```
263+ /// use cot::common_types::Url;
264+ ///
265+ /// let url = Url::new("https://example.com/foo/bar").unwrap();
266+ /// assert_eq!(url.path(), "/foo/bar");
267+ /// ```
268+ #[ must_use]
269+ pub fn path ( & self ) -> & str {
270+ self . 0 . path ( )
271+ }
272+
273+ /// Returns the query string of the URL, if present.
274+ ///
275+ /// The query is the part that follows the '?' character.
276+ ///
277+ /// # Example
278+ /// ```
279+ /// use cot::common_types::Url;
280+ ///
281+ /// let url = Url::new("https://example.com?query=1").unwrap();
282+ /// assert_eq!(url.query(), Some("query=1"));
283+ /// ```
284+ #[ must_use]
285+ pub fn query ( & self ) -> Option < & str > {
286+ self . 0 . query ( )
287+ }
288+
289+ /// Returns the fragment identifier of the URL, if present.
290+ ///
291+ /// The fragment is the part that follows the '#' character.
292+ ///
293+ /// # Example
294+ /// ```
295+ /// use cot::common_types::Url;
296+ ///
297+ /// let url = Url::new("https://example.com#section-1").unwrap();
298+ /// assert_eq!(url.fragment(), Some("section-1"));
299+ /// ```
300+ #[ must_use]
301+ pub fn fragment ( & self ) -> Option < & str > {
302+ self . 0 . fragment ( )
303+ }
304+ }
305+
306+ /// Implements string parsing for `Url`.
307+ ///
308+ /// This allows a string to be parsed directly into a validated [`Url`]
309+ /// instance. The parsing process ensures that the input string is a
310+ /// syntactically valid URL.
311+ ///
312+ /// # Errors
313+ ///
314+ /// Returns [`UrlParseError`] if the input string is not a valid URL format.
315+ ///
316+ /// # Examples
317+ ///
318+ /// ```
319+ /// use std::str::FromStr;
320+ ///
321+ /// use cot::common_types::Url;
322+ ///
323+ /// // Parsing a valid URL string
324+ /// let url = Url::from_str("https://example.com").unwrap();
325+ /// assert_eq!(url.as_str(), "https://example.com/");
326+ ///
327+ /// // Attempting to parse an invalid URL
328+ /// assert!(Url::from_str("not-a-url").is_err());
329+ /// ```
330+ impl FromStr for Url {
331+ type Err = UrlParseError ;
332+
333+ fn from_str ( s : & str ) -> Result < Self , Self :: Err > {
334+ Url :: new ( s)
335+ }
336+ }
337+
338+ /// A type that represents an error that occurs when parsing a URL.
339+ ///
340+ /// This is returned by [`Url::new`] and [`Url::from_str`] when the input string
341+ /// is not a valid URL.
342+ #[ derive( Debug , Error ) ]
343+ #[ error( transparent) ]
344+ #[ expect( missing_copy_implementations) ] // implementation detail
345+ pub struct UrlParseError ( url:: ParseError ) ;
346+
347+ impl From < UrlParseError > for FormFieldValidationError {
348+ fn from ( error : UrlParseError ) -> Self {
349+ FormFieldValidationError :: from_string ( error. to_string ( ) )
350+ }
351+ }
352+
138353/// A validated email address.
139354///
140355/// This is a newtype wrapper around [`EmailAddress`] that provides validation
@@ -154,7 +369,7 @@ impl From<String> for Password {
154369/// // Convert using TryFrom
155370/// let email = Email::try_from("user@example.com").unwrap();
156371/// ```
157- #[ derive( Clone , Debug ) ]
372+ #[ derive( Debug , Clone , PartialEq , Eq , Hash ) ]
158373pub struct Email ( EmailAddress ) ;
159374
160375impl Email {
@@ -174,8 +389,10 @@ impl Email {
174389 /// let email = Email::new("user@example.com").unwrap();
175390 /// assert!(Email::new("invalid").is_err());
176391 /// ```
177- pub fn new < S : AsRef < str > > ( email : S ) -> Result < Email , email_address:: Error > {
178- EmailAddress :: from_str ( email. as_ref ( ) ) . map ( Self )
392+ pub fn new < S : AsRef < str > > ( email : S ) -> Result < Email , EmailParseError > {
393+ EmailAddress :: from_str ( email. as_ref ( ) )
394+ . map ( Self )
395+ . map_err ( EmailParseError )
179396 }
180397
181398 /// Returns the email address as a string.
@@ -320,7 +537,7 @@ impl Email {
320537/// let email = Email::from_str("user@example.com").unwrap();
321538/// ```
322539impl FromStr for Email {
323- type Err = email_address :: Error ;
540+ type Err = EmailParseError ;
324541
325542 fn from_str ( s : & str ) -> Result < Self , Self :: Err > {
326543 Email :: new ( s)
@@ -337,7 +554,7 @@ impl FromStr for Email {
337554/// let email = Email::try_from("user@example.com").unwrap();
338555/// ```
339556impl TryFrom < & str > for Email {
340- type Error = email_address :: Error ;
557+ type Error = EmailParseError ;
341558
342559 fn try_from ( value : & str ) -> Result < Self , Self :: Error > {
343560 Email :: new ( value)
@@ -355,13 +572,27 @@ impl TryFrom<&str> for Email {
355572/// ```
356573#[ cfg( feature = "db" ) ]
357574impl TryFrom < String > for Email {
358- type Error = email_address :: Error ;
575+ type Error = EmailParseError ;
359576
360577 fn try_from ( value : String ) -> Result < Self , Self :: Error > {
361578 Email :: new ( value)
362579 }
363580}
364581
582+ /// A type that represents an error that occurs when parsing an email address.
583+ ///
584+ /// This is returned by [`Email::new`] and [`Email::from_str`] when the input
585+ /// string is not a valid email address.
586+ #[ derive( Debug , Error ) ]
587+ #[ error( transparent) ]
588+ pub struct EmailParseError ( email_address:: Error ) ;
589+
590+ impl From < EmailParseError > for FormFieldValidationError {
591+ fn from ( error : EmailParseError ) -> Self {
592+ FormFieldValidationError :: from_string ( error. to_string ( ) )
593+ }
594+ }
595+
365596/// Implements database value conversion for `Email`.
366597///
367598/// This allows a normalized `Email` to be stored in the database as a text
@@ -420,6 +651,20 @@ mod tests {
420651
421652 use super :: * ;
422653
654+ #[ test]
655+ fn url_new ( ) {
656+ let parse_url = Url :: new ( "https://example.com/" ) . unwrap ( ) ;
657+ assert_eq ! ( parse_url. as_str( ) , "https://example.com/" ) ;
658+ assert_eq ! ( parse_url. scheme( ) , "https" ) ;
659+ assert_eq ! ( parse_url. host( ) , Some ( "example.com" ) ) ;
660+ }
661+
662+ #[ test]
663+ fn url_new_normalize ( ) {
664+ let parse_url = Url :: new ( "https://example.com" ) . unwrap ( ) ;
665+ assert_eq ! ( parse_url. as_str( ) , "https://example.com/" ) ; // Normalizes to add trailing slash
666+ }
667+
423668 #[ test]
424669 fn password_debug ( ) {
425670 let password = Password :: new ( "password" ) ;
0 commit comments