Skip to main content

blockfrost/
url.rs

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    /// Build a paginated URL, ignoring any block cursors set on `pagination`.
18    ///
19    /// This is the variant used by every endpoint that does not accept `from`/`to`.
20    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    /// Build a paginated URL, appending `from`/`to` when they are set on `pagination`.
27    ///
28    /// Only endpoints documented as accepting block cursors use this variant.
29    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    /// Non-cursor endpoints must never leak `from`/`to`, even when they are set.
211    #[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}