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

0xmichalis / nftbk / 18804506775

25 Oct 2025 02:40PM UTC coverage: 54.993% (+0.5%) from 54.534%
18804506775

push

github

0xmichalis
refactor: move config module under server

3117 of 5668 relevant lines covered (54.99%)

10.62 hits per line

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

80.43
/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;
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 {
43✔
25
        let ipfs_gateways = IPFS_GATEWAYS.to_vec();
129✔
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 fetch_and_write(
21✔
81
        &self,
82
        url: &str,
83
        token: &impl crate::chain::common::ContractTokenInfo,
84
        output_path: &Path,
85
        options: Options,
86
    ) -> anyhow::Result<PathBuf> {
87
        let mut file_path = get_filename(url, token, output_path, options).await?;
126✔
88

89
        if let Some(existing_path) = try_exists(&file_path).await? {
49✔
90
            tracing::debug!(
7✔
91
                "File already exists at {} (skipping download)",
×
92
                existing_path.display()
×
93
            );
94
            return Ok(existing_path);
7✔
95
        }
96

97
        let parent = file_path.parent().ok_or_else(|| {
42✔
98
            anyhow::anyhow!("File path has no parent directory: {}", file_path.display())
×
99
        })?;
100
        tokio::fs::create_dir_all(parent).await.map_err(|e| {
42✔
101
            anyhow::anyhow!("Failed to create directory {}: {}", parent.display(), e)
×
102
        })?;
103

104
        if is_data_url(url) {
28✔
105
            let content = get_data_url(url)
24✔
106
                .ok_or_else(|| anyhow::anyhow!("Failed to parse data URL: {}", url))?;
8✔
107
            if !extensions::has_known_extension(&file_path) {
8✔
108
                if let Some(detected_ext) = extensions::detect_media_extension(&content) {
16✔
109
                    let current_path_str = file_path.to_string_lossy();
16✔
110
                    tracing::debug!("Appending detected media extension: {}", detected_ext);
8✔
111
                    file_path = PathBuf::from(format!("{current_path_str}.{detected_ext}"));
32✔
112
                }
113
            }
114

115
            info!("Saving {} (data url)", file_path.display());
8✔
116
            write_and_postprocess_file(&file_path, &content, url).await?;
32✔
117
            info!("Saved {} (data url)", file_path.display());
8✔
118
            return Ok(file_path);
8✔
119
        }
120

121
        let resolved_url = resolve_url_with_gateways(url, &self.ipfs_gateways);
24✔
122
        if url == resolved_url {
6✔
123
            info!("Saving {} (url: {})", file_path.display(), url);
1✔
124
        } else {
125
            info!(
5✔
126
                "Saving {} (original: {}, resolved: {})",
×
127
                file_path.display(),
×
128
                url,
129
                resolved_url
130
            );
131
        }
132
        let file_path = self
12✔
133
            .fetch_and_stream_to_file(&resolved_url, &file_path, self.max_retries)
24✔
134
            .await?;
12✔
135

136
        write_and_postprocess_file(&file_path, &[], url).await?;
×
137
        if url == resolved_url {
×
138
            info!("Saved {} (url: {})", file_path.display(), url);
×
139
        } else {
140
            info!(
×
141
                "Saved {} (original: {}, resolved: {})",
×
142
                file_path.display(),
×
143
                url,
144
                resolved_url
145
            );
146
        }
147
        Ok(file_path)
×
148
    }
149

150
    pub async fn try_fetch_response(
×
151
        &self,
152
        url: &str,
153
    ) -> (
154
        anyhow::Result<reqwest::Response>,
155
        Option<reqwest::StatusCode>,
156
    ) {
157
        try_fetch_response(url, &self.ipfs_gateways).await
×
158
    }
159

160
    pub(crate) async fn fetch_and_stream_to_file(
6✔
161
        &self,
162
        url: &str,
163
        file_path: &Path,
164
        max_retries: u32,
165
    ) -> anyhow::Result<PathBuf> {
166
        fetch_and_stream_to_file(url, file_path, max_retries, &self.ipfs_gateways).await
30✔
167
    }
168
}
169

