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 url::Url;
26
27use crate::error::BuildRequestError;
28
29/// A type that can be converted into a [`Url`].
30///
31/// Implemented for [`Url`] itself and for strings, so URLs can be passed as
32/// `"https://example.org/path"` without an explicit parse step. This trait is
33/// sealed, i.e., it cannot be implemented outside this crate.
34pub trait IntoUrl: sealed::Sealed {
35    /// Converts the value into a parsed [`Url`].
36    fn into_url(self) -> Result<Url, BuildRequestError>;
37}
38
39impl IntoUrl for Url {
40    fn into_url(self) -> Result<Url, BuildRequestError> {
41        Ok(self)
42    }
43}
44
45impl IntoUrl for &str {
46    fn into_url(self) -> Result<Url, BuildRequestError> {
47        Url::parse(self).map_err(|e| {
48            BuildRequestError::InvalidUrl {
49                source: Box::new(e),
50            }
51        })
52    }
53}
54
55impl IntoUrl for String {
56    fn into_url(self) -> Result<Url, BuildRequestError> {
57        self.as_str().into_url()
58    }
59}
60
61impl IntoUrl for &String {
62    fn into_url(self) -> Result<Url, BuildRequestError> {
63        self.as_str().into_url()
64    }
65}
66
67mod sealed {
68    pub trait Sealed {}
69    impl Sealed for url::Url {}
70    impl Sealed for &str {}
71    impl Sealed for String {}
72    impl Sealed for &String {}
73}
74
75/// An HTTP request: method, URL, headers, and a buffered body.
76///
77/// Built with [`Request::builder`] (or the [`get`](Request::get) /
78/// [`post`](Request::post) shorthands) and issued with
79/// [`Client::request`](crate::Client::request). A validated request always
80/// has an `https` URL with a host.
81#[derive(Debug, Clone)]
82pub struct Request {
83    method: http::Method,
84    url: Url,
85    headers: http::HeaderMap,
86    body: Bytes,
87    timeout: Option<Duration>,
88}
89
90impl Request {
91    /// Returns a builder with no fields set. The URL is the only required
92    /// field.
93    #[must_use]
94    pub fn builder() -> RequestBuilder {
95        RequestBuilder::new()
96    }
97
98    /// Returns a builder for a GET request to `url`.
99    #[must_use]
100    pub fn get(url: impl IntoUrl) -> RequestBuilder {
101        RequestBuilder::new().method(http::Method::GET).url(url)
102    }
103
104    /// Returns a builder for a POST request to `url`.
105    #[must_use]
106    pub fn post(url: impl IntoUrl) -> RequestBuilder {
107        RequestBuilder::new().method(http::Method::POST).url(url)
108    }
109
110    /// The request method.
111    #[must_use]
112    pub fn method(&self) -> &http::Method {
113        &self.method
114    }
115
116    /// The request URL.
117    #[must_use]
118    pub fn url(&self) -> &Url {
119        &self.url
120    }
121
122    /// The request headers.
123    #[must_use]
124    pub fn headers(&self) -> &http::HeaderMap {
125        &self.headers
126    }
127
128    /// The request body.
129    #[must_use]
130    pub fn body(&self) -> &Bytes {
131        &self.body
132    }
133
134    /// The per-request timeout override, if any.
135    #[must_use]
136    pub fn request_timeout(&self) -> Option<Duration> {
137        self.timeout
138    }
139
140    /// Builds the transport-level `http::Request`. The URI is the full URL,
141    /// so `:authority` reflects where the request is addressed.
142    pub(crate) fn to_http(&self) -> Result<http::Request<BufferedBody>, crate::Error> {
143        let mut builder = http::Request::builder()
144            .method(self.method.clone())
145            .uri(self.url.as_str());
146        if let Some(headers) = builder.headers_mut() {
147            headers.extend(self.headers.clone());
148        }
149        builder
150            .body(BufferedBody::new(self.body.clone()))
151            .map_err(|e| {
152                crate::Error::InvalidRequest {
153                    reason: e.to_string().into(),
154                }
155            })
156    }
157}
158
159/// Builder for a [`Request`].
160///
161/// Setters are infallible; errors (an unparsable URL, an invalid header) are
162/// deferred and reported by [`build`](Self::build), mirroring
163/// `http::request::Builder`. The first error wins.
164#[derive(Debug)]
165pub struct RequestBuilder {
166    method: http::Method,
167    url: Option<Url>,
168    headers: http::HeaderMap,
169    body: Bytes,
170    timeout: Option<Duration>,
171    error: Option<BuildRequestError>,
172}
173
174impl RequestBuilder {
175    fn new() -> Self {
176        RequestBuilder {
177            method: http::Method::GET,
178            url: None,
179            headers: http::HeaderMap::new(),
180            body: Bytes::new(),
181            timeout: None,
182            error: None,
183        }
184    }
185
186    /// Sets the request method (default: GET).
187    #[must_use]
188    pub fn method(mut self, method: http::Method) -> Self {
189        self.method = method;
190        self
191    }
192
193    /// Sets the request URL. Required; the scheme must be `https`.
194    #[must_use]
195    pub fn url(mut self, url: impl IntoUrl) -> Self {
196        match url.into_url() {
197            Ok(url) => self.url = Some(url),
198            Err(e) => {
199                self.error.get_or_insert(e);
200            }
201        }
202        self
203    }
204
205    /// Appends a header.
206    #[must_use]
207    pub fn header<K, V>(mut self, key: K, value: V) -> Self
208    where
209        K: TryInto<http::header::HeaderName>,
210        K::Error: Into<http::Error>,
211        V: TryInto<http::header::HeaderValue>,
212        V::Error: Into<http::Error>,
213    {
214        match (key.try_into(), value.try_into()) {
215            (Ok(key), Ok(value)) => {
216                self.headers.append(key, value);
217            }
218            (Err(e), _) => {
219                self.error.get_or_insert(BuildRequestError::InvalidHeader {
220                    source: Box::new(e.into()),
221                });
222            }
223            (_, Err(e)) => {
224                self.error.get_or_insert(BuildRequestError::InvalidHeader {
225                    source: Box::new(e.into()),
226                });
227            }
228        }
229        self
230    }
231
232    /// Sets the request body. Bodies are buffered; they are sent in full and
233    /// can be replayed if connection establishment has to be repeated.
234    #[must_use]
235    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
236        self.body = body.into();
237        self
238    }
239
240    /// Overrides the client's request timeout for this request.
241    ///
242    /// One deadline covering establishment, the response head, and body
243    /// collection; raise it for a response the caller will not collect right
244    /// away (see [`Response`](crate::Response)).
245    #[must_use]
246    pub fn request_timeout(mut self, timeout: Duration) -> Self {
247        self.timeout = Some(timeout);
248        self
249    }
250
251    /// Validates the accumulated state and builds the [`Request`].
252    pub fn build(self) -> Result<Request, BuildRequestError> {
253        if let Some(error) = self.error {
254            return Err(error);
255        }
256        let url = self.url.ok_or(BuildRequestError::MissingUrl)?;
257        if url.scheme() != "https" {
258            return Err(BuildRequestError::UnsupportedScheme {
259                scheme: url.scheme().to_string(),
260            });
261        }
262        if url.host_str().is_none() {
263            return Err(BuildRequestError::MissingHost);
264        }
265        // The URL becomes the request URI verbatim, and `:authority` must not
266        // carry userinfo (RFC 9114 §4.3.1) — sending it would leak the
267        // credentials on the wire.
268        if !url.username().is_empty() || url.password().is_some() {
269            return Err(BuildRequestError::UserinfoNotAllowed);
270        }
271        Ok(Request {
272            method: self.method,
273            url,
274            headers: self.headers,
275            body: self.body,
276            timeout: self.timeout,
277        })
278    }
279}
280
281/// A fully buffered request body: yields one data frame and ends.
282pub(crate) struct BufferedBody {
283    data: Option<Bytes>,
284}
285
286impl BufferedBody {
287    fn new(data: Bytes) -> Self {
288        let data = if data.is_empty() { None } else { Some(data) };
289        BufferedBody { data }
290    }
291}
292
293impl http_body::Body for BufferedBody {
294    type Data = Bytes;
295    type Error = std::convert::Infallible;
296
297    fn poll_frame(
298        self: Pin<&mut Self>,
299        _cx: &mut Context<'_>,
300    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
301        Poll::Ready(self.get_mut().data.take().map(|data| Ok(Frame::data(data))))
302    }
303
304    fn is_end_stream(&self) -> bool {
305        self.data.is_none()
306    }
307
308    fn size_hint(&self) -> http_body::SizeHint {
309        let len = self.data.as_ref().map_or(0, Bytes::len) as u64;
310        http_body::SizeHint::with_exact(len)
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn get_builds_with_defaults() {
320        let req = Request::get("https://chat.example.org/rooms")
321            .build()
322            .unwrap();
323        assert_eq!(req.method(), http::Method::GET);
324        assert_eq!(req.url().as_str(), "https://chat.example.org/rooms");
325        assert!(req.request_timeout().is_none());
326        assert!(req.body().is_empty());
327    }
328
329    #[test]
330    fn non_https_scheme_is_rejected() {
331        let err = Request::get("http://chat.example.org/")
332            .build()
333            .unwrap_err();
334        assert!(matches!(
335            err,
336            BuildRequestError::UnsupportedScheme { scheme } if scheme == "http"
337        ));
338    }
339
340    #[test]
341    fn userinfo_is_rejected() {
342        for url in [
343            "https://user:secret@api.example.org/x",
344            "https://user@api.example.org/x",
345        ] {
346            let err = Request::get(url).build().unwrap_err();
347            assert!(
348                matches!(err, BuildRequestError::UserinfoNotAllowed),
349                "{url}"
350            );
351        }
352    }
353
354    #[test]
355    fn unparsable_url_is_reported_at_build() {
356        let err = Request::get("not a url").build().unwrap_err();
357        assert!(matches!(err, BuildRequestError::InvalidUrl { .. }));
358    }
359
360    #[test]
361    fn missing_url_is_reported_at_build() {
362        let err = Request::builder().build().unwrap_err();
363        assert!(matches!(err, BuildRequestError::MissingUrl));
364    }
365
366    #[test]
367    fn invalid_header_is_deferred_to_build() {
368        let err = Request::get("https://example.org/")
369            .header("bad header name", "value")
370            .build()
371            .unwrap_err();
372        assert!(matches!(err, BuildRequestError::InvalidHeader { .. }));
373    }
374
375    #[test]
376    fn to_http_uses_the_full_url_as_uri() {
377        let req = Request::post("https://example.org:8443/echo")
378            .header("content-type", "text/plain")
379            .body("hello")
380            .build()
381            .unwrap();
382        let http_req = req.to_http().unwrap();
383        assert_eq!(http_req.uri(), "https://example.org:8443/echo");
384        assert_eq!(
385            http_req.uri().authority().map(http::uri::Authority::as_str),
386            Some("example.org:8443")
387        );
388        assert_eq!(http_req.headers()["content-type"], "text/plain");
389    }
390}