• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

0xmichalis / nftbk / 19676380878

25 Nov 2025 04:17PM UTC coverage: 54.592% (-0.1%) from 54.722%
19676380878

push

github

0xmichalis
feat(server): dynamic quotes based on total backup size

89 of 153 new or added lines in 6 files covered. (58.17%)

130 existing lines in 5 files now uncovered.

3222 of 5902 relevant lines covered (54.59%)

10.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.18
/src/httpclient/mod.rs
1
use std::path::{Path, PathBuf};
2

3
use tracing::info;
4

5
use crate::content::get_filename;
6
use crate::content::{extensions, try_exists, write_and_postprocess_file, Options};
7
use crate::httpclient::fetch::{try_fetch_response, try_head_content_length};
8
use crate::httpclient::retry::{retry_operation, should_retry};
9
use crate::httpclient::stream::stream_http_to_file;
10
use crate::ipfs::config::{IpfsGatewayConfig, IpfsGatewayType, IPFS_GATEWAYS};
11
use crate::url::{get_data_url, is_data_url, resolve_url_with_gateways};
12

13
pub mod fetch;
14
pub mod retry;
15
pub mod stream;
16

17
#[derive(Clone, Debug)]
18
pub struct HttpClient {
19
    pub(crate) ipfs_gateways: Vec<IpfsGatewayConfig>,
20
    pub(crate) max_retries: u32,
21
}
22

23
impl HttpClient {
24
    pub fn new() -> Self {
49✔
25
        let ipfs_gateways = IPFS_GATEWAYS.to_vec();
147✔
26
        Self {
27
            ipfs_gateways,
28
            max_retries: 5,
29
        }
30
    }
31

32
    pub fn with_gateways(mut self, gateways: Vec<(String, IpfsGatewayType)>) -> Self {
12✔
33
        self.ipfs_gateways = gateways
24✔
34
            .into_iter()
12✔
35
            .map(|(url, gateway_type)| IpfsGatewayConfig {
12✔
36
                url: Box::leak(url.into_boxed_str()),
20✔
37
                gateway_type,
10✔
38
                bearer_token_env: None,
10✔
39
            })
40
            .collect();
12✔
41
        self
12✔
42
    }
43

44
    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
11✔
45
        self.max_retries = max_retries;
11✔
46
        self
11✔
47
    }
48

49
    pub async fn fetch(&self, url: &str) -> anyhow::Result<Vec<u8>> {
24✔
50
        if is_data_url(url) {
24✔
51
            return get_data_url(url)
4✔
52
                .ok_or_else(|| anyhow::anyhow!("Failed to parse data URL: {}", url));
3✔
53
        }
54

55
        let resolved_url = resolve_url_with_gateways(url, &self.ipfs_gateways);
40✔
56
        let gateways = self.ipfs_gateways.clone();
30✔
57

58
        let (result, _status) = retry_operation(
59
            || {
12✔
60
                let url = resolved_url.clone();
36✔
61
                let gateways = gateways.clone();
36✔
62
                Box::pin(async move {
24✔
63
                    match try_fetch_response(&url, &gateways).await {
48✔
64
                        (Ok(response), status) => match response.bytes().await {
35✔
65
                            Ok(b) => (Ok(b.to_vec()), status),
14✔
66
                            Err(e) => (Err(anyhow::anyhow!(e)), status),
×
67
                        },
68
                        (Err(err), status) => (Err(err), status),
15✔
69
                    }
70
                })
71
            },
72
            self.max_retries,
10✔
73
            should_retry,
74
            &resolved_url,
10✔
75
        )
76
        .await;
10✔
77
        result
10✔
78
    }
79

80
    pub async fn head_content_length(&self, url: &str) -> anyhow::Result<u64> {
20✔
81
        if is_data_url(url) {
20✔
82
            let data = get_data_url(url)
27✔
83
                .ok_or_else(|| anyhow::anyhow!("Failed to parse data URL: {}", url))?;
9✔
84
            return Ok(data.len() as u64);
9✔
85
        }
86

87
        let resolved_url = resolve_url_with_gateways(url, &self.ipfs_gateways);
4✔
88
        let gateways = self.ipfs_gateways.clone();
3✔
89

90
        let (result, _status) = retry_operation(
91
            || {
1✔
92
                let url = resolved_url.clone();
3✔
93
                let gateways = gateways.clone();
3✔
94
                Box::pin(async move { try_head_content_length(&url, &gateways).await })
6✔
95
            },
96
            self.max_retries,
1✔
97
            should_retry,
98
            &resolved_url,
1✔
99
        )
100
        .await;
1✔
101
        result
1✔
102
    }
103

