Skip to main content

oxihttp_core/
request_builder.rs

1//! Execution-free HTTP request builder for constructing `http::Request<Body>` values.
2//!
3//! Unlike the client's request builder, this builder has no network I/O — the terminal method
4//! is `.build()` which returns an `OxiRequest<Body>`. Useful for tests, server-side request
5//! forging, and as a reusable base for building requests without a live connection.
6
7use bytes::Bytes;
8use http::{HeaderMap, HeaderName, HeaderValue, Method, Uri};
9
10use crate::body::Body;
11use crate::error::OxiHttpError;
12use crate::OxiRequest;
13
14/// Execution-free HTTP request builder.
15///
16/// Constructs an `http::Request<Body>` without performing any network I/O.
17/// The terminal method is [`RequestBuilder::build`] which returns an
18/// `OxiRequest<Body>`. This is distinct from `oxihttp_client::RequestBuilder`
19/// which owns a network connection and executes requests.
20///
21/// # Example
22///
23/// ```rust
24/// use oxihttp_core::CoreRequestBuilder;
25///
26/// let req = CoreRequestBuilder::get("http://example.com/api")
27///     .expect("valid URI")
28///     .header("x-api-key", "secret")
29///     .expect("valid header")
30///     .build()
31///     .expect("valid request");
32///
33/// assert_eq!(req.method(), http::Method::GET);
34/// ```
35#[derive(Debug)]
36pub struct RequestBuilder {
37    method: Method,
38    uri: Uri,
39    headers: HeaderMap,
40    body: Body,
41}
42
43impl RequestBuilder {
44    /// Create a new builder with the given method and URI.
45    pub fn new(method: Method, uri: Uri) -> Self {
46        Self {
47            method,
48            uri,
49            headers: HeaderMap::new(),
50            body: Body::empty(),
51        }
52    }
53
54    /// Create a GET request builder.
55    pub fn get(uri: impl AsRef<str>) -> Result<Self, OxiHttpError> {
56        let uri = Uri::try_from(uri.as_ref())?;
57        Ok(Self::new(Method::GET, uri))
58    }
59
60    /// Create a POST request builder.
61    pub fn post(uri: impl AsRef<str>) -> Result<Self, OxiHttpError> {
62        let uri = Uri::try_from(uri.as_ref())?;
63        Ok(Self::new(Method::POST, uri))
64    }
65
66    /// Create a PUT request builder.
67    pub fn put(uri: impl AsRef<str>) -> Result<Self, OxiHttpError> {
68        let uri = Uri::try_from(uri.as_ref())?;
69        Ok(Self::new(Method::PUT, uri))
70    }
71
72    /// Create a DELETE request builder.
73    pub fn delete(uri: impl AsRef<str>) -> Result<Self, OxiHttpError> {
74        let uri = Uri::try_from(uri.as_ref())?;
75        Ok(Self::new(Method::DELETE, uri))
76    }
77
78    /// Create a PATCH request builder.
79    pub fn patch(uri: impl AsRef<str>) -> Result<Self, OxiHttpError> {
80        let uri = Uri::try_from(uri.as_ref())?;
81        Ok(Self::new(Method::PATCH, uri))
82    }
83
84    /// Create a HEAD request builder.
85    pub fn head(uri: impl AsRef<str>) -> Result<Self, OxiHttpError> {
86        let uri = Uri::try_from(uri.as_ref())?;
87        Ok(Self::new(Method::HEAD, uri))
88    }
89
90    /// Add a single header, replacing any existing header with the same name.
91    pub fn header(
92        mut self,
93        name: impl AsRef<str>,
94        value: impl AsRef<str>,
95    ) -> Result<Self, OxiHttpError> {
96        let name = HeaderName::try_from(name.as_ref())
97            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
98        let value = HeaderValue::try_from(value.as_ref())
99            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
100        self.headers.insert(name, value);
101        Ok(self)
102    }
103
104    /// Merge a `HeaderMap` into the request headers, replacing duplicates.
105    pub fn headers(mut self, headers: HeaderMap) -> Self {
106        for (k, v) in &headers {
107            self.headers.insert(k.clone(), v.clone());
108        }
109        self
110    }
111
112    /// Set a raw body (no `Content-Type` set automatically).
113    pub fn body(mut self, body: impl Into<Body>) -> Self {
114        self.body = body.into();
115        self
116    }
117
118    /// Set a JSON body, serialising `value` and setting `Content-Type: application/json`.
119    pub fn json<T: serde::Serialize>(mut self, value: &T) -> Result<Self, OxiHttpError> {
120        let bytes = serde_json::to_vec(value).map_err(|e| OxiHttpError::Json(e.to_string()))?;
121        self.headers.insert(
122            http::header::CONTENT_TYPE,
123            HeaderValue::from_static("application/json"),
124        );
125        self.body = Body::full(Bytes::from(bytes));
126        Ok(self)
127    }
128
129    /// Set a URL-encoded form body, setting
130    /// `Content-Type: application/x-www-form-urlencoded`.
131    ///
132    /// Takes a [`crate::FormBody`] whose `build()` is infallible, so this
133    /// method returns `Self` directly.
134    pub fn form(mut self, body: crate::FormBody) -> Self {
135        self.headers.insert(
136            http::header::CONTENT_TYPE,
137            HeaderValue::from_static("application/x-www-form-urlencoded"),
138        );
139        self.body = Body::full(body.build());
140        self
141    }
142
143    /// Build the request.
144    ///
145    /// Returns `Err` only when the accumulated headers, method, or URI are
146    /// invalid at the `http::Request` layer (which should be rare given that
147    /// the builder already validated each piece).
148    pub fn build(self) -> Result<OxiRequest<Body>, OxiHttpError> {
149        let mut builder = http::Request::builder().method(self.method).uri(self.uri);
150        for (k, v) in &self.headers {
151            builder = builder.header(k, v);
152        }
153        builder
154            .body(self.body)
155            .map_err(|e| OxiHttpError::Http(std::sync::Arc::new(e)))
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::FormBody;
163
164    #[test]
165    fn test_get_builder() {
166        let req = RequestBuilder::get("http://example.com/path")
167            .expect("valid URI")
168            .build()
169            .expect("build succeeds");
170        assert_eq!(req.method(), &Method::GET);
171        assert_eq!(req.uri().to_string(), "http://example.com/path");
172    }
173
174    #[test]
175    fn test_post_with_json() {
176        #[derive(serde::Serialize)]
177        struct Payload {
178            key: &'static str,
179        }
180
181        let req = RequestBuilder::post("http://example.com/api")
182            .expect("valid URI")
183            .json(&Payload { key: "value" })
184            .expect("serialises")
185            .build()
186            .expect("build succeeds");
187
188        assert_eq!(req.method(), &Method::POST);
189        assert_eq!(
190            req.headers()
191                .get(http::header::CONTENT_TYPE)
192                .map(|v| v.as_bytes()),
193            Some(b"application/json".as_ref()),
194        );
195    }
196
197    #[test]
198    fn test_headers_merged() {
199        let mut extra = HeaderMap::new();
200        extra.insert(
201            HeaderName::from_static("x-custom"),
202            HeaderValue::from_static("abc"),
203        );
204
205        let req = RequestBuilder::get("http://example.com")
206            .expect("valid URI")
207            .headers(extra)
208            .build()
209            .expect("build succeeds");
210
211        assert_eq!(
212            req.headers().get("x-custom").map(|v| v.as_bytes()),
213            Some(b"abc".as_ref()),
214        );
215    }
216
217    #[test]
218    fn test_form_sets_content_type() {
219        let form = FormBody::new().field("foo", "bar");
220        let req = RequestBuilder::post("http://example.com/form")
221            .expect("valid URI")
222            .form(form)
223            .build()
224            .expect("build succeeds");
225
226        assert_eq!(
227            req.headers()
228                .get(http::header::CONTENT_TYPE)
229                .map(|v| v.as_bytes()),
230            Some(b"application/x-www-form-urlencoded".as_ref()),
231        );
232    }
233
234    #[test]
235    fn test_invalid_uri_returns_error() {
236        let result = RequestBuilder::get("not a valid uri!!!!");
237        assert!(result.is_err());
238    }
239
240    #[test]
241    fn test_all_methods() {
242        let cases: &[(&str, Method)] = &[
243            ("http://a.com", Method::POST),
244            ("http://a.com", Method::PUT),
245            ("http://a.com", Method::DELETE),
246            ("http://a.com", Method::PATCH),
247            ("http://a.com", Method::HEAD),
248        ];
249        for (uri, method) in cases {
250            let req = match method.as_str() {
251                "POST" => RequestBuilder::post(uri),
252                "PUT" => RequestBuilder::put(uri),
253                "DELETE" => RequestBuilder::delete(uri),
254                "PATCH" => RequestBuilder::patch(uri),
255                "HEAD" => RequestBuilder::head(uri),
256                _ => unreachable!(),
257            }
258            .expect("valid uri")
259            .build()
260            .expect("build succeeds");
261            assert_eq!(req.method(), method);
262        }
263    }
264}