1use core::fmt;
7use core::str::FromStr;
8
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use url::{ParseError, Url};
12
13use crate::ensure_cdk;
14
15#[derive(Debug, Error, PartialEq, Eq)]
17pub enum Error {
18 #[error(transparent)]
20 Url(#[from] ParseError),
21 #[error("Invalid URL")]
23 InvalidUrl,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct MintUrl(String);
29
30impl Serialize for MintUrl {
31 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
32 where
33 S: serde::Serializer,
34 {
35 serializer.serialize_str(&self.to_string())
37 }
38}
39
40impl<'de> Deserialize<'de> for MintUrl {
41 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42 where
43 D: serde::Deserializer<'de>,
44 {
45 let s = String::deserialize(deserializer)?;
47 MintUrl::from_str(&s).map_err(serde::de::Error::custom)
48 }
49}
50
51impl MintUrl {
52 fn format_url(url: &str) -> Result<String, Error> {
53 ensure_cdk!(!url.is_empty(), Error::InvalidUrl);
54
55 let url = url.trim_end_matches('/');
56 let protocol = url
58 .split("://")
59 .nth(0)
60 .ok_or(Error::InvalidUrl)?
61 .to_lowercase();
62 let host = url
63 .split("://")
64 .nth(1)
65 .ok_or(Error::InvalidUrl)?
66 .split('/')
67 .nth(0)
68 .ok_or(Error::InvalidUrl)?
69 .to_lowercase();
70 let path = url
71 .split("://")
72 .nth(1)
73 .ok_or(Error::InvalidUrl)?
74 .split('/')
75 .skip(1)
76 .collect::<Vec<&str>>()
77 .join("/");
78 let mut formatted_url = format!("{protocol}://{host}");
79 if !path.is_empty() {
80 formatted_url.push_str(&format!("/{path}"));
81 }
82 Ok(formatted_url)
83 }
84
85 pub fn join(&self, path: &str) -> Result<Url, Error> {
87 let url = Url::parse(&self.0)?;
88
89 let base_path = url.path();
91
92 let normalized_path = if base_path.ends_with('/') {
94 format!("{base_path}{path}")
95 } else {
96 format!("{base_path}/{path}")
97 };
98
99 let mut result = url.clone();
101 result.set_path(&normalized_path);
102 Ok(result)
103 }
104
105 pub fn join_paths(&self, path_elements: &[&str]) -> Result<Url, Error> {
107 self.join(&path_elements.join("/"))
108 }
109}
110
111impl FromStr for MintUrl {
112 type Err = Error;
113
114 fn from_str(url: &str) -> Result<Self, Self::Err> {
115 let formatted_url = Self::format_url(url);
116 match formatted_url {
117 Ok(url) => Ok(Self(url)),
118 Err(_) => Err(Error::InvalidUrl),
119 }
120 }
121}
122
123impl fmt::Display for MintUrl {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 write!(f, "{}", self.0)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131
132 use super::*;
133 use crate::Token;
134
135 #[test]
136 fn test_trim_trailing_slashes() {
137 let very_unformatted_url = "http://url-to-check.com////";
138 let unformatted_url = "http://url-to-check.com/";
139 let formatted_url = "http://url-to-check.com";
140
141 let very_trimmed_url = MintUrl::from_str(very_unformatted_url).unwrap();
142 assert_eq!(formatted_url, very_trimmed_url.to_string());
143
144 let trimmed_url = MintUrl::from_str(unformatted_url).unwrap();
145 assert_eq!(formatted_url, trimmed_url.to_string());
146
147 let unchanged_url = MintUrl::from_str(formatted_url).unwrap();
148 assert_eq!(formatted_url, unchanged_url.to_string());
149 }
150 #[test]
151 fn test_case_insensitive() {
152 let wrong_cased_url = "http://URL-to-check.com";
153 let correct_cased_url = "http://url-to-check.com";
154
155 let cased_url_formatted = MintUrl::from_str(wrong_cased_url).unwrap();
156 assert_eq!(correct_cased_url, cased_url_formatted.to_string());
157
158 let wrong_cased_url_with_path = "http://URL-to-check.com/PATH/to/check";
159 let correct_cased_url_with_path = "http://url-to-check.com/PATH/to/check";
160
161 let cased_url_with_path_formatted = MintUrl::from_str(wrong_cased_url_with_path).unwrap();
162 assert_eq!(
163 correct_cased_url_with_path,
164 cased_url_with_path_formatted.to_string()
165 );
166 }
167
168 #[test]
169 fn test_join_paths() {
170 let url_no_path = "http://url-to-check.com";
171
172 let url = MintUrl::from_str(url_no_path).unwrap();
173 assert_eq!(
174 format!("{url_no_path}/hello/world"),
175 url.join_paths(&["hello", "world"]).unwrap().to_string()
176 );
177
178 let url_no_path_with_slash = "http://url-to-check.com/";
179
180 let url = MintUrl::from_str(url_no_path_with_slash).unwrap();
181 assert_eq!(
182 format!("{url_no_path_with_slash}hello/world"),
183 url.join_paths(&["hello", "world"]).unwrap().to_string()
184 );
185
186 let url_with_path = "http://url-to-check.com/my/path";
187
188 let url = MintUrl::from_str(url_with_path).unwrap();
189 assert_eq!(
190 format!("{url_with_path}/hello/world"),
191 url.join_paths(&["hello", "world"]).unwrap().to_string()
192 );
193
194 let url_with_path_with_slash = "http://url-to-check.com/my/path/";
195
196 let url = MintUrl::from_str(url_with_path_with_slash).unwrap();
197 assert_eq!(
198 format!("{url_with_path_with_slash}hello/world"),
199 url.join_paths(&["hello", "world"]).unwrap().to_string()
200 );
201 }
202
203 #[test]
204 fn test_mint_url_slash_eqality() {
205 let mint_url_with_slash_str = "https://mint.minibits.cash/Bitcoin/";
206 let mint_url_with_slash = MintUrl::from_str(mint_url_with_slash_str).unwrap();
207
208 let mint_url_without_slash_str = "https://mint.minibits.cash/Bitcoin";
209 let mint_url_without_slash = MintUrl::from_str(mint_url_without_slash_str).unwrap();
210
211 assert_eq!(mint_url_with_slash, mint_url_without_slash);
212 assert_eq!(
213 mint_url_with_slash.to_string(),
214 mint_url_without_slash_str.to_string()
215 );
216 }
217
218 #[test]
219 fn test_token_equality_trailing_slash() {
220 let token_with_slash = Token::from_str("cashuBo2FteCNodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luL2F1Y3NhdGF0gaJhaUgAUAVQ8ElBRmFwgqRhYQhhc3hAYzg2NTZhZDg4MzVmOWVmMzVkYWQ1MTZjNGU5ZTU5ZjA3YzFmODg0NTc2NWY3M2FhNWMyMjVhOGI4MGM0ZGM0ZmFjWCECNpnvLdFcsaVbCPUlOzr78XtBoD3mm3jQcldsQ6iKUBFhZKNhZVggrER4tfjjiH0e-lf9H---us1yjQQi__ZCFB9yFwH4jDphc1ggZfP2KcQOWA110vLz11caZF1PuXN606caPO2ZCAhfdvphclggadgz0psQELNif3xJ5J2d_TJWtRKfDFSj7h2ZD4WSFeykYWECYXN4QGZlNjAzNjA1NWM1MzVlZTBlYjI3MjQ1NmUzNjJlNmNkOWViNDNkMWQxODg0M2MzMDQ4MGU0YzE2YjI0MDY5MDZhY1ghAilA3g2_NriE94uTPISd2CM-90x53mK5QNM2iyTFDlnTYWSjYWVYIExR7bUzqM6-lRU7PbbEfnPW1vnSzCEN4SArmJZqp_7bYXNYIJMKRTSlXumUjPWXX5V8-hGPSZ-OXZJiEWm6_IB93OUDYXJYIB8YsigK7dMX59Oiy4Rh05xU0n0rVAPV7g_YFx564ZVa").unwrap();
221
222 let token_without_slash = Token::from_str("cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSBomFpSABQBVDwSUFGYXCCpGFhCGFzeEBjODY1NmFkODgzNWY5ZWYzNWRhZDUxNmM0ZTllNTlmMDdjMWY4ODQ1NzY1ZjczYWE1YzIyNWE4YjgwYzRkYzRmYWNYIQI2me8t0VyxpVsI9SU7Ovvxe0GgPeabeNByV2xDqIpQEWFko2FlWCCsRHi1-OOIfR76V_0f7766zXKNBCL_9kIUH3IXAfiMOmFzWCBl8_YpxA5YDXXS8vPXVxpkXU-5c3rTpxo87ZkICF92-mFyWCBp2DPSmxAQs2J_fEnknZ39Mla1Ep8MVKPuHZkPhZIV7KRhYQJhc3hAZmU2MDM2MDU1YzUzNWVlMGViMjcyNDU2ZTM2MmU2Y2Q5ZWI0M2QxZDE4ODQzYzMwNDgwZTRjMTZiMjQwNjkwNmFjWCECKUDeDb82uIT3i5M8hJ3YIz73THneYrlA0zaLJMUOWdNhZKNhZVggTFHttTOozr6VFTs9tsR-c9bW-dLMIQ3hICuYlmqn_tthc1ggkwpFNKVe6ZSM9ZdflXz6EY9Jn45dkmIRabr8gH3c5QNhclggHxiyKArt0xfn06LLhGHTnFTSfStUA9XuD9gXHnrhlVo").unwrap();
223
224 let url_with_slash = token_with_slash.mint_url().unwrap();
225 let url_without_slash = token_without_slash.mint_url().unwrap();
226
227 assert_eq!(url_without_slash.to_string(), url_with_slash.to_string());
228 assert_eq!(url_without_slash, url_with_slash);
229 }
230}