Skip to main content

rust_web_server/test_client/
mod.rs

1#[cfg(test)]
2mod tests;
3
4use crate::application::Application;
5use crate::header::Header;
6use crate::http::VERSION;
7use crate::mime_type::MimeType;
8use crate::range::Range;
9use crate::request::{METHOD, Request};
10use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
11use crate::server::{Address, ConnectionInfo};
12
13/// An in-process HTTP test client that dispatches requests directly through an
14/// [`Application`] without opening a TCP socket.
15///
16/// Use this in unit and integration tests to exercise controllers and routing
17/// without starting the server.
18///
19/// # Example
20///
21/// ```rust,no_run
22/// use rust_web_server::app::App;
23/// use rust_web_server::core::New;
24/// use rust_web_server::test_client::TestClient;
25///
26/// let client = TestClient::new(App::new());
27///
28/// let res = client.get("/healthz").send();
29/// assert_eq!(200, res.status());
30///
31/// let res = client.post("/echo")
32///     .header("Content-Type", "text/plain")
33///     .body_text("hello")
34///     .send();
35/// assert_eq!(200, res.status());
36/// ```
37pub struct TestClient<A: Application> {
38    app: A,
39    connection: ConnectionInfo,
40}
41
42impl<A: Application> TestClient<A> {
43    /// Create a test client wrapping `app`. Requests are dispatched on a
44    /// synthetic `127.0.0.1:12345 → 127.0.0.1:7878` connection.
45    pub fn new(app: A) -> Self {
46        TestClient {
47            app,
48            connection: ConnectionInfo {
49                client: Address { ip: "127.0.0.1".to_string(), port: 12345 },
50                server: Address { ip: "127.0.0.1".to_string(), port: 7878 },
51                request_size: 16000,
52                sni_hostname: None,
53                tls: false,
54            },
55        }
56    }
57
58    /// Build a `GET` request to `path`.
59    pub fn get(&self, path: &str) -> TestRequest<'_, A> {
60        TestRequest::new(METHOD.get.to_string(), path, self)
61    }
62
63    /// Build a `POST` request to `path`.
64    pub fn post(&self, path: &str) -> TestRequest<'_, A> {
65        TestRequest::new(METHOD.post.to_string(), path, self)
66    }
67
68    /// Build a `PUT` request to `path`.
69    pub fn put(&self, path: &str) -> TestRequest<'_, A> {
70        TestRequest::new(METHOD.put.to_string(), path, self)
71    }
72
73    /// Build a `PATCH` request to `path`.
74    pub fn patch(&self, path: &str) -> TestRequest<'_, A> {
75        TestRequest::new(METHOD.patch.to_string(), path, self)
76    }
77
78    /// Build a `DELETE` request to `path`.
79    pub fn delete(&self, path: &str) -> TestRequest<'_, A> {
80        TestRequest::new(METHOD.delete.to_string(), path, self)
81    }
82
83    /// Build an `OPTIONS` request to `path`.
84    pub fn options(&self, path: &str) -> TestRequest<'_, A> {
85        TestRequest::new(METHOD.options.to_string(), path, self)
86    }
87}
88
89/// A pending test request. Chain builder methods then call [`TestRequest::send`].
90pub struct TestRequest<'a, A: Application> {
91    method: String,
92    path: String,
93    headers: Vec<Header>,
94    body: Vec<u8>,
95    client: &'a TestClient<A>,
96}
97
98impl<'a, A: Application> TestRequest<'a, A> {
99    fn new(method: String, path: &str, client: &'a TestClient<A>) -> Self {
100        TestRequest {
101            method,
102            path: path.to_string(),
103            headers: vec![],
104            body: Default::default(),
105            client,
106        }
107    }
108
109    /// Add a request header.
110    pub fn header(mut self, name: &str, value: &str) -> Self {
111        self.headers.push(Header { name: name.to_string().into(), value: value.to_string().into() });
112        self
113    }
114
115    /// Set the request body to raw bytes.
116    pub fn body_bytes(mut self, body: Vec<u8>) -> Self {
117        self.body = body;
118        self
119    }
120
121    /// Set the request body to a UTF-8 string.
122    pub fn body_text(mut self, text: &str) -> Self {
123        self.body = text.as_bytes().to_vec();
124        self
125    }
126
127    /// Set the request body to a JSON string, with
128    /// `Content-Type: application/json`.
129    pub fn body_json(mut self, json: &str) -> Self {
130        self.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: "application/json".into() });
131        self.body = json.as_bytes().to_vec();
132        self
133    }
134
135    /// Dispatch the request and return the response.
136    pub fn send(self) -> TestResponse {
137        let request = Request {
138            method: self.method,
139            request_uri: self.path,
140            http_version: VERSION.http_1_1.to_string(),
141            headers: self.headers,
142            body: self.body.into(),
143        };
144
145        let response = self.client.app.execute(&request, &self.client.connection)
146            .unwrap_or_else(|msg| {
147                let dummy = Request {
148                    method: "GET".to_string(),
149                    request_uri: "/".to_string(),
150                    http_version: VERSION.http_1_1.to_string(),
151                    headers: vec![],
152                    body: Default::default(),
153                };
154                let header_list = Header::get_header_list(&dummy);
155                let body = msg.into_bytes();
156                let cr = Range::get_content_range(body, MimeType::TEXT_PLAIN.to_string());
157                Response::get_response(
158                    STATUS_CODE_REASON_PHRASE.n500_internal_server_error,
159                    Some(header_list),
160                    Some(vec![cr]),
161                )
162            });
163
164        TestResponse::from_response(response)
165    }
166}
167
168/// The result of a dispatched test request.
169pub struct TestResponse {
170    status: i16,
171    reason: String,
172    headers: Vec<Header>,
173    body: Vec<u8>,
174}
175
176impl TestResponse {
177    fn from_response(mut r: Response) -> Self {
178        let body: Vec<u8> = r.content_range_list.iter()
179            .flat_map(|cr| cr.body.iter().copied())
180            .collect();
181
182        // Mirror Response::generate_response, which only adds these headers
183        // at HTTP/1.1 write time — TestClient bypasses that path entirely.
184        if r.content_range_list.len() == 1 {
185            let content_range = r.content_range_list.get(0).unwrap();
186            r.headers.push(Header {
187                name: Header::_CONTENT_TYPE.into(),
188                value: content_range.content_type.to_string().into(),
189            });
190            if let Some(value) = crate::response::content_range_header_value(r.status_code, content_range) {
191                r.headers.push(Header {
192                    name: Header::_CONTENT_RANGE.into(),
193                    value: value.into(),
194                });
195            }
196            r.headers.push(Header {
197                name: Header::_CONTENT_LENGTH.into(),
198                value: content_range.body.len().to_string().into(),
199            });
200        }
201
202        TestResponse {
203            status: r.status_code,
204            reason: r.reason_phrase,
205            headers: r.headers,
206            body,
207        }
208    }
209
210    /// HTTP status code, e.g. `200`.
211    pub fn status(&self) -> i16 {
212        self.status
213    }
214
215    /// HTTP reason phrase, e.g. `"OK"`.
216    pub fn reason(&self) -> &str {
217        &self.reason
218    }
219
220    /// Return the value of the first header matching `name` (case-insensitive).
221    pub fn header(&self, name: &str) -> Option<&str> {
222        let lower = name.to_lowercase();
223        self.headers
224            .iter()
225            .find(|h| h.name.to_lowercase() == lower)
226            .map(|h| h.value.as_ref())
227    }
228
229    /// All response headers.
230    pub fn headers(&self) -> &[Header] {
231        &self.headers
232    }
233
234    /// Raw response body bytes.
235    pub fn body_bytes(&self) -> &[u8] {
236        &self.body
237    }
238
239    /// Response body decoded as UTF-8. Panics if the body is not valid UTF-8.
240    pub fn body_text(&self) -> &str {
241        std::str::from_utf8(&self.body).expect("response body is not valid UTF-8")
242    }
243
244    /// `true` if the status code is 2xx.
245    pub fn is_success(&self) -> bool {
246        (200..300).contains(&self.status)
247    }
248}