104
    pub async fn fetch_and_write(
21✔
105
        &self,
106
        url: &str,
107
        token: &impl crate::chain::common::ContractTokenInfo,
108
        output_path: &Path,
109
        options: Options,
110
    ) -> anyhow::Result<PathBuf> {
111
        let mut file_path = get_filename(url, token, output_path, options).await?;
126✔
112

113
        if let Some(existing_path) = try_exists(&file_path).await? {
49✔
114
            tracing::debug!(
7✔
UNCOV
115
                "File already exists at {} (skipping download)",
×
UNCOV
116
                existing_path.display()
×
117
            );
118
            return Ok(existing_path);
7✔
119
        }
120

121
        let parent = file_path.parent().ok_or_else(|| {
42✔
UNCOV
122
            anyhow::anyhow!("File path has no parent directory: {}", file_path.display())
×
123
        })?;
124
        tokio::fs::create_dir_all(parent).await.map_err(|e| {
42✔
UNCOV
125
            anyhow::anyhow!("Failed to create directory {}: {}", parent.display(), e)
×
126
        })?;
127

128
        if is_data_url(url) {
28✔
129
            let content = get_data_url(url)
24✔
130
                .ok_or_else(|| anyhow::anyhow!("Failed to parse data URL: {}", url))?;
8✔
131
            if !extensions::has_known_extension(&file_path) {
8✔
132
                if let Some(detected_ext) = extensions::detect_media_extension(&content) {
16✔
133
                    let current_path_str = file_path.to_string_lossy();
16✔
134
                    tracing::debug!("Appending detected media extension: {}", detected_ext);
8✔
135
                    file_path = PathBuf::from(format!("{current_path_str}.{detected_ext}"));
32✔
136
                }
137
            }
138

139
            info!("Saving {} (data url)", file_path.display());
8✔
140
            write_and_postprocess_file(&file_path, &content, url).await?;
32✔
141
            info!("Saved {} (data url)", file_path.display());
8✔
142
            return Ok(file_path);
8✔
143
        }
144

145
        let resolved_url = resolve_url_with_gateways(url, &self.ipfs_gateways);
24✔
146
        if url == resolved_url {
6✔
147
            info!("Saving {} (url: {})", file_path.display(), url);
1✔
148
        } else {
149
            info!(
5✔
150
                "Saving {} (original: {}, resolved: {})",
×
UNCOV
151
                file_path.display(),
×
152
                url,
153
                resolved_url
154
            );
155
        }
156
        let file_path = self
12✔
157
            .fetch_and_stream_to_file(&resolved_url, &file_path, self.max_retries)
24✔
158
            .await?;
12✔
159

UNCOV
160
        write_and_postprocess_file(&file_path, &[], url).await?;
×
UNCOV
161
        if url == resolved_url {
×
UNCOV
162
            info!("Saved {} (url: {})", file_path.display(), url);
×
163
        } else {
UNCOV
164
            info!(
×
UNCOV
165
                "Saved {} (original: {}, resolved: {})",
×
UNCOV
166
                file_path.display(),
×
167
                url,
168
                resolved_url
169
            );
170
        }
UNCOV
171
        Ok(file_path)
×
172
    }
173

UNCOV
174
    pub async fn try_fetch_response(
×
175
        &self,
176
        url: &str,
177
    ) -> (
178
        anyhow::Result<reqwest::Response>,
179
        Option<reqwest::StatusCode>,
180
    ) {
UNCOV
181
        try_fetch_response(url, &self.ipfs_gateways).await
×
182
    }
183

184
    pub(crate) async fn fetch_and_stream_to_file(
6✔
185
        &self,
186
        url: &str,
187
        file_path: &Path,
188
        max_retries: u32,
189
    ) -> anyhow::Result<PathBuf> {
190
        fetch_and_stream_to_file(url, file_path, max_retries, &self.ipfs_gateways).await
30✔
191
    }
192
}
193

194
async fn fetch_and_stream_to_file(
6✔
195
    url: &str,
196
    file_path: &Path,
197
    max_retries: u32,
198
    gateways: &[IpfsGatewayConfig],
199
) -> anyhow::Result<PathBuf> {
200
    let (result, _status) = retry_operation(
201
        || {
6✔
202
            let url = url.to_string();
18✔
203
            let file_path = file_path.to_path_buf();
18✔
204
            let gateways = gateways.to_owned();
18✔
205
            Box::pin(async move {
12✔
206
                match try_fetch_response(&url, &gateways).await {
24✔
UNCOV
207
                    (Ok(response), status) => {
×
UNCOV
208
                        (stream_http_to_file(response, &file_path).await, status)
×
209
                    }
210
                    (Err(err), status) => (Err(err), status),
18✔
211
                }
212
            })
213
        },
214
        max_retries,
6✔
215
        should_retry,
216
        url,
6✔
217
    )
218
    .await;
6✔
219
    result
6✔
220
}
221

