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

0xmichalis / nftbk / 26839860092

02 Jun 2026 06:23PM UTC coverage: 47.891% (-6.7%) from 54.6%
26839860092

push

github

0xmichalis
ci: add deny and vet jobs; readme cleanup

2350 of 4907 relevant lines covered (47.89%)

9.09 hits per line

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

75.29
/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::types::DEFAULT_MAX_CONTENT_REQUEST_RETRIES;
12
use crate::url::{get_data_url, is_data_url, resolve_url_with_gateways};
13

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

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

24
impl HttpClient {
25
    pub fn new() -> Self {
50✔
26
        let ipfs_gateways = IPFS_GATEWAYS.to_vec();
150✔
27
        Self {
28
            ipfs_gateways,
29
            max_retries: DEFAULT_MAX_CONTENT_REQUEST_RETRIES,
30
        }
31
    }
32

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

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

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

56
        let resolved_url = resolve_url_with_gateways(url, &self.ipfs_gateways);
57
        let gateways = self.ipfs_gateways.clone();
58

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

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

88
        let resolved_url = resolve_url_with_gateways(url, &self.ipfs_gateways);
89
        let gateways = self.ipfs_gateways.clone();
90

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

475
        let client = HttpClient::new();
476
        let size = client.head_content_length(&url).await.unwrap();
477
        assert_eq!(size, 1024);
478
    }
479

480
    #[tokio::test]
481
    async fn falls_back_to_get_when_head_forbidden() {
482
        let mock_server = MockServer::start().await;
483
        let url = format!("{}/blocked-head", mock_server.uri());
484

485
        Mock::given(method("HEAD"))
486
            .and(path("/blocked-head"))
487
            .respond_with(ResponseTemplate::new(403))
488
            .mount(&mock_server)
489
            .await;
490

491
        Mock::given(method("GET"))
492
            .and(path("/blocked-head"))
493
            .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8; 2048]))
494
            .mount(&mock_server)
495
            .await;
496

497
        let client = HttpClient::new();
498
        let size = client.head_content_length(&url).await.unwrap();
499
        assert_eq!(size, 2048);
500
    }
501
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc