Skip to main content

rws_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/// # extern crate rws_server as rust_web_server;
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                sni_hostname: None,
54                tls: false,
55            },
56        }
57    }
58
59    /// Build a `GET` request to `path`.
60    pub fn get(&self, path: &str) -> TestRequest<'_, A> {
61        TestRequest::new(METHOD.get.to_string(), path, self)
62    }
63
64    /// Build a `POST` request to `path`.
65    pub fn post(&self, path: &str) -> TestRequest<'_, A> {
66        TestRequest::new(METHOD.post.to_string(), path, self)
67    }
68
69    /// Build a `PUT` request to `path`.
70    pub fn put(&self, path: &str) -> TestRequest<'_, A> {
71        TestRequest::new(METHOD.put.to_string(), path, self)
72    }
73
74    /// Build a `PATCH` request to `path`.
75    pub fn patch(&self, path: &str) -> TestRequest<'_, A> {
76        TestRequest::new(METHOD.patch.to_string(), path, self)
77    }
78
79    /// Build a `DELETE` request to `path`.
80    pub fn delete(&self, path: &str) -> TestRequest<'_, A> {
81        TestRequest::new(METHOD.delete.to_string(), path, self)
82    }
83
84    /// Build an `OPTIONS` request to `path`.
85    pub fn options(&self, path: &str) -> TestRequest<'_, A> {
86        TestRequest::new(METHOD.options.to_string(), path, self)
87    }
88}
89
90/// A pending test request. Chain builder methods then call [`TestRequest::send`].
91pub struct TestRequest<'a, A: Application> {
92    method: String,
93    path: String,
94    headers: Vec<Header>,
95    body: Vec<u8>,
96    client: &'a TestClient<A>,
97}
98
99impl<'a, A: Application> TestRequest<'a, A> {
100    fn new(method: String, path: &str, client: &'a TestClient<A>) -> Self {
101        TestRequest {
102            method,
103            path: path.to_string(),
104            headers: vec![],
105            body: Default::default(),
106            client,
107        }
108    }
109
110    /// Add a request header.
111    pub fn header(mut self, name: &str, value: &str) -> Self {
112        self.headers.push(Header { name: name.to_string().into(), value: value.to_string().into() });
113        self
114    }
115
116    /// Set the request body to raw bytes.
117    pub fn body_bytes(mut self, body: Vec<u8>) -> Self {
118        self.body = body;
119        self
120    }
121
122    /// Set the request body to a UTF-8 string.
123    pub fn body_text(mut self, text: &str) -> Self {
124        self.body = text.as_bytes().to_vec();
125        self
126    }
127
128    /// Set the request body to a JSON string, with
129    /// `Content-Type: application/json`.
130    pub fn body_json(mut self, json: &str) -> Self {
131        self.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: "application/json".into() });
132        self.body = json.as_bytes().to_vec();
133        self
134    }
135
136    /// Dispatch the request and return the response.
137    pub fn send(self) -> TestResponse {
138        let request = Request {
139            method: self.method,
140            request_uri: self.path,
141            http_version: VERSION.http_1_1.to_string(),
142            headers: self.headers,
143            body: self.body.into(),
144        };
145
146        let response = self.client.app.execute(&request, &self.client.connection)
147            .unwrap_or_else(|msg| {
148                let dummy = Request {
149                    method: "GET".to_string(),
150                    request_uri: "/".to_string(),
151                    http_version: VERSION.http_1_1.to_string(),
152                    headers: vec![],
153                    body: Default::default(),
154                };
155                let header_list = Header::get_header_list(&dummy);
156                let body = msg.into_bytes();
157                let cr = Range::get_content_range(body, MimeType::TEXT_PLAIN.to_string());
158                Response::get_response(
159                    STATUS_CODE_REASON_PHRASE.n500_internal_server_error,
160                    Some(header_list),
161                    Some(vec![cr]),
162                )
163            });
164
165        TestResponse::from_response(response)
166    }
167}
168
169/// The result of a dispatched test request.
170pub struct TestResponse {
171    status: i16,
172    reason: String,
173    headers: Vec<Header>,
174    body: Vec<u8>,
175}
176
177impl TestResponse {
178    fn from_response(mut r: Response) -> Self {
179        let body: Vec<u8> = r.content_range_list.iter()
180            .flat_map(|cr| cr.body.iter().copied())
181            .collect();
182
183        // Mirror Response::generate_response, which only adds these headers
184        // at HTTP/1.1 write time — TestClient bypasses that path entirely.
185        if r.content_range_list.len() == 1 {
186            let content_range = r.content_range_list.get(0).unwrap();
187            r.headers.push(Header {
188                name: Header::_CONTENT_TYPE.into(),
189                value: content_range.content_type.to_string().into(),
190            });
191            if let Some(value) = crate::response::content_range_header_value(r.status_code, content_range) {
192                r.headers.push(Header {
193                    name: Header::_CONTENT_RANGE.into(),
194                    value: value.into(),
195                });
196            }
197            r.headers.push(Header {
198                name: Header::_CONTENT_LENGTH.into(),
199                value: content_range.body.len().to_string().into(),
200            });
201        }
202
203        TestResponse {
204            status: r.status_code,
205            reason: r.reason_phrase,
206            headers: r.headers,
207            body,
208        }
209    }
210
211    /// HTTP status code, e.g. `200`.
212    pub fn status(&self) -> i16 {
213        self.status
214    }
215
216    /// HTTP reason phrase, e.g. `"OK"`.
217    pub fn reason(&self) -> &str {
218        &self.reason
219    }
220
221    /// Return the value of the first header matching `name` (case-insensitive).
222    pub fn header(&self, name: &str) -> Option<&str> {
223        let lower = name.to_lowercase();
224        self.headers
225            .iter()
226            .find(|h| h.name.to_lowercase() == lower)
227            .map(|h| h.value.as_ref())
228    }
229
230    /// All response headers.
231    pub fn headers(&self) -> &[Header] {
232        &self.headers
233    }
234
235    /// Raw response body bytes.
236    pub fn body_bytes(&self) -> &[u8] {
237        &self.body
238    }
239
240    /// Response body decoded as UTF-8. Panics if the body is not valid UTF-8.
241    pub fn body_text(&self) -> &str {
242        std::str::from_utf8(&self.body).expect("response body is not valid UTF-8")
243    }
244
245    /// `true` if the status code is 2xx.
246    pub fn is_success(&self) -> bool {
247        (200..300).contains(&self.status)
248    }
249}