1use std::io::Read;
4use std::sync::Arc;
5
6use copc_streaming::ByteSource;
7
8use spatialrust_core::PointCloud;
9
10use crate::copc::query::{CopcFileInfo, CopcQuery};
11use crate::copc::reader::{read_copc_from_byte_source, read_header_info};
12use crate::error::{copc_format, IoError};
13
14const DEFAULT_MAX_PARALLEL_RANGES: usize = 8;
15
16#[derive(Clone, Debug)]
21pub struct HttpByteSource {
22 url: String,
23 max_parallel_ranges: usize,
24 agent: ureq::Agent,
25}
26
27impl PartialEq for HttpByteSource {
28 fn eq(&self, other: &Self) -> bool {
29 self.url == other.url && self.max_parallel_ranges == other.max_parallel_ranges
30 }
31}
32
33impl Eq for HttpByteSource {}
34
35impl HttpByteSource {
36 pub fn new(url: impl Into<String>) -> Result<Self, IoError> {
38 let url = url.into();
39 validate_http_url(&url)?;
40 Ok(Self {
41 url,
42 max_parallel_ranges: DEFAULT_MAX_PARALLEL_RANGES,
43 agent: ureq::Agent::new(),
44 })
45 }
46
47 #[must_use]
49 pub fn with_max_parallel_ranges(mut self, max_parallel_ranges: usize) -> Self {
50 self.max_parallel_ranges = max_parallel_ranges.max(1);
51 self
52 }
53
54 #[must_use]
56 pub fn url(&self) -> &str {
57 &self.url
58 }
59
60 #[must_use]
62 pub fn max_parallel_ranges(&self) -> usize {
63 self.max_parallel_ranges
64 }
65}
66
67impl ByteSource for HttpByteSource {
68 async fn read_range(
69 &self,
70 offset: u64,
71 length: u64,
72 ) -> Result<Vec<u8>, copc_streaming::CopcError> {
73 fetch_http_range(&self.agent, &self.url, offset, length)
74 }
75
76 async fn read_ranges(
77 &self,
78 ranges: &[(u64, u64)],
79 ) -> Result<Vec<Vec<u8>>, copc_streaming::CopcError> {
80 fetch_http_ranges_parallel(&self.agent, &self.url, ranges, self.max_parallel_ranges)
81 }
82
83 async fn size(&self) -> Result<Option<u64>, copc_streaming::CopcError> {
84 fetch_http_size(&self.agent, &self.url)
85 }
86}
87
88pub fn read_copc_url(url: &str) -> Result<PointCloud, IoError> {
90 read_copc_url_with_query(url, None)
91}
92
93pub fn read_copc_url_with_query(
95 url: &str,
96 query: Option<&CopcQuery>,
97) -> Result<PointCloud, IoError> {
98 if let Some(query) = query {
99 query.validate()?;
100 }
101 validate_http_url(url)?;
102 let source = HttpByteSource::new(url)?;
103 pollster::block_on(read_copc_from_byte_source(source, query))
104}
105
106pub fn read_copc_url_info(url: &str) -> Result<CopcFileInfo, IoError> {
108 validate_http_url(url)?;
109 let source = HttpByteSource::new(url)?;
110 pollster::block_on(async { read_header_info(source).await.map(|(_, info)| info) })
111}
112
113fn fetch_http_range(
114 agent: &ureq::Agent,
115 url: &str,
116 offset: u64,
117 length: u64,
118) -> Result<Vec<u8>, copc_streaming::CopcError> {
119 if length == 0 {
120 return Ok(Vec::new());
121 }
122
123 let end = offset.saturating_add(length.saturating_sub(1));
124 let response = agent
125 .get(url)
126 .set("Range", &format!("bytes={offset}-{end}"))
127 .call()
128 .map_err(|error| copc_streaming::CopcError::ByteSource(Box::new(error)))?;
129
130 let status = response.status();
131 if status != 200 && status != 206 {
132 return Err(copc_streaming::CopcError::ByteSource(Box::new(std::io::Error::new(
133 std::io::ErrorKind::InvalidData,
134 format!("unexpected HTTP status {status} for range request"),
135 ))));
136 }
137
138 let mut bytes = Vec::with_capacity(length as usize);
139 response
140 .into_reader()
141 .take(length)
142 .read_to_end(&mut bytes)
143 .map_err(copc_streaming::CopcError::Io)?;
144 Ok(bytes)
145}
146
147fn fetch_http_ranges_parallel(
148 agent: &ureq::Agent,
149 url: &str,
150 ranges: &[(u64, u64)],
151 max_parallel_ranges: usize,
152) -> Result<Vec<Vec<u8>>, copc_streaming::CopcError> {
153 if ranges.is_empty() {
154 return Ok(Vec::new());
155 }
156
157 let mut results = Vec::with_capacity(ranges.len());
158 let url = Arc::new(url.to_owned());
159
160 for batch in ranges.chunks(max_parallel_ranges.max(1)) {
161 let batch_results = read_range_batch(agent.clone(), Arc::clone(&url), batch)?;
162 results.extend(batch_results);
163 }
164
165 Ok(results)
166}
167
168fn read_range_batch(
169 agent: ureq::Agent,
170 url: Arc<String>,
171 ranges: &[(u64, u64)],
172) -> Result<Vec<Vec<u8>>, copc_streaming::CopcError> {
173 if ranges.len() == 1 {
174 let (offset, length) = ranges[0];
175 return Ok(vec![fetch_http_range(&agent, url.as_str(), offset, length)?]);
176 }
177
178 std::thread::scope(|scope| {
179 let mut handles = Vec::with_capacity(ranges.len());
180 for (index, &(offset, length)) in ranges.iter().enumerate() {
181 let agent = agent.clone();
182 let url = Arc::clone(&url);
183 handles.push(scope.spawn(move || {
184 let bytes = fetch_http_range(&agent, url.as_str(), offset, length)?;
185 Ok::<_, copc_streaming::CopcError>((index, bytes))
186 }));
187 }
188
189 let mut batch = vec![Vec::new(); ranges.len()];
190 for handle in handles {
191 let (index, bytes) = handle.join().map_err(|_| {
192 copc_streaming::CopcError::ByteSource(Box::new(std::io::Error::other(
193 "parallel HTTP range worker panicked",
194 )))
195 })??;
196 batch[index] = bytes;
197 }
198 Ok(batch)
199 })
200}
201
202fn fetch_http_size(
203 agent: &ureq::Agent,
204 url: &str,
205) -> Result<Option<u64>, copc_streaming::CopcError> {
206 if let Ok(response) = agent.head(url).call() {
207 if let Some(total) = response.header("Content-Length").and_then(parse_u64_header) {
208 return Ok(Some(total));
209 }
210 }
211
212 let response = agent
213 .get(url)
214 .set("Range", "bytes=0-0")
215 .call()
216 .map_err(|error| copc_streaming::CopcError::ByteSource(Box::new(error)))?;
217
218 if let Some(total) = response.header("Content-Range").and_then(parse_content_range_total) {
219 return Ok(Some(total));
220 }
221
222 if let Some(total) = response.header("Content-Length").and_then(parse_u64_header) {
223 return Ok(Some(total));
224 }
225
226 Ok(None)
227}
228
229fn validate_http_url(url: &str) -> Result<(), IoError> {
230 if url.starts_with("http://") || url.starts_with("https://") {
231 Ok(())
232 } else {
233 Err(copc_format(format!(
234 "COPC HTTP sources require an http:// or https:// URL, got `{url}`"
235 )))
236 }
237}
238
239fn parse_u64_header(value: &str) -> Option<u64> {
240 value.trim().parse().ok()
241}
242
243fn parse_content_range_total(value: &str) -> Option<u64> {
244 value.split('/').nth(1)?.trim().parse().ok()
245}
246
247#[cfg(test)]
248mod tests {
249 use super::{
250 fetch_http_ranges_parallel, parse_content_range_total, read_range_batch, validate_http_url,
251 HttpByteSource,
252 };
253 use copc_streaming::ByteSource;
254 use std::io::{Read, Write};
255 use std::net::{TcpListener, TcpStream};
256 use std::sync::atomic::{AtomicUsize, Ordering};
257 use std::sync::Arc;
258 use std::thread;
259 use std::time::Duration;
260
261 #[test]
262 fn validates_http_urls() {
263 assert!(validate_http_url("https://example.com/cloud.copc.laz").is_ok());
264 assert!(validate_http_url("/tmp/local.copc.laz").is_err());
265 }
266
267 #[test]
268 fn parses_content_range_total() {
269 assert_eq!(parse_content_range_total("bytes 0-0/12345"), Some(12345));
270 }
271
272 #[test]
273 fn constructs_http_source() {
274 let source = HttpByteSource::new("https://example.com/cloud.copc.laz").unwrap();
275 assert_eq!(source.url(), "https://example.com/cloud.copc.laz");
276 assert_eq!(source.max_parallel_ranges(), 8);
277 assert_eq!(source.clone(), source);
278 assert_ne!(source.clone().with_max_parallel_ranges(4), source);
279 }
280
281 #[test]
282 fn read_ranges_fetches_multiple_byte_ranges() {
283 let payload = b"0123456789ABCDEF";
284 let requests = Arc::new(AtomicUsize::new(0));
285 let requests_server = Arc::clone(&requests);
286
287 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
288 listener.set_nonblocking(true).unwrap();
289 let addr = listener.local_addr().unwrap();
290
291 let server = thread::spawn(move || {
292 let deadline = std::time::Instant::now() + Duration::from_secs(5);
293 while requests_server.load(Ordering::SeqCst) < 3 {
294 if std::time::Instant::now() > deadline {
295 panic!("timed out waiting for HTTP range requests");
296 }
297 let Ok((mut stream, _)) = listener.accept() else {
298 thread::sleep(Duration::from_millis(10));
299 continue;
300 };
301 serve_test_range(&mut stream, payload, &requests_server);
302 }
303 });
304
305 let url = format!("http://{addr}/cloud.copc.laz");
306 let source = HttpByteSource::new(&url).unwrap().with_max_parallel_ranges(3);
307 let ranges = vec![(0, 4), (4, 4), (8, 4)];
308 let results = pollster::block_on(source.read_ranges(&ranges)).unwrap();
309
310 assert_eq!(results.len(), 3);
311 assert_eq!(results[0], b"0123".to_vec());
312 assert_eq!(results[1], b"4567".to_vec());
313 assert_eq!(results[2], b"89AB".to_vec());
314 assert_eq!(requests.load(Ordering::SeqCst), 3);
315 server.join().unwrap();
316 }
317
318 #[test]
319 fn fetch_ranges_batches_by_parallelism_limit() {
320 let url = "https://example.com/cloud.copc.laz";
321 let ranges = vec![(0, 1); 5];
322 let err = fetch_http_ranges_parallel(&ureq::Agent::new(), url, &ranges, 2).unwrap_err();
323 assert!(matches!(
324 err,
325 copc_streaming::CopcError::ByteSource(_) | copc_streaming::CopcError::Io(_)
326 ));
327 }
328
329 #[test]
330 fn single_range_batch_delegates_to_fetch() {
331 let result = read_range_batch(
332 ureq::Agent::new(),
333 Arc::new("https://invalid.test/not-found.copc.laz".to_owned()),
334 &[(0, 1)],
335 );
336 assert!(result.is_err());
337 }
338
339 #[cfg(feature = "streaming")]
340 #[test]
341 fn bounded_http_copc_source_reads_range_served_file() {
342 use std::sync::atomic::AtomicBool;
343 use std::time::Duration;
344
345 use spatialrust_core::PointCloudBuilder;
346 use spatialrust_records::{
347 BoundedSpatialRecordSource, CancellationToken, MemoryBudget, StreamOptions,
348 };
349
350 let mut builder = PointCloudBuilder::xyz();
351 for index in 0..100 {
352 builder.push_point([index as f32, 0.0, 0.0]).unwrap();
353 }
354 let cloud = builder.build().unwrap();
355 let path = std::env::temp_dir()
356 .join(format!("spatialrust_http_stream_{}.copc.laz", std::process::id()));
357 crate::copc::write_copc_file(&path, &cloud).unwrap();
358 let payload = Arc::new(std::fs::read(&path).unwrap());
359 std::fs::remove_file(path).unwrap();
360
361 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
362 listener.set_nonblocking(true).unwrap();
363 let addr = listener.local_addr().unwrap();
364 let stopped = Arc::new(AtomicBool::new(false));
365 let stopped_server = Arc::clone(&stopped);
366 let payload_server = Arc::clone(&payload);
367 let server = std::thread::spawn(move || {
368 let requests = AtomicUsize::new(0);
369 while !stopped_server.load(Ordering::Acquire) {
370 match listener.accept() {
371 Ok((mut stream, _)) => {
372 serve_test_range(&mut stream, &payload_server, &requests);
373 }
374 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
375 std::thread::sleep(Duration::from_millis(1));
376 }
377 Err(error) => panic!("HTTP test server failed: {error}"),
378 }
379 }
380 requests.load(Ordering::SeqCst)
381 });
382
383 let url = format!("http://{addr}/cloud.copc.laz");
384 let options = StreamOptions::new(17, MemoryBudget::new(4 * 1024 * 1024).unwrap()).unwrap();
385 let mut source =
386 crate::CopcChunkSource::open_url(&url, None, options, CancellationToken::default())
387 .unwrap();
388 let mut point_count = 0;
389 while let Some(chunk) = source.next_chunk() {
390 point_count += chunk.unwrap().record().cloud().len();
391 }
392 stopped.store(true, Ordering::Release);
393 assert_eq!(point_count, 100);
394 assert!(server.join().unwrap() >= 3);
395 }
396
397 fn serve_test_range(stream: &mut TcpStream, payload: &[u8], requests: &AtomicUsize) {
398 stream.set_nonblocking(false).unwrap();
402 let mut buffer = [0_u8; 512];
403 let read = stream.read(&mut buffer).unwrap();
404 let request = std::str::from_utf8(&buffer[..read]).unwrap();
405 if request.starts_with("HEAD ") {
406 requests.fetch_add(1, Ordering::SeqCst);
407 let response = format!(
408 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n",
409 payload.len()
410 );
411 stream.write_all(response.as_bytes()).unwrap();
412 return;
413 }
414 let range = request
415 .lines()
416 .find_map(|line| line.strip_prefix("Range: bytes="))
417 .expect("missing Range header");
418 let (start, end) = range
419 .split_once('-')
420 .and_then(|(start, end)| Some((start.parse::<u64>().ok()?, end.parse::<u64>().ok()?)))
421 .expect("invalid Range header");
422 let start = start as usize;
423 let end = end as usize;
424 let body = payload[start..=end].to_vec();
425
426 requests.fetch_add(1, Ordering::SeqCst);
427 let response = format!(
428 "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{end}/{}\r\n\r\n",
429 body.len(),
430 payload.len()
431 );
432 stream.write_all(response.as_bytes()).unwrap();
433 stream.write_all(&body).unwrap();
434 }
435}