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