1use crate::{
2 pagination::Pagination, CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL,
3};
4use std::error::Error;
5use url::{form_urlencoded, Url as UrlI};
6
7#[derive(Clone, Debug)]
8pub struct Url;
9
10impl Url {
11 pub fn from_endpoint(base_url: &str, endpoint_url: &str) -> Result<String, Box<dyn Error>> {
12 let url = Self::create_base_url(base_url, endpoint_url)?;
13
14 Ok(url.to_string())
15 }
16
17 pub fn from_paginated_endpoint(
21 base_url: &str, endpoint_url: &str, pagination: Pagination,
22 ) -> Result<String, Box<dyn Error>> {
23 Self::build_paginated_url(base_url, endpoint_url, pagination, false)
24 }
25
26 pub fn from_cursor_paginated_endpoint(
30 base_url: &str, endpoint_url: &str, pagination: Pagination,
31 ) -> Result<String, Box<dyn Error>> {
32 Self::build_paginated_url(base_url, endpoint_url, pagination, true)
33 }
34
35 fn build_paginated_url(
36 base_url: &str, endpoint_url: &str, pagination: Pagination, with_cursors: bool,
37 ) -> Result<String, Box<dyn Error>> {
38 let mut url = Self::create_base_url(base_url, endpoint_url)?;
39 let mut query_pairs = form_urlencoded::Serializer::new(String::new());
40
41 query_pairs.append_pair("page", pagination.page.to_string().as_str());
42 query_pairs.append_pair("count", pagination.count.to_string().as_str());
43 query_pairs.append_pair("order", pagination.order_to_string().as_str());
44
45 if with_cursors {
46 if let Some(from) = pagination.from {
47 query_pairs.append_pair("from", from.to_string().as_str());
48 }
49 if let Some(to) = pagination.to {
50 query_pairs.append_pair("to", to.to_string().as_str());
51 }
52 }
53
54 let query = query_pairs.finish();
55
56 url.set_query(Some(&query));
57
58 Ok(url.to_string())
59 }
60
61 pub fn generate_batch(
62 url: &str, batch_size: usize, start: usize, pagination: Pagination,
63 ) -> Result<Vec<String>, Box<dyn Error>> {
64 let mut result = Vec::new();
65 let url = UrlI::parse(url)?;
66 let extra_query_pairs: Vec<(String, String)> = url
67 .query_pairs()
68 .filter(|(name, _)| name != "page" && name != "count" && name != "order")
69 .map(|(name, value)| (name.into_owned(), value.into_owned()))
70 .collect();
71
72 for page in start..(start + batch_size) {
73 let mut query_pairs = form_urlencoded::Serializer::new(String::new());
74
75 query_pairs.append_pair("page", page.to_string().as_str());
76 query_pairs.append_pair("count", pagination.count.to_string().as_str());
77 query_pairs.append_pair("order", pagination.order_to_string().as_str());
78 query_pairs.extend_pairs(&extra_query_pairs);
79
80 let query = query_pairs.finish();
81
82 let mut url = url.clone();
83
84 url.set_query(Some(&query));
85
86 result.push(url.to_string());
87 }
88
89 Ok(result)
90 }
91
92 pub fn get_base_url_from_project_id(project_id: &str) -> String {
93 match project_id {
94 id if id.starts_with("mainnet") => CARDANO_MAINNET_URL,
95 id if id.starts_with("preview") => CARDANO_PREVIEW_URL,
96 id if id.starts_with("preprod") => CARDANO_PREPROD_URL,
97 _ => CARDANO_MAINNET_URL,
98 }
99 .to_string()
100 }
101
102 fn create_base_url(base_url: &str, endpoint_url: &str) -> Result<reqwest::Url, Box<dyn Error>> {
103 let mut url = UrlI::parse(base_url)?;
104 let endpoint = endpoint_url.strip_prefix('/').unwrap_or(endpoint_url);
105
106 if !url.path().ends_with('/') {
107 url.set_path(&format!("{}/", url.path()));
108 }
109
110 Ok(url.join(endpoint)?)
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::pagination::{BlockCursor, Order, Pagination};
118 use crate::{CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL};
119 use rstest::rstest;
120
121 #[rstest]
122 #[case("http://example.com", "api/data", "http://example.com/api/data")]
123 #[case("http://example.com/", "/api/data", "http://example.com/api/data")]
124 #[case(
125 "http://example.com/basepath",
126 "endpoint",
127 "http://example.com/basepath/endpoint"
128 )]
129 fn test_from_endpoint_success(
130 #[case] base_url: &str, #[case] endpoint_url: &str, #[case] expected: &str,
131 ) {
132 let result = Url::from_endpoint(base_url, endpoint_url).unwrap();
133 assert_eq!(result, expected);
134 }
135
136 #[rstest]
137 #[case("not a url", "api", true)]
138 #[case("http://example.com", "api", false)]
139 fn test_from_endpoint_error(
140 #[case] base_url: &str, #[case] endpoint_url: &str, #[case] should_err: bool,
141 ) {
142 let result = Url::from_endpoint(base_url, endpoint_url);
143
144 assert_eq!(result.is_err(), should_err);
145 }
146
147 #[rstest]
148 #[case(
149 "http://example.com",
150 "api/items",
151 2,
152 5,
153 Order::Desc,
154 "http://example.com/api/items?page=2&count=5&order=desc"
155 )]
156 #[case(
157 "https://foo.bar",
158 "data",
159 1,
160 10,
161 Order::Asc,
162 "https://foo.bar/data?page=1&count=10&order=asc"
163 )]
164 fn test_from_paginated_endpoint(
165 #[case] base_url: &str, #[case] endpoint_url: &str, #[case] page: usize,
166 #[case] count: usize, #[case] order: Order, #[case] expected: &str,
167 ) {
168 let pagination = Pagination {
169 page,
170 count,
171 order,
172 ..Default::default()
173 };
174 let result = Url::from_paginated_endpoint(base_url, endpoint_url, pagination).unwrap();
175 assert_eq!(result, expected);
176 }
177
178 #[rstest]
179 #[case(
180 Some(BlockCursor::block(8929261)),
181 Some(BlockCursor::tx(9999269, 10)),
182 "http://example.com/api/items?page=2&count=5&order=desc&from=8929261&to=9999269%3A10"
183 )]
184 #[case(
185 Some(BlockCursor::tx(8929261, 3)),
186 None,
187 "http://example.com/api/items?page=2&count=5&order=desc&from=8929261%3A3"
188 )]
189 #[case(
190 None,
191 Some(BlockCursor::block(9999269)),
192 "http://example.com/api/items?page=2&count=5&order=desc&to=9999269"
193 )]
194 fn test_from_cursor_paginated_endpoint(
195 #[case] from: Option<BlockCursor>, #[case] to: Option<BlockCursor>, #[case] expected: &str,
196 ) {
197 let pagination = Pagination {
198 from,
199 to,
200 ..Pagination::new(Order::Desc, 2, 5)
201 };
202
203 let result =
204 Url::from_cursor_paginated_endpoint("http://example.com", "api/items", pagination)
205 .unwrap();
206
207 assert_eq!(result, expected);
208 }
209
210 #[test]
212 fn test_from_paginated_endpoint_ignores_cursors() {
213 let pagination = Pagination::new(Order::Desc, 2, 5).with_range(8929261, (9999269, 10));
214
215 let result =
216 Url::from_paginated_endpoint("http://example.com", "api/items", pagination).unwrap();
217
218 assert_eq!(
219 result,
220 "http://example.com/api/items?page=2&count=5&order=desc"
221 );
222 }
223
224 #[rstest]
225 #[case("http://example.com/api/data", 3, 1, 10, Order::Asc,
226 vec![
227 "http://example.com/api/data?page=1&count=10&order=asc",
228 "http://example.com/api/data?page=2&count=10&order=asc",
229 "http://example.com/api/data?page=3&count=10&order=asc",
230 ])]
231 fn test_generate_batch(
232 #[case] base: &str, #[case] batch_size: usize, #[case] page_start: usize,
233 #[case] count: usize, #[case] order: Order, #[case] expected: Vec<&str>,
234 ) {
235 let pagination = Pagination {
236 page: 0,
237 count,
238 order,
239 ..Default::default()
240 };
241 let urls = Url::generate_batch(base, batch_size, page_start, pagination).unwrap();
242 let expected: Vec<String> = expected.into_iter().map(String::from).collect();
243 assert_eq!(urls, expected);
244 }
245
246 #[test]
247 fn test_generate_batch_preserves_cursor_query_parameters() {
248 let pagination = Pagination::new(Order::Asc, 1, 10);
249 let urls = Url::generate_batch(
250 "http://example.com/api/data?page=1&count=10&order=asc&from=8929261&to=9999269%3A10",
251 2,
252 1,
253 pagination,
254 )
255 .unwrap();
256
257 assert_eq!(
258 urls,
259 vec![
260 "http://example.com/api/data?page=1&count=10&order=asc&from=8929261&to=9999269%3A10",
261 "http://example.com/api/data?page=2&count=10&order=asc&from=8929261&to=9999269%3A10",
262 ]
263 );
264 }
265
266 #[rstest]
267 #[case(
268 "http://example.com/api/data",
269 0,
270 1,
271 100,
272 Order::Asc,
273 vec![]
274 )]
275 #[case(
276 "http://example.com/api/data",
277 2,
278 10,
279 50,
280 Order::Desc,
281 vec![
282 "http://example.com/api/data?page=10&count=50&order=desc",
283 "http://example.com/api/data?page=11&count=50&order=desc"
284 ]
285 )]
286 #[case(
287 "https://test.net/resources",
288 3,
289 5,
290 25,
291 Order::Asc,
292 vec![
293 "https://test.net/resources?page=5&count=25&order=asc",
294 "https://test.net/resources?page=6&count=25&order=asc",
295 "https://test.net/resources?page=7&count=25&order=asc"
296 ]
297 )]
298 fn test_generate_batch_extended(
299 #[case] base: &str, #[case] batch_size: usize, #[case] page_start: usize,
300 #[case] count: usize, #[case] order: Order, #[case] expected: Vec<&str>,
301 ) {
302 let pagination = Pagination {
303 page: 0,
304 count,
305 order,
306 ..Default::default()
307 };
308 let urls = Url::generate_batch(base, batch_size, page_start, pagination).unwrap();
309 let expected: Vec<String> = expected.into_iter().map(String::from).collect();
310
311 assert_eq!(
312 urls, expected,
313 "Failed for base: {base}, batch_size: {batch_size}, page_start: {page_start}",
314 );
315 }
316
317 #[test]
318 fn test_get_base_url_from_project_id() {
319 let cases = vec![
320 ("mainnet123", CARDANO_MAINNET_URL),
321 ("previewABC", CARDANO_PREVIEW_URL),
322 ("preprodXYZ", CARDANO_PREPROD_URL),
323 ("unknown", CARDANO_MAINNET_URL),
324 ];
325
326 for (project_id, expected) in cases {
327 let url = Url::get_base_url_from_project_id(project_id);
328 assert_eq!(url, expected.to_string(), "for project_id {project_id}");
329 }
330 }
331}