use std::{fmt, sync::LazyLock}; use log::{debug, trace}; use regex::Regex; use serde::{Deserialize, Serialize}; /// ID of a file in a Sharry share /// /// - impl `Clone` as this is just a String /// - impl `serde` for cachefile handling /// - impl `Display` for formatting compatibility /// - impl `AsRef<[u8]>` for hashing with `blake2b_simd` #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(test, derive(Default))] pub struct Uri(String); impl fmt::Display for Uri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl AsRef<[u8]> for Uri { fn as_ref(&self) -> &[u8] { self.0.as_bytes() } } impl From for Uri { fn from(value: String) -> Self { fn parse_url(value: &str) -> Option<(String, String)> { /// Pattern breakdown: /// - `^(?P[^:/?#]+)://` - capture scheme (anything but `:/?#`) + `"://"` /// - `(?P[^/?#]+)` - capture authority/host (anything but `/?#`) /// - `(/.*)?` - maybe trailing slash and some path /// - `$` - end of string static SHARRY_URI_RE: LazyLock = LazyLock::new(|| { trace!("compiling SHARRY_URI_RE"); Regex::new(r"^(?P[^:/?#]+)://(?P[^/?#]+)(/.*)?$") .expect("Regex compilation failed") }); SHARRY_URI_RE.captures(value).map(|caps| { let captured = |name| { caps.name(name) .unwrap_or_else(|| panic!("{name} not captured")) .as_str() .to_string() }; (captured("scheme"), captured("host")) }) } trace!("TryFrom {value:?}"); if let Some((scheme, host)) = parse_url(&value) { let result = Self(format!("{scheme}://{host}")); debug!("{result:?}"); result } else { Self(value) } } } impl Uri { /// arbitrary endpoint in the Sharry API v2 fn endpoint(&self, path: fmt::Arguments) -> String { let uri = format!("{}/api/v2/{path}", self.0); trace!("endpoint: {uri:?}"); uri } /// Sharry API endpoint to create a new share pub fn share_create(&self) -> String { self.endpoint(format_args!("alias/upload/new")) } /// Sharry API endpoint to ping a share's notification hook pub fn share_notify(&self, share_id: &super::ShareID) -> String { self.endpoint(format_args!("alias/mail/notify/{share_id}")) } /// Sharry API endpoint to create a new file inside a share pub fn file_create(&self, share_id: &super::ShareID) -> String { self.endpoint(format_args!("alias/upload/{share_id}/files/tus")) } /// Sharry API endpoint to push data into a file inside a share pub fn file_patch(&self, share_id: &super::ShareID, file_id: &super::FileID) -> String { self.endpoint(format_args!("alias/upload/{share_id}/files/tus/{file_id}")) } } #[cfg(test)] mod tests { use super::*; use crate::{ sharry::{FileID, ShareID}, test_util::check_trait, }; #[test] fn basic_traits_working() { let cases = [ // simple http host "http://example.com", // https host with port "https://my-host:8080", // custom scheme "custom+scheme://host", ]; for uri_data in cases { let uri = Uri(uri_data.to_string()); check_trait(uri.to_string(), uri_data, "Display", "Uri"); check_trait(uri.as_ref(), uri_data.as_bytes(), "AsRef<[u8]>", "Uri"); } } #[test] fn valid_urls_produce_expected_uri() { let cases = [ // simple http host ("http://example.com", "http://example.com"), // https host with port ("https://my-host:8080", "https://my-host:8080"), // trailing slash ("scheme://host/", "scheme://host"), // with path ("scheme://host/path/to/whatever", "scheme://host"), // custom scheme ("custom+scheme://host", "custom+scheme://host"), ]; for (good, expected) in cases { let uri = Uri::from(good.to_string()); check_trait(uri.0, expected, "From", "Uri"); } } #[test] fn invalid_urls_passed_through() { let cases = [ // missing “://” "http:/example.com", // missing scheme "://example.com", // missing host "http://", "ftp://?query", // totally malformed "just-a-string", "", "///", ]; for bad in cases { let uri = Uri::from(bad.to_string()); check_trait(uri.0, bad, "From", "Uri"); } } #[test] fn test_endpoint() { let cases = [ // simple path ("path/to/something", "/api/v2/path/to/something"), // underscores, hyphens, dots ("bob_smith-son.eve", "/api/v2/bob_smith-son.eve"), // unicode ("漢字ユーザー", "/api/v2/漢字ユーザー"), // empty path ("", "/api/v2/"), // leading/trailing spaces (" frank ", "/api/v2/ frank "), // uppercase ("GUEST", "/api/v2/GUEST"), // numeric ("12345", "/api/v2/12345"), ]; let uri = Uri::default(); for (path, expected) in cases { assert_eq!(&expected, &uri.endpoint(format_args!("{path}"))); } } #[test] fn test_pub_endpoints() { let uri = Uri::default(); let share_id = ShareID::from("sid".to_string()); let file_id = FileID::new_test("fid".to_string()); assert_eq!("/api/v2/alias/upload/new", uri.share_create()); assert_eq!("/api/v2/alias/mail/notify/sid", uri.share_notify(&share_id)); assert_eq!( "/api/v2/alias/upload/sid/files/tus", uri.file_create(&share_id) ); assert_eq!( "/api/v2/alias/upload/sid/files/tus/fid", uri.file_patch(&share_id, &file_id) ); } }