Skip to main content

origin_http/
request.rs

1use crate::Headers;
2use crate::headers::RedactedBody;
3use origin_domain::{AppError, Result};
4use serde::Serialize;
5use std::fmt;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Method {
9    Get,
10    Post,
11    Put,
12    Patch,
13    Delete,
14}
15
16impl Method {
17    pub fn as_str(self) -> &'static str {
18        match self {
19            Self::Get => "GET",
20            Self::Post => "POST",
21            Self::Put => "PUT",
22            Self::Patch => "PATCH",
23            Self::Delete => "DELETE",
24        }
25    }
26}
27
28impl fmt::Display for Method {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.write_str(self.as_str())
31    }
32}
33
34/// One outgoing request.
35///
36/// The body is already encoded bytes: encoding is the caller's decision, and keeping it
37/// out of the port means the port never needs to know about JSON, forms or multipart.
38#[derive(Clone)]
39pub struct HttpRequest {
40    pub method: Method,
41    pub url: String,
42    pub headers: Headers,
43    pub body: Option<Vec<u8>>,
44}
45
46/// Redacting `Debug`: a request body can carry an OAuth code, a refresh token or a
47/// client secret, and a derived `Debug` would print it verbatim into any log line that
48/// formats a request with `?`.
49impl fmt::Debug for HttpRequest {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.debug_struct("HttpRequest")
52            .field("method", &self.method)
53            .field("url", &self.url)
54            .field("headers", &self.headers)
55            .field(
56                "body",
57                &self.body.as_ref().map(|body| RedactedBody(body.len())),
58            )
59            .finish()
60    }
61}
62
63impl HttpRequest {
64    pub fn new(method: Method, url: impl Into<String>) -> Self {
65        Self {
66            method,
67            url: url.into(),
68            headers: Headers::new(),
69            body: None,
70        }
71    }
72
73    pub fn get(url: impl Into<String>) -> Self {
74        Self::new(Method::Get, url)
75    }
76
77    pub fn post(url: impl Into<String>) -> Self {
78        Self::new(Method::Post, url)
79    }
80
81    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
82        self.headers.insert(name, value);
83        self
84    }
85
86    /// Set a bearer token.
87    ///
88    /// Takes the raw string rather than a `Secret` so that `origin-http` stays
89    /// independent of the credential layer; callers pass `token.expose()`.
90    pub fn bearer(self, token: &str) -> Self {
91        self.header("authorization", format!("Bearer {token}"))
92    }
93
94    pub fn json<T: Serialize>(mut self, value: &T) -> Result<Self> {
95        let encoded = serde_json::to_vec(value)
96            .map_err(|error| AppError::internal(format!("cannot encode request body: {error}")))?;
97        self.headers.insert("content-type", "application/json");
98        self.body = Some(encoded);
99        Ok(self)
100    }
101
102    /// `application/x-www-form-urlencoded` body, as used by OAuth token endpoints.
103    pub fn form(mut self, fields: &[(&str, &str)]) -> Self {
104        let encoded = fields
105            .iter()
106            .map(|(key, value)| format!("{}={}", encode(key), encode(value)))
107            .collect::<Vec<_>>()
108            .join("&");
109
110        self.headers
111            .insert("content-type", "application/x-www-form-urlencoded");
112        self.body = Some(encoded.into_bytes());
113        self
114    }
115
116    /// Query string appended to the URL.
117    pub fn query(mut self, parameters: &[(&str, &str)]) -> Self {
118        if parameters.is_empty() {
119            return self;
120        }
121
122        let encoded = parameters
123            .iter()
124            .map(|(key, value)| format!("{}={}", encode(key), encode(value)))
125            .collect::<Vec<_>>()
126            .join("&");
127
128        let separator = if self.url.contains('?') { '&' } else { '?' };
129        self.url = format!("{}{separator}{encoded}", self.url);
130        self
131    }
132}
133
134/// Percent-encode for `application/x-www-form-urlencoded`.
135///
136/// Deliberately conservative: everything outside the unreserved set is escaped, so a
137/// scope string, a redirect URI or a PKCE verifier survives intact.
138pub(crate) fn encode(value: &str) -> String {
139    let mut encoded = String::with_capacity(value.len());
140    for byte in value.bytes() {
141        match byte {
142            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
143                encoded.push(byte as char);
144            }
145            b' ' => encoded.push_str("%20"),
146            other => encoded.push_str(&format!("%{other:02X}")),
147        }
148    }
149    encoded
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn query_parameters_are_percent_encoded() {
158        let request = HttpRequest::get("https://api.example.com/search")
159            .query(&[("q", "hello world"), ("scope", "repo:status")]);
160
161        assert_eq!(
162            request.url,
163            "https://api.example.com/search?q=hello%20world&scope=repo%3Astatus"
164        );
165    }
166
167    #[test]
168    fn debug_output_never_contains_the_body() {
169        let request = HttpRequest::post("https://example.com/token")
170            .form(&[("refresh_token", "rt-super-secret")]);
171
172        let rendered = format!("{request:?}");
173
174        assert!(!rendered.contains("rt-super-secret"), "got: {rendered}");
175        assert!(rendered.contains("bytes, redacted"), "got: {rendered}");
176    }
177
178    #[test]
179    fn a_request_with_no_body_shows_none() {
180        let rendered = format!("{:?}", HttpRequest::get("https://example.com"));
181        assert!(rendered.contains("body: None"), "got: {rendered}");
182    }
183
184    #[test]
185    fn query_appends_to_an_existing_query_string() {
186        let request = HttpRequest::get("https://api.example.com/x?page=2").query(&[("per", "50")]);
187        assert_eq!(request.url, "https://api.example.com/x?page=2&per=50");
188    }
189
190    #[test]
191    fn form_bodies_are_encoded_and_typed() {
192        let request = HttpRequest::post("https://example.com/token")
193            .form(&[("grant_type", "authorization_code"), ("code", "a/b+c")]);
194
195        assert_eq!(
196            request.headers.get("content-type"),
197            Some("application/x-www-form-urlencoded")
198        );
199        assert_eq!(
200            String::from_utf8(request.body.unwrap()).unwrap(),
201            "grant_type=authorization_code&code=a%2Fb%2Bc"
202        );
203    }
204}