170
async fn fetch_and_stream_to_file(
6✔
171
    url: &str,
172
    file_path: &Path,
173
    max_retries: u32,
174
    gateways: &[IpfsGatewayConfig],
175
) -> anyhow::Result<PathBuf> {
176
    let (result, _status) = retry_operation(
177
        || {
6✔
178
            let url = url.to_string();
18✔
179
            let file_path = file_path.to_path_buf();
18✔
180
            let gateways = gateways.to_owned();
18✔
181
            Box::pin(async move {
12✔
182
                match try_fetch_response(&url, &gateways).await {
24✔
183
                    (Ok(response), status) => {
×
184
                        (stream_http_to_file(response, &file_path).await, status)
×
185
                    }
186
                    (Err(err), status) => (Err(err), status),
18✔
187
                }
188
            })
189
        },
190
        max_retries,
6✔
191
        should_retry,
192
        url,
6✔
193
    )
194
    .await;
6✔
195
    result
6✔
196
}
197

198
impl Default for HttpClient {
199
    fn default() -> Self {
1✔
200
        Self::new()
1✔
201
    }
202
}
203

204
#[cfg(test)]
205
mod fetch_tests {
206
    use super::*;
207
    use crate::ipfs::config::IpfsGatewayType;
208
    use wiremock::{
209
        matchers::{method, path},
210
        Mock, MockServer, ResponseTemplate,
211
    };
212

213
    #[tokio::test]
214
    async fn test_fetch_data_url() {
215
        let client = HttpClient::new();
216
        let data_url = "data:text/plain;base64,SGVsbG8gV29ybGQ="; // "Hello World" in base64
217

218
        let result = client.fetch(data_url).await;
219
        assert!(result.is_ok());
220
        let content = result.unwrap();
221
        assert_eq!(content, b"Hello World");
222
    }
223

224
    #[tokio::test]
225
    async fn test_fetch_data_url_invalid() {
226
        let client = HttpClient::new();
227
        let invalid_data_url = "data:invalid";
228

229
        let result = client.fetch(invalid_data_url).await;
230
        assert!(result.is_err());
231
        let err = result.err().unwrap().to_string();
232
        assert!(err.contains("Failed to parse data URL"));
233
    }
234

235
    #[tokio::test]
236
    async fn test_fetch_http_url_success() {
237
        let mock_server = MockServer::start().await;
238
        let url = format!("{}/test", mock_server.uri());
239

240
        Mock::given(method("GET"))
241
            .and(path("/test"))
242
            .respond_with(ResponseTemplate::new(200).set_body_string("HTTP Success"))
243
            .mount(&mock_server)
244
            .await;
245

246
        let client = HttpClient::new();
247
        let result = client.fetch(&url).await;
248
        assert!(result.is_ok());
249
        let content = result.unwrap();
250
        assert_eq!(content, b"HTTP Success");
251
    }
252

253
    #[tokio::test]
254
    async fn test_fetch_ipfs_url_success() {
255
        let mock_server = MockServer::start().await;
256
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
257
        let ipfs_url = format!("{}/ipfs/{}", mock_server.uri(), cid);
258

259
        Mock::given(method("GET"))
260
            .and(path(format!("/ipfs/{}", cid)))
261
            .respond_with(ResponseTemplate::new(200).set_body_string("IPFS Content"))
262
            .mount(&mock_server)
263
            .await;
264

265
        let client =
266
            HttpClient::new().with_gateways(vec![(mock_server.uri(), IpfsGatewayType::Path)]);
267
        let result = client.fetch(&ipfs_url).await;
268
        assert!(result.is_ok());
269
        let content = result.unwrap();
270
        assert_eq!(content, b"IPFS Content");
271
    }
272

273
    #[tokio::test]
274
    async fn test_fetch_http_url_404() {
275
        let mock_server = MockServer::start().await;
276
        let url = format!("{}/not-found", mock_server.uri());
277

278
        Mock::given(method("GET"))
279
            .and(path("/not-found"))
280
            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
281
            .mount(&mock_server)
282
            .await;
283

284
        let client = HttpClient::new();
285
        let result = client.fetch(&url).await;
286
        assert!(result.is_err());
287
        let err = result.err().unwrap().to_string();
288
        assert!(err.contains("HTTP error: status 404"));
289
    }
290

291
    #[tokio::test]
292
    async fn test_fetch_http_url_500_with_retries() {
293
        let mock_server = MockServer::start().await;
294
        let url = format!("{}/server-error", mock_server.uri());
295

296
        Mock::given(method("GET"))
297
            .and(path("/server-error"))
298
            .respond_with(ResponseTemplate::new(500).set_body_string("Server Error"))
299
            .mount(&mock_server)
300
            .await;
301

302
        let client = HttpClient::new().with_max_retries(2);
303
        let result = client.fetch(&url).await;
304
        assert!(result.is_err());
305
        let err = result.err().unwrap().to_string();
306
        assert!(err.contains("HTTP error: status 500"));
307
    }
308

309
    #[tokio::test]
310
    async fn test_fetch_network_error() {
311
        // Use a URL that will cause a connection error quickly
312
        // Port 1 is typically not in use and will fail fast
313
        let invalid_url = "http://127.0.0.1:1/invalid";
314
        let client = HttpClient::new().with_max_retries(0); // No retries to make it faster
315

316
        let result = client.fetch(invalid_url).await;
317
        assert!(result.is_err());
318
    }
319

320
    #[tokio::test]
321
    async fn test_fetch_with_custom_retries() {
322
        let mock_server = MockServer::start().await;
323
        let url = format!("{}/test", mock_server.uri());
324

325
        Mock::given(method("GET"))
326
            .and(path("/test"))
327
            .respond_with(ResponseTemplate::new(200).set_body_string("Success"))
328
            .mount(&mock_server)
329
            .await;
330

331
        let client = HttpClient::new().with_max_retries(3);
332
        let result = client.fetch(&url).await;
333
        assert!(result.is_ok());
334
        let content = result.unwrap();
335
        assert_eq!(content, b"Success");
336
    }
337

338
    #[tokio::test]
339
    async fn test_fetch_with_custom_gateways() {
340
        let mock_server = MockServer::start().await;
341
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
342
        let ipfs_url = format!("{}/ipfs/{}", mock_server.uri(), cid);
343

344
        Mock::given(method("GET"))
345
            .and(path(format!("/ipfs/{}", cid)))
346
            .respond_with(ResponseTemplate::new(200).set_body_string("Custom Gateway"))
347
            .mount(&mock_server)
348
            .await;
349

350
        let client =
351
            HttpClient::new().with_gateways(vec![(mock_server.uri(), IpfsGatewayType::Path)]);
352
        let result = client.fetch(&ipfs_url).await;
353
        assert!(result.is_ok());
354
        let content = result.unwrap();
355
        assert_eq!(content, b"Custom Gateway");
356
    }
357

358
    #[tokio::test]
359
    async fn test_fetch_large_response() {
360
        let mock_server = MockServer::start().await;
361
        let url = format!("{}/large", mock_server.uri());
362
        let large_content = "x".repeat(1024 * 1024); // 1MB
363

364
        Mock::given(method("GET"))
365
            .and(path("/large"))
366
            .respond_with(ResponseTemplate::new(200).set_body_string(large_content.clone()))
367
            .mount(&mock_server)
368
            .await;
369

370
        let client = HttpClient::new();
371
        let result = client.fetch(&url).await;
372
        assert!(result.is_ok());
373
        let content = result.unwrap();
374
        assert_eq!(content.len(), 1024 * 1024);
375
        assert_eq!(content, large_content.as_bytes());
376
    }
377

378
    #[tokio::test]
379
    async fn test_fetch_binary_content() {
380
        let mock_server = MockServer::start().await;
381
        let url = format!("{}/binary", mock_server.uri());
382
        let binary_content = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; // PNG header
383

384
        Mock::given(method("GET"))
385
            .and(path("/binary"))
386
            .respond_with(ResponseTemplate::new(200).set_body_bytes(binary_content.clone()))
387
            .mount(&mock_server)
388
            .await;
389

390
        let client = HttpClient::new();
391
        let result = client.fetch(&url).await;
392
        assert!(result.is_ok());
393
        let content = result.unwrap();
394
        assert_eq!(content, binary_content);
395
    }
396

397
    #[tokio::test]
398
    async fn test_httpclient_default_implementation() {
399
        let mock_server = MockServer::start().await;
400
        let url = format!("{}/default-test", mock_server.uri());
401

402
        Mock::given(method("GET"))
403
            .and(path("/default-test"))
404
            .respond_with(
405
                ResponseTemplate::new(200).set_body_string("Default Implementation Works"),
406
            )
407
            .mount(&mock_server)
408
            .await;
409

410
        // Test that Default::default() creates a working HttpClient
411
        let client = HttpClient::default();
412
        let result = client.fetch(&url).await;
413
        assert!(result.is_ok());
414
        let content = result.unwrap();
415
        assert_eq!(content, b"Default Implementation Works");
416

417
        // Verify that default has the expected configuration
418
        assert_eq!(client.max_retries, 5);
419
        assert!(!client.ipfs_gateways.is_empty()); // Should have default IPFS gateways
420
    }
421
}
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