Skip to main content

oxideav_http/
lib.rs

1//! HTTP/HTTPS source driver for oxideav.
2//!
3//! Implements `Read + Seek` over HTTP `Range` requests so any container
4//! demuxer that takes a `Box<dyn BytesSource>` can read directly from a
5//! URL. Servers must support `Range: bytes=…` (most static-file hosts
6//! do; we verify with a `HEAD` at construction).
7//!
8//! Wire it into a [`oxideav_core::RuntimeContext`] with [`register`]:
9//! both `http` and `https` register as bytes-shape sources, so
10//! `ctx.sources.open(uri)` yields `SourceOutput::Bytes(_)`. For callers
11//! that hold a bare [`oxideav_source::SourceRegistry`] use
12//! [`register_source`] directly.
13//!
14//! ```no_run
15//! let mut ctx = oxideav_core::RuntimeContext::new();
16//! oxideav_http::register(&mut ctx);
17//! let _r = ctx.sources.open("https://example.com/clip.mp4").unwrap();
18//! ```
19
20use std::io::{self, Read, Seek, SeekFrom};
21use std::sync::OnceLock;
22
23use oxideav_core::BytesSource;
24use oxideav_core::RuntimeContext;
25use oxideav_core::{Error, Result};
26use oxideav_source::SourceRegistry;
27use ureq::Agent;
28
29/// Install the `http` + `https` schemes into a full runtime context.
30///
31/// This is the unified entry point every sibling crate exposes; it
32/// just forwards to [`register_source`] on `ctx.sources`.
33pub fn register(ctx: &mut RuntimeContext) {
34    register_source(&mut ctx.sources);
35}
36
37oxideav_core::register!("http", register);
38
39/// Register the `http` and `https` schemes on a bare
40/// [`SourceRegistry`] as bytes sources. Both schemes share the same
41/// opener (`open_http`).
42///
43/// Most callers should use [`register`] instead — it threads through a
44/// full [`RuntimeContext`].
45pub fn register_source(registry: &mut SourceRegistry) {
46    registry.register_bytes("http", open_http);
47    registry.register_bytes("https", open_http);
48}
49
50/// Open a URL as a seekable byte source.
51pub fn open_http(uri: &str) -> Result<Box<dyn BytesSource>> {
52    let src = HttpSource::open(uri)?;
53    Ok(Box::new(src))
54}
55
56fn agent() -> &'static Agent {
57    static A: OnceLock<Agent> = OnceLock::new();
58    A.get_or_init(|| Agent::config_builder().build().new_agent())
59}
60
61/// `ReadSeek` over an HTTP/HTTPS resource, using `Range` requests.
62pub struct HttpSource {
63    uri: String,
64    total_len: u64,
65    pos: u64,
66    /// Active response body for the current contiguous read run, if any.
67    body: Option<Box<dyn Read + Send>>,
68}
69
70impl HttpSource {
71    pub fn open(uri: &str) -> Result<Self> {
72        let head = agent()
73            .head(uri)
74            .call()
75            .map_err(|e| Error::other(format!("HTTP HEAD {uri}: {e}")))?;
76
77        let status = head.status();
78        if !status.is_success() {
79            return Err(Error::other(format!("HTTP HEAD {uri}: status {status}")));
80        }
81        let headers = head.headers();
82        let total_len = headers
83            .get("content-length")
84            .and_then(|v| v.to_str().ok())
85            .and_then(|s| s.parse::<u64>().ok())
86            .ok_or_else(|| {
87                Error::Unsupported(format!("HTTP HEAD {uri}: missing Content-Length"))
88            })?;
89        let accept_ranges = headers
90            .get("accept-ranges")
91            .and_then(|v| v.to_str().ok())
92            .unwrap_or("");
93        if !accept_ranges.eq_ignore_ascii_case("bytes") {
94            return Err(Error::Unsupported(format!(
95                "HTTP HEAD {uri}: server does not advertise byte ranges (Accept-Ranges: '{accept_ranges}')"
96            )));
97        }
98        Ok(Self {
99            uri: uri.to_owned(),
100            total_len,
101            pos: 0,
102            body: None,
103        })
104    }
105
106    pub fn len(&self) -> u64 {
107        self.total_len
108    }
109
110    pub fn is_empty(&self) -> bool {
111        self.total_len == 0
112    }
113
114    fn issue_range(&mut self) -> io::Result<()> {
115        if self.pos >= self.total_len {
116            self.body = None;
117            return Ok(());
118        }
119        let range = format!("bytes={}-", self.pos);
120        let resp = agent()
121            .get(&self.uri)
122            .header("Range", &range)
123            .call()
124            .map_err(|e| io::Error::other(format!("HTTP GET {} {}: {e}", self.uri, range)))?;
125        let status = resp.status();
126        if !(status == 206 || status == 200) {
127            return Err(io::Error::other(format!(
128                "HTTP GET {} {}: status {status}",
129                self.uri, range
130            )));
131        }
132        // ureq 3: Body owns the stream; into_body().into_reader() yields
133        // a `Read` that pulls from the wire as bytes are requested.
134        let reader = resp.into_body().into_reader();
135        self.body = Some(Box::new(reader));
136        Ok(())
137    }
138}
139
140impl Read for HttpSource {
141    fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
142        if out.is_empty() {
143            return Ok(0);
144        }
145        if self.pos >= self.total_len {
146            return Ok(0);
147        }
148        loop {
149            if self.body.is_none() {
150                self.issue_range()?;
151            }
152            let body = self.body.as_mut().expect("body just issued");
153            let n = body.read(out)?;
154            if n > 0 {
155                self.pos += n as u64;
156                return Ok(n);
157            }
158            self.body = None;
159            if self.pos >= self.total_len {
160                return Ok(0);
161            }
162        }
163    }
164}
165
166impl Seek for HttpSource {
167    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
168        let new_pos: u64 = match from {
169            SeekFrom::Start(n) => n,
170            SeekFrom::Current(d) => add_signed(self.pos, d)?,
171            SeekFrom::End(d) => add_signed(self.total_len, d)?,
172        };
173        if new_pos > self.total_len {
174            return Err(io::Error::new(io::ErrorKind::InvalidInput, "seek past end"));
175        }
176        if new_pos != self.pos {
177            self.body = None;
178            self.pos = new_pos;
179        }
180        Ok(new_pos)
181    }
182}
183
184fn add_signed(base: u64, delta: i64) -> io::Result<u64> {
185    if delta >= 0 {
186        base.checked_add(delta as u64)
187            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek overflow"))
188    } else {
189        base.checked_sub(delta.unsigned_abs())
190            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek before start"))
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use std::collections::BTreeSet;
198
199    #[test]
200    fn register_via_runtime_context_installs_http_and_https_schemes() {
201        let mut ctx = RuntimeContext::new();
202        register(&mut ctx);
203        let schemes: BTreeSet<&str> = ctx.sources.schemes().collect();
204        assert!(
205            schemes.contains("http"),
206            "register did not install http; got {schemes:?}"
207        );
208        assert!(
209            schemes.contains("https"),
210            "register did not install https; got {schemes:?}"
211        );
212    }
213
214    #[test]
215    fn register_source_installs_http_and_https_schemes_on_bare_registry() {
216        let mut reg = SourceRegistry::new();
217        register_source(&mut reg);
218        let schemes: BTreeSet<&str> = reg.schemes().collect();
219        assert!(schemes.contains("http"));
220        assert!(schemes.contains("https"));
221    }
222}