Skip to main content

scion_http3/
request.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Requests and how they are built.
16
17use std::{
18    pin::Pin,
19    task::{Context, Poll},
20    time::Duration,
21};
22
23use bytes::Bytes;
24use http_body::Frame;
25use sciparse::address::ip_addr::ScionIpAddr;
26use url::Url;
27
28use crate::error::BuildRequestError;
29
30/// A type that can be converted into a [`Url`].
31///
32/// Implemented for [`Url`] itself and for strings, so URLs can be passed as
33/// `"https://example.org/path"` without an explicit parse step. This trait is
34/// sealed, i.e., it cannot be implemented outside this crate.
35pub trait IntoUrl: sealed::Sealed {
36    /// Converts the value into a parsed [`Url`].
37    fn into_url(self) -> Result<Url, BuildRequestError>;
38}
39
40impl IntoUrl for Url {
41    fn into_url(self) -> Result<Url, BuildRequestError> {
42        Ok(self)
43    }
44}
45
46impl IntoUrl for &str {
47    fn into_url(self) -> Result<Url, BuildRequestError> {
48        Url::parse(self).map_err(|e| {
49            BuildRequestError::InvalidUrl {
50                source: Box::new(e),
51            }
52        })
53    }
54}
55
56impl IntoUrl for String {
57    fn into_url(self) -> Result<Url, BuildRequestError> {
58        self.as_str().into_url()
59    }
60}
61
62impl IntoUrl for &String {
63    fn into_url(self) -> Result<Url, BuildRequestError> {
64        self.as_str().into_url()
65    }
66}
67
68mod sealed {
69    pub trait Sealed {}
70    impl Sealed for url::Url {}
71    impl Sealed for &str {}
72    impl Sealed for String {}
73    impl Sealed for &String {}
74}
75
76/// An HTTP request: method, URL, headers, and a buffered body.
77///
78/// Built with [`Request::builder`] (or the [`get`](Request::get) /
79/// [`post`](Request::post) shorthands) and issued with
80/// [`Client::request`](crate::Client::request). A validated request always
81/// has an `https` URL with a host.
82#[derive(Debug, Clone)]
83pub struct Request {
84    method: http::Method,
85    url: Url,
86    headers: http::HeaderMap,
87    body: Bytes,
88    targets: Option<Vec<ScionIpAddr>>,
89    timeout: Option<Duration>,
90}
91
92impl Request {
93    /// Returns a builder with no fields set. The URL is the only required
94    /// field.
95    #[must_use]
96    pub fn builder() -> RequestBuilder {
97        RequestBuilder::new()
98    }
99
100    /// Returns a builder for a GET request to `url`.
101    #[must_use]
102    pub fn get(url: impl IntoUrl) -> RequestBuilder {
103        RequestBuilder::new().method(http::Method::GET).url(url)
104    }
105
106    /// Returns a builder for a POST request to `url`.
107    #[must_use]
108    pub fn post(url: impl IntoUrl) -> RequestBuilder {
109        RequestBuilder::new().method(http::Method::POST).url(url)
110    }
111
112    /// The request method.
113    #[must_use]
114    pub fn method(&self) -> &http::Method {
115        &self.method
116    }
117
118    /// The request URL.
119    #[must_use]
120    pub fn url(&self) -> &Url {
121        &self.url
122    }
123
124    /// The request headers.
125    #[must_use]
126    pub fn headers(&self) -> &http::HeaderMap {
127        &self.headers
128    }
129
130    /// The request body.
131    #[must_use]
132    pub fn body(&self) -> &Bytes {
133        &self.body
134    }
135
136    /// The caller-supplied candidate addresses, if any (see
137    /// [`RequestBuilder::targets`]). Sorted and de-duplicated.
138    #[must_use]
139    pub fn targets(&self) -> Option<&[ScionIpAddr]> {
140        self.targets.as_deref()
141    }
142
143    /// The per-request timeout override, if any.
144    #[must_use]
145    pub fn request_timeout(&self) -> Option<Duration> {
146        self.timeout
147    }
148
149    /// Builds the transport-level `http::Request`. The URI is the full URL,
150    /// so `:authority` reflects where the request is addressed.
151    pub(crate) fn to_http(&self) -> Result<http::Request<BufferedBody>, crate::Error> {
152        let mut builder = http::Request::builder()
153            .method(self.method.clone())
154            .uri(self.url.as_str());
155        if let Some(headers) = builder.headers_mut() {
156            headers.extend(self.headers.clone());
157        }
158        builder
159            .body(BufferedBody::new(self.body.clone()))
160            .map_err(|e| {
161                crate::Error::InvalidRequest {
162                    reason: e.to_string().into(),
163                }
164            })
165    }
166}
167
168/// Builder for a [`Request`].
169///
170/// Setters are infallible; errors (an unparsable URL, an invalid header) are
171/// deferred and reported by [`build`](Self::build), mirroring
172/// `http::request::Builder`. The first error wins.
173#[derive(Debug)]
174pub struct RequestBuilder {
175    method: http::Method,
176    url: Option<Url>,
177    headers: http::HeaderMap,
178    body: Bytes,
179    targets: Option<Vec<ScionIpAddr>>,
180    timeout: Option<Duration>,
181    error: Option<BuildRequestError>,
182}
183
184impl RequestBuilder {
185    fn new() -> Self {
186        RequestBuilder {
187            method: http::Method::GET,
188            url: None,
189            headers: http::HeaderMap::new(),
190            body: Bytes::new(),
191            targets: None,
192            timeout: None,
193            error: None,
194        }
195    }
196
197    /// Sets the request method (default: GET).
198    #[must_use]
199    pub fn method(mut self, method: http::Method) -> Self {
200        self.method = method;
201        self
202    }
203
204    /// Sets the request URL. Required; the scheme must be `https`.
205    #[must_use]
206    pub fn url(mut self, url: impl IntoUrl) -> Self {
207        match url.into_url() {
208            Ok(url) => self.url = Some(url),
209            Err(e) => {
210                self.error.get_or_insert(e);
211            }
212        }
213        self
214    }
215
216    /// Appends a header.
217    #[must_use]
218    pub fn header<K, V>(mut self, key: K, value: V) -> Self
219    where
220        K: TryInto<http::header::HeaderName>,
221        K::Error: Into<http::Error>,
222        V: TryInto<http::header::HeaderValue>,
223        V::Error: Into<http::Error>,
224    {
225        match (key.try_into(), value.try_into()) {
226            (Ok(key), Ok(value)) => {
227                self.headers.append(key, value);
228            }
229            (Err(e), _) => {
230                self.error.get_or_insert(BuildRequestError::InvalidHeader {
231                    source: Box::new(e.into()),
232                });
233            }
234            (_, Err(e)) => {
235                self.error.get_or_insert(BuildRequestError::InvalidHeader {
236                    source: Box::new(e.into()),
237                });
238            }
239        }
240        self
241    }
242
243    /// Sets the request body. Bodies are buffered; they are sent in full and
244    /// can be replayed if connection establishment has to be repeated.
245    #[must_use]
246    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
247        self.body = body.into();
248        self
249    }
250
251    /// Bypasses DNS resolution: connect to `addr`, as if resolution had
252    /// returned exactly this address. The address carries no port — the port
253    /// always comes from the URL. The URL's host remains the server name for SNI
254    /// and certificate validation.
255    ///
256    /// This is the one-element case of [`targets`](Self::targets).
257    #[must_use]
258    pub fn target(self, addr: ScionIpAddr) -> Self {
259        self.targets(vec![addr])
260    }
261
262    /// Supplies the result of a resolution the caller performed itself:
263    /// connect to any of `addrs`, as if DNS had returned exactly this list.
264    /// The caller asserts the addresses are equivalent servers for the URL's
265    /// origin; certificate validation against the URL's host enforces it.
266    ///
267    /// Like [`target`](Self::target), the addresses carry no port. The list
268    /// is sorted and de-duplicated, so element order does not affect
269    /// connection reuse. It must not be empty.
270    #[must_use]
271    pub fn targets(mut self, mut addrs: Vec<ScionIpAddr>) -> Self {
272        if addrs.is_empty() {
273            self.error.get_or_insert(BuildRequestError::EmptyTargets);
274            return self;
275        }
276        addrs.sort_unstable();
277        addrs.dedup();
278        self.targets = Some(addrs);
279        self
280    }
281
282    /// Overrides the client's request timeout for this request.
283    ///
284    /// One deadline covering establishment, the response head, and body
285    /// collection; raise it for a response the caller will not collect right
286    /// away (see [`Response`](crate::Response)).
287    #[must_use]
288    pub fn request_timeout(mut self, timeout: Duration) -> Self {
289        self.timeout = Some(timeout);
290        self
291    }
292
293    /// Validates the accumulated state and builds the [`Request`].
294    pub fn build(self) -> Result<Request, BuildRequestError> {
295        if let Some(error) = self.error {
296            return Err(error);
297        }
298        let url = self.url.ok_or(BuildRequestError::MissingUrl)?;
299        if url.scheme() != "https" {
300            return Err(BuildRequestError::UnsupportedScheme {
301                scheme: url.scheme().to_string(),
302            });
303        }
304        if url.host_str().is_none() {
305            return Err(BuildRequestError::MissingHost);
306        }
307        // The URL becomes the request URI verbatim, and `:authority` must not
308        // carry userinfo (RFC 9114 §4.3.1) — sending it would leak the
309        // credentials on the wire.
310        if !url.username().is_empty() || url.password().is_some() {
311            return Err(BuildRequestError::UserinfoNotAllowed);
312        }
313        Ok(Request {
314            method: self.method,
315            url,
316            headers: self.headers,
317            body: self.body,
318            targets: self.targets,
319            timeout: self.timeout,
320        })
321    }
322}
323
324/// A fully buffered request body: yields one data frame and ends.
325pub(crate) struct BufferedBody {
326    data: Option<Bytes>,
327}
328
329impl BufferedBody {
330    fn new(data: Bytes) -> Self {
331        let data = if data.is_empty() { None } else { Some(data) };
332        BufferedBody { data }
333    }
334}
335
336impl http_body::Body for BufferedBody {
337    type Data = Bytes;
338    type Error = std::convert::Infallible;
339
340    fn poll_frame(
341        self: Pin<&mut Self>,
342        _cx: &mut Context<'_>,
343    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
344        Poll::Ready(self.get_mut().data.take().map(|data| Ok(Frame::data(data))))
345    }
346
347    fn is_end_stream(&self) -> bool {
348        self.data.is_none()
349    }
350
351    fn size_hint(&self) -> http_body::SizeHint {
352        let len = self.data.as_ref().map_or(0, Bytes::len) as u64;
353        http_body::SizeHint::with_exact(len)
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use std::net::Ipv4Addr;
360
361    use super::*;
362
363    fn addr(last_octet: u8) -> ScionIpAddr {
364        ScionIpAddr::new(
365            "1-ff00:0:110".parse().unwrap(),
366            Ipv4Addr::new(10, 0, 0, last_octet).into(),
367        )
368    }
369
370    #[test]
371    fn get_builds_with_defaults() {
372        let req = Request::get("https://chat.example.org/rooms")
373            .build()
374            .unwrap();
375        assert_eq!(req.method(), http::Method::GET);
376        assert_eq!(req.url().as_str(), "https://chat.example.org/rooms");
377        assert!(req.targets().is_none());
378        assert!(req.request_timeout().is_none());
379        assert!(req.body().is_empty());
380    }
381
382    #[test]
383    fn non_https_scheme_is_rejected() {
384        let err = Request::get("http://chat.example.org/")
385            .build()
386            .unwrap_err();
387        assert!(matches!(
388            err,
389            BuildRequestError::UnsupportedScheme { scheme } if scheme == "http"
390        ));
391    }
392
393    #[test]
394    fn userinfo_is_rejected() {
395        for url in [
396            "https://user:secret@api.example.org/x",
397            "https://user@api.example.org/x",
398        ] {
399            let err = Request::get(url).build().unwrap_err();
400            assert!(
401                matches!(err, BuildRequestError::UserinfoNotAllowed),
402                "{url}"
403            );
404        }
405    }
406
407    #[test]
408    fn unparsable_url_is_reported_at_build() {
409        let err = Request::get("not a url").build().unwrap_err();
410        assert!(matches!(err, BuildRequestError::InvalidUrl { .. }));
411    }
412
413    #[test]
414    fn missing_url_is_reported_at_build() {
415        let err = Request::builder().build().unwrap_err();
416        assert!(matches!(err, BuildRequestError::MissingUrl));
417    }
418
419    #[test]
420    fn invalid_header_is_deferred_to_build() {
421        let err = Request::get("https://example.org/")
422            .header("bad header name", "value")
423            .build()
424            .unwrap_err();
425        assert!(matches!(err, BuildRequestError::InvalidHeader { .. }));
426    }
427
428    #[test]
429    fn targets_are_sorted_and_deduplicated() {
430        let req = Request::get("https://example.org/")
431            .targets(vec![addr(2), addr(1), addr(2)])
432            .build()
433            .unwrap();
434        assert_eq!(req.targets(), Some(&[addr(1), addr(2)][..]));
435    }
436
437    #[test]
438    fn empty_targets_are_rejected() {
439        let err = Request::get("https://example.org/")
440            .targets(vec![])
441            .build()
442            .unwrap_err();
443        assert!(matches!(err, BuildRequestError::EmptyTargets));
444    }
445
446    #[test]
447    fn to_http_uses_the_full_url_as_uri() {
448        let req = Request::post("https://example.org:8443/echo")
449            .header("content-type", "text/plain")
450            .body("hello")
451            .build()
452            .unwrap();
453        let http_req = req.to_http().unwrap();
454        assert_eq!(http_req.uri(), "https://example.org:8443/echo");
455        assert_eq!(
456            http_req.uri().authority().map(http::uri::Authority::as_str),
457            Some("example.org:8443")
458        );
459        assert_eq!(http_req.headers()["content-type"], "text/plain");
460    }
461}