Skip to content

Commit 2a0fbd9

Browse files
kingzcheungm4tx
authored andcommitted
feat: add validated Url type (#339)
1 parent 91c78ed commit 2a0fbd9

File tree

4 files changed

+353
-15
lines changed

4 files changed

+353
-15
lines changed

cot/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ tower = { workspace = true, features = ["util"] }
6464
tower-livereload = { workspace = true, optional = true }
6565
tower-sessions = { workspace = true, features = ["memory-store"] }
6666
tracing.workspace = true
67-
url = { workspace = true, features = ["serde"], optional = true }
67+
url = { workspace = true, features = ["serde"] }
6868

6969
[dev-dependencies]
7070
async-stream.workspace = true
@@ -97,7 +97,7 @@ ignored = [
9797
default = ["sqlite", "postgres", "mysql", "json"]
9898
full = ["default", "fake", "live-reload", "test"]
9999
fake = ["dep:fake"]
100-
db = ["dep:url", "dep:sea-query", "dep:sea-query-binder", "dep:sqlx"]
100+
db = ["dep:sea-query", "dep:sea-query-binder", "dep:sqlx"]
101101
sqlite = ["db", "sea-query/backend-sqlite", "sea-query-binder/sqlx-sqlite", "sqlx/sqlite"]
102102
postgres = ["db", "sea-query/backend-postgres", "sea-query-binder/sqlx-postgres", "sqlx/postgres"]
103103
mysql = ["db", "sea-query/backend-mysql", "sea-query-binder/sqlx-mysql", "sqlx/mysql"]

cot/src/common_types.rs

Lines changed: 251 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ use cot::db::impl_mysql::MySqlValueRef;
1414
use cot::db::impl_postgres::PostgresValueRef;
1515
#[cfg(feature = "sqlite")]
1616
use cot::db::impl_sqlite::SqliteValueRef;
17+
use cot::form::FormFieldValidationError;
1718
use email_address::EmailAddress;
19+
use thiserror::Error;
1820

1921
#[cfg(feature = "db")]
2022
use 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)]
158373
pub struct Email(EmailAddress);
159374

160375
impl 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
/// ```
322539
impl 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
/// ```
339556
impl 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")]
357574
impl 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");

cot/src/form.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,6 @@ impl FormFieldValidationError {
220220
}
221221
}
222222

223-
impl From<email_address::Error> for FormFieldValidationError {
224-
fn from(error: email_address::Error) -> Self {
225-
FormFieldValidationError::from_string(error.to_string())
226-
}
227-
}
228-
229223
/// An enum indicating the target of a form validation error.
230224
#[derive(Debug)]
231225
pub enum FormErrorTarget<'a> {

0 commit comments

Comments
 (0)