222
impl Default for HttpClient {
223
    fn default() -> Self {
1✔
224
        Self::new()
1✔
225
    }
226
}
227

228
#[cfg(test)]
229
mod fetch_tests {
230
    use super::*;
231
    use crate::ipfs::config::IpfsGatewayType;
232
    use wiremock::{
233
        matchers::{method, path},
234
        Mock, MockServer, ResponseTemplate,
235
    };
236

237
    #[tokio::test]
238
    async fn test_fetch_data_url() {
239
        let client = HttpClient::new();
240
        let data_url = "data:text/plain;base64,SGVsbG8gV29ybGQ="; // "Hello World" in base64
241

242
        let result = client.fetch(data_url).await;
243
        assert!(result.is_ok());
244
        let content = result.unwrap();
245
        assert_eq!(content, b"Hello World");
246
    }
247

248
    #[tokio::test]
249
    async fn test_fetch_data_url_invalid() {
250
        let client = HttpClient::new();
251
        let invalid_data_url = "data:invalid";
252

253
        let result = client.fetch(invalid_data_url).await;
254
        assert!(result.is_err());
255
        let err = result.err().unwrap().to_string();
256
        assert!(err.contains("Failed to parse data URL"));
257
    }
258

259
    #[tokio::test]
260
    async fn test_fetch_http_url_success() {
261
        let mock_server = MockServer::start().await;
262
        let url = format!("{}/test", mock_server.uri());
263

264
        Mock::given(method("GET"))
265
            .and(path("/test"))
266
            .respond_with(ResponseTemplate::new(200).set_body_string("HTTP Success"))
267
            .mount(&mock_server)
268
            .await;
269

270
        let client = HttpClient::new();
271
        let result = client.fetch(&url).await;
272
        assert!(result.is_ok());
273
        let content = result.unwrap();
274
        assert_eq!(content, b"HTTP Success");
275
    }
276

277
    #[tokio::test]
278
    async fn test_fetch_ipfs_url_success() {
279
        let mock_server = MockServer::start().await;
280
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
281
        let ipfs_url = format!("{}/ipfs/{}", mock_server.uri(), cid);
282

283
        Mock::given(method("GET"))
284
            .and(path(format!("/ipfs/{}", cid)))
285
            .respond_with(ResponseTemplate::new(200).set_body_string("IPFS Content"))
286
            .mount(&mock_server)
287
            .await;
288

289
        let client =
290
            HttpClient::new().with_gateways(vec![(mock_server.uri(), IpfsGatewayType::Path)]);
291
        let result = client.fetch(&ipfs_url).await;
292
        assert!(result.is_ok());
293
        let content = result.unwrap();
294
        assert_eq!(content, b"IPFS Content");
295
    }
296

297
    #[tokio::test]
298
    async fn test_fetch_http_url_404() {
299
        let mock_server = MockServer::start().await;
300
        let url = format!("{}/not-found", mock_server.uri());
301

302
        Mock::given(method("GET"))
303
            .and(path("/not-found"))
304
            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
305
            .mount(&mock_server)
306
            .await;
307

308
        let client = HttpClient::new();
309
        let result = client.fetch(&url).await;
310
        assert!(result.is_err());
311
        let err = result.err().unwrap().to_string();
312
        assert!(err.contains("HTTP error: status 404"));
313
    }
314

315
    #[tokio::test]
316
    async fn test_fetch_http_url_500_with_retries() {
317
        let mock_server = MockServer::start().await;
318
        let url = format!("{}/server-error", mock_server.uri());
319

320
        Mock::given(method("GET"))
321
            .and(path("/server-error"))
322
            .respond_with(ResponseTemplate::new(500).set_body_string("Server Error"))
323
            .mount(&mock_server)
324
            .await;
325

326
        let client = HttpClient::new().with_max_retries(2);
327
        let result = client.fetch(&url).await;
328
        assert!(result.is_err());
329
        let err = result.err().unwrap().to_string();
330
        assert!(err.contains("HTTP error: status 500"));
331
    }
332

333
    #[tokio::test]
334
    async fn test_fetch_network_error() {
335
        // Use a URL that will cause a connection error quickly
336
        // Port 1 is typically not in use and will fail fast
337
        let invalid_url = "http://127.0.0.1:1/invalid";
338
        let client = HttpClient::new().with_max_retries(0); // No retries to make it faster
339

340
        let result = client.fetch(invalid_url).await;
341
        assert!(result.is_err());
342
    }
343

344
    #[tokio::test]
345
    async fn test_fetch_with_custom_retries() {
346
        let mock_server = MockServer::start().await;
347
        let url = format!("{}/test", mock_server.uri());
348

349
        Mock::given(method("GET"))
350
            .and(path("/test"))
351
            .respond_with(ResponseTemplate::new(200).set_body_string("Success"))
352
            .mount(&mock_server)
353
            .await;
354

355
        let client = HttpClient::new().with_max_retries(3);
356
        let result = client.fetch(&url).await;
357
        assert!(result.is_ok());
358
        let content = result.unwrap();
359
        assert_eq!(content, b"Success");
360
    }
361

362
    #[tokio::test]
363
    async fn test_fetch_with_custom_gateways() {
364
        let mock_server = MockServer::start().await;
365
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
366
        let ipfs_url = format!("{}/ipfs/{}", mock_server.uri(), cid);
367

368
        Mock::given(method("GET"))
369
            .and(path(format!("/ipfs/{}", cid)))
370
            .respond_with(ResponseTemplate::new(200).set_body_string("Custom Gateway"))
371
            .mount(&mock_server)
372
            .await;
373

374
        let client =
375
            HttpClient::new().with_gateways(vec![(mock_server.uri(), IpfsGatewayType::Path)]);
376
        let result = client.fetch(&ipfs_url).await;
377
        assert!(result.is_ok());
378
        let content = result.unwrap();
379
        assert_eq!(content, b"Custom Gateway");
380
    }
381

382
    #[tokio::test]
383
    async fn test_fetch_large_response() {
384
        let mock_server = MockServer::start().await;
385
        let url = format!("{}/large", mock_server.uri());
386
        let large_content = "x".repeat(1024 * 1024); // 1MB
387

388
        Mock::given(method("GET"))
389
            .and(path("/large"))
390
            .respond_with(ResponseTemplate::new(200).set_body_string(large_content.clone()))
391
            .mount(&mock_server)
392
            .await;
393

394
        let client = HttpClient::new();
395
        let result = client.fetch(&url).await;
396
        assert!(result.is_ok());
397
        let content = result.unwrap();
398
        assert_eq!(content.len(), 1024 * 1024);
399
        assert_eq!(content, large_content.as_bytes());
400
    }
401

402
    #[tokio::test]
403
    async fn test_fetch_binary_content() {
404
        let mock_server = MockServer::start().await;
405
        let url = format!("{}/binary", mock_server.uri());
406
        let binary_content = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; // PNG header
407

408
        Mock::given(method("GET"))
409
            .and(path("/binary"))
410
            .respond_with(ResponseTemplate::new(200).set_body_bytes(binary_content.clone()))
411
            .mount(&mock_server)
412
            .await;
413

414
        let client = HttpClient::new();
415
        let result = client.fetch(&url).await;
416
        assert!(result.is_ok());
417
        let content = result.unwrap();
418
        assert_eq!(content, binary_content);
419
    }
420

421
    #[tokio::test]
422
    async fn test_httpclient_default_implementation() {
423
        let mock_server = MockServer::start().await;
424
        let url = format!("{}/default-test", mock_server.uri());
425

426
        Mock::given(method("GET"))
427
            .and(path("/default-test"))
428
            .respond_with(
429
                ResponseTemplate::new(200).set_body_string("Default Implementation Works"),
430
            )
431
            .mount(&mock_server)
432
            .await;
433

434
        // Test that Default::default() creates a working HttpClient
435
        let client = HttpClient::default();
436
        let result = client.fetch(&url).await;
437
        assert!(result.is_ok());
438
        let content = result.unwrap();
439
        assert_eq!(content, b"Default Implementation Works");
440

441
        // Verify that default has the expected configuration
442
        assert_eq!(client.max_retries, 5);
443
        assert!(!client.ipfs_gateways.is_empty()); // Should have default IPFS gateways
444
    }
445
}
446

447
#[cfg(test)]
448
mod head_content_length_tests {
449
    use super::*;
450
    use wiremock::{
451
        matchers::{method, path},
452
        Mock, MockServer, ResponseTemplate,
453
    };
454

455
    #[tokio::test]
456
    async fn calculates_size_for_data_url() {
457
        let client = HttpClient::new();
458
        let data_url = "data:text/plain;base64,SGVsbG8="; // "Hello"
459
        let size = client.head_content_length(data_url).await.unwrap();
460
        assert_eq!(size, 5);
461
    }
462

463
    #[tokio::test]
464
    async fn calculates_size_for_http_resource() {
465
        let mock_server = MockServer::start().await;
466
        let url = format!("{}/asset", mock_server.uri());
467

468
        Mock::given(method("HEAD"))
469
            .and(path("/asset"))
470
            .respond_with(ResponseTemplate::new(200).insert_header("Content-Length", "1024"))
471
            .mount(&mock_server)
472
            .await;
473

474
        let client = HttpClient::new();
475
        let size = client.head_content_length(&url).await.unwrap();
476
        assert_eq!(size, 1024);
477
    }
478
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc