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#[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
46impl 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 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 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 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
134pub(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}