Skip to main content

philiprehberger_http_test/
lib.rs

1//! Declarative HTTP API integration testing framework with fluent assertions.
2//!
3//! This crate provides a builder-based API for constructing HTTP requests and
4//! asserting properties of the responses. It is designed for integration tests
5//! against real or mock HTTP servers.
6//!
7//! # Quick start
8//!
9//! ```no_run
10//! use philiprehberger_http_test::get;
11//!
12//! let response = get("https://httpbin.org/get").send().unwrap();
13//! response.assert_ok();
14//! ```
15
16use std::fmt;
17use std::time::Duration;
18
19/// Error type for HTTP test operations.
20#[derive(Debug)]
21pub enum HttpTestError {
22    /// The HTTP request failed to execute.
23    RequestFailed(String),
24    /// An assertion on the response did not hold.
25    AssertionFailed {
26        /// The expected value.
27        expected: String,
28        /// The actual value.
29        actual: String,
30        /// Additional context describing the assertion.
31        context: String,
32    },
33    /// A JSON path could not be resolved.
34    JsonPathError(String),
35    /// A connection to the server could not be established.
36    ConnectionError(String),
37}
38
39impl fmt::Display for HttpTestError {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            HttpTestError::RequestFailed(msg) => write!(f, "request failed: {msg}"),
43            HttpTestError::AssertionFailed {
44                expected,
45                actual,
46                context,
47            } => write!(
48                f,
49                "assertion failed ({context}): expected {expected}, got {actual}"
50            ),
51            HttpTestError::JsonPathError(msg) => write!(f, "JSON path error: {msg}"),
52            HttpTestError::ConnectionError(msg) => write!(f, "connection error: {msg}"),
53        }
54    }
55}
56
57impl std::error::Error for HttpTestError {}
58
59/// A builder for constructing an HTTP test request.
60pub struct TestRequest {
61    method: String,
62    url: String,
63    headers: Vec<(String, String)>,
64    body: Option<String>,
65    query: Vec<(String, String)>,
66    timeout: Option<Duration>,
67}
68
69/// Create a GET request to the given URL.
70pub fn get(url: &str) -> TestRequest {
71    TestRequest::new("GET", url)
72}
73
74/// Create a POST request to the given URL.
75pub fn post(url: &str) -> TestRequest {
76    TestRequest::new("POST", url)
77}
78
79/// Create a PUT request to the given URL.
80pub fn put(url: &str) -> TestRequest {
81    TestRequest::new("PUT", url)
82}
83
84/// Create a DELETE request to the given URL.
85pub fn delete(url: &str) -> TestRequest {
86    TestRequest::new("DELETE", url)
87}
88
89/// Create a PATCH request to the given URL.
90pub fn patch(url: &str) -> TestRequest {
91    TestRequest::new("PATCH", url)
92}
93
94impl TestRequest {
95    fn new(method: &str, url: &str) -> Self {
96        Self {
97            method: method.to_string(),
98            url: url.to_string(),
99            headers: Vec::new(),
100            body: None,
101            query: Vec::new(),
102            timeout: None,
103        }
104    }
105
106    /// Add a header to the request.
107    pub fn header(mut self, key: &str, value: &str) -> Self {
108        self.headers.push((key.to_string(), value.to_string()));
109        self
110    }
111
112    /// Set the `Authorization: Bearer <token>` header.
113    pub fn bearer_token(self, token: &str) -> Self {
114        self.header("Authorization", &format!("Bearer {token}"))
115    }
116
117    /// Set the `Authorization: Basic <credentials>` header using base64-encoded `user:pass`.
118    pub fn basic_auth(self, user: &str, pass: &str) -> Self {
119        use std::io::Write;
120        let mut buf = Vec::new();
121        write!(buf, "{user}:{pass}").unwrap();
122        let encoded = base64_encode(&buf);
123        self.header("Authorization", &format!("Basic {encoded}"))
124    }
125
126    /// Add a query parameter to the request URL.
127    pub fn query(mut self, key: &str, value: &str) -> Self {
128        self.query.push((key.to_string(), value.to_string()));
129        self
130    }
131
132    /// Set a JSON body and the `Content-Type: application/json` header.
133    pub fn json_body(mut self, value: &serde_json::Value) -> Self {
134        self.body = Some(value.to_string());
135        self.headers
136            .push(("Content-Type".to_string(), "application/json".to_string()));
137        self
138    }
139
140    /// Set the raw request body.
141    pub fn body(mut self, body: impl Into<String>) -> Self {
142        self.body = Some(body.into());
143        self
144    }
145
146    /// Set the request timeout.
147    pub fn timeout(mut self, duration: Duration) -> Self {
148        self.timeout = Some(duration);
149        self
150    }
151
152    /// Execute the request and return the response.
153    pub fn send(&self) -> Result<TestResponse, HttpTestError> {
154        let mut url = self.url.clone();
155
156        if !self.query.is_empty() {
157            let sep = if url.contains('?') { '&' } else { '?' };
158            let params: Vec<String> = self
159                .query
160                .iter()
161                .map(|(k, v)| format!("{k}={v}"))
162                .collect();
163            url = format!("{url}{sep}{}", params.join("&"));
164        }
165
166        let client_builder = reqwest::blocking::ClientBuilder::new();
167        let client_builder = if let Some(t) = self.timeout {
168            client_builder.timeout(t)
169        } else {
170            client_builder
171        };
172
173        let client = client_builder
174            .build()
175            .map_err(|e| HttpTestError::ConnectionError(e.to_string()))?;
176
177        let mut request = match self.method.as_str() {
178            "GET" => client.get(&url),
179            "POST" => client.post(&url),
180            "PUT" => client.put(&url),
181            "DELETE" => client.delete(&url),
182            "PATCH" => client.patch(&url),
183            other => {
184                return Err(HttpTestError::RequestFailed(format!(
185                    "unsupported method: {other}"
186                )))
187            }
188        };
189
190        for (key, value) in &self.headers {
191            request = request.header(key, value);
192        }
193
194        if let Some(body) = &self.body {
195            request = request.body(body.clone());
196        }
197
198        let response = request
199            .send()
200            .map_err(|e| HttpTestError::ConnectionError(e.to_string()))?;
201
202        let status = response.status().as_u16();
203        let headers: Vec<(String, String)> = response
204            .headers()
205            .iter()
206            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
207            .collect();
208        let body = response
209            .text()
210            .map_err(|e| HttpTestError::RequestFailed(e.to_string()))?;
211
212        Ok(TestResponse {
213            status,
214            headers,
215            body,
216        })
217    }
218}
219
220/// The response from an HTTP test request, with assertion methods for validation.
221pub struct TestResponse {
222    /// The HTTP status code.
223    pub status: u16,
224    /// The response headers as key-value pairs.
225    pub headers: Vec<(String, String)>,
226    /// The response body as a string.
227    pub body: String,
228}
229
230impl TestResponse {
231    /// Assert that the status code matches the expected value.
232    pub fn assert_status(&self, expected: u16) -> &Self {
233        assert_eq!(
234            self.status, expected,
235            "expected status {expected}, got {}",
236            self.status
237        );
238        self
239    }
240
241    /// Assert that the status code is in the 2xx range.
242    pub fn assert_ok(&self) -> &Self {
243        assert!(
244            (200..300).contains(&self.status),
245            "expected 2xx status, got {}",
246            self.status
247        );
248        self
249    }
250
251    /// Assert that the status code is in the 3xx range.
252    pub fn assert_redirect(&self) -> &Self {
253        assert!(
254            (300..400).contains(&self.status),
255            "expected 3xx status, got {}",
256            self.status
257        );
258        self
259    }
260
261    /// Assert that the status code is in the 4xx range.
262    pub fn assert_client_error(&self) -> &Self {
263        assert!(
264            (400..500).contains(&self.status),
265            "expected 4xx status, got {}",
266            self.status
267        );
268        self
269    }
270
271    /// Assert that the status code is in the 5xx range.
272    pub fn assert_server_error(&self) -> &Self {
273        assert!(
274            (500..600).contains(&self.status),
275            "expected 5xx status, got {}",
276            self.status
277        );
278        self
279    }
280
281    /// Assert that a response header has the expected value (case-insensitive key match).
282    pub fn assert_header(&self, key: &str, value: &str) -> &Self {
283        let lower_key = key.to_lowercase();
284        let found = self
285            .headers
286            .iter()
287            .find(|(k, _)| k.to_lowercase() == lower_key);
288        match found {
289            Some((_, v)) => assert_eq!(
290                v, value,
291                "header '{key}': expected '{value}', got '{v}'"
292            ),
293            None => panic!("header '{key}' not found in response"),
294        }
295        self
296    }
297
298    /// Assert that a response header exists (case-insensitive key match).
299    pub fn assert_header_exists(&self, key: &str) -> &Self {
300        let lower_key = key.to_lowercase();
301        assert!(
302            self.headers.iter().any(|(k, _)| k.to_lowercase() == lower_key),
303            "expected header '{key}' to exist"
304        );
305        self
306    }
307
308    /// Assert that the response body contains the given substring.
309    pub fn assert_body_contains(&self, substring: &str) -> &Self {
310        assert!(
311            self.body.contains(substring),
312            "expected body to contain '{substring}', body was: {}",
313            truncate_for_display(&self.body, 200)
314        );
315        self
316    }
317
318    /// Assert that the response body equals the expected string exactly.
319    pub fn assert_body_equals(&self, expected: &str) -> &Self {
320        assert_eq!(
321            self.body, expected,
322            "expected body to equal '{expected}', got: {}",
323            truncate_for_display(&self.body, 200)
324        );
325        self
326    }
327
328    /// Assert that a value at the given JSON path matches the expected value.
329    ///
330    /// Supports dot-separated keys and array indices:
331    /// - `"name"` — top-level field
332    /// - `"data.count"` — nested field
333    /// - `"users[0].name"` — array index then field
334    pub fn assert_json_path(&self, path: &str, expected: &serde_json::Value) -> &Self {
335        let json: serde_json::Value = serde_json::from_str(&self.body).unwrap_or_else(|e| {
336            panic!("failed to parse response body as JSON: {e}");
337        });
338
339        let actual = resolve_json_path(&json, path);
340
341        match actual {
342            Some(val) => assert_eq!(
343                val, expected,
344                "JSON path '{path}': expected {expected}, got {val}"
345            ),
346            None => panic!("JSON path '{path}' not found in response body"),
347        }
348        self
349    }
350
351    /// Parse the response body as JSON.
352    pub fn json(&self) -> Result<serde_json::Value, HttpTestError> {
353        serde_json::from_str(&self.body)
354            .map_err(|e| HttpTestError::JsonPathError(format!("failed to parse JSON: {e}")))
355    }
356}
357
358/// Resolve a simple JSON path expression against a JSON value.
359///
360/// Supports dot-separated keys with optional array indices, e.g.:
361/// - `"name"` -> `value["name"]`
362/// - `"data.count"` -> `value["data"]["count"]`
363/// - `"users[0].name"` -> `value["users"][0]["name"]`
364fn resolve_json_path<'a>(
365    value: &'a serde_json::Value,
366    path: &str,
367) -> Option<&'a serde_json::Value> {
368    let segments = parse_path_segments(path);
369    let mut current = value;
370
371    for segment in &segments {
372        match segment {
373            PathSegment::Key(key) => {
374                current = current.get(key.as_str())?;
375            }
376            PathSegment::Index(idx) => {
377                current = current.get(*idx)?;
378            }
379        }
380    }
381
382    Some(current)
383}
384
385#[derive(Debug)]
386enum PathSegment {
387    Key(String),
388    Index(usize),
389}
390
391/// Parse a JSON path string like `"users[0].name"` into segments.
392fn parse_path_segments(path: &str) -> Vec<PathSegment> {
393    let mut segments = Vec::new();
394
395    for part in path.split('.') {
396        if part.is_empty() {
397            continue;
398        }
399        if let Some(bracket_pos) = part.find('[') {
400            let key = &part[..bracket_pos];
401            if !key.is_empty() {
402                segments.push(PathSegment::Key(key.to_string()));
403            }
404            // Parse all bracket indices, e.g. "[0][1]"
405            let rest = &part[bracket_pos..];
406            let mut remaining = rest;
407            while let Some(start) = remaining.find('[') {
408                if let Some(end) = remaining.find(']') {
409                    if let Ok(idx) = remaining[start + 1..end].parse::<usize>() {
410                        segments.push(PathSegment::Index(idx));
411                    }
412                    remaining = &remaining[end + 1..];
413                } else {
414                    break;
415                }
416            }
417        } else {
418            segments.push(PathSegment::Key(part.to_string()));
419        }
420    }
421
422    segments
423}
424
425/// Simple base64 encoding (no external dependency needed for this).
426fn base64_encode(input: &[u8]) -> String {
427    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
428    let mut result = String::new();
429    let mut i = 0;
430    while i < input.len() {
431        let b0 = input[i] as u32;
432        let b1 = if i + 1 < input.len() { input[i + 1] as u32 } else { 0 };
433        let b2 = if i + 2 < input.len() { input[i + 2] as u32 } else { 0 };
434        let triple = (b0 << 16) | (b1 << 8) | b2;
435        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
436        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
437        if i + 1 < input.len() {
438            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
439        } else {
440            result.push('=');
441        }
442        if i + 2 < input.len() {
443            result.push(CHARS[(triple & 0x3F) as usize] as char);
444        } else {
445            result.push('=');
446        }
447        i += 3;
448    }
449    result
450}
451
452fn truncate_for_display(s: &str, max_len: usize) -> &str {
453    if s.len() <= max_len {
454        s
455    } else {
456        &s[..max_len]
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use serde_json::json;
464    use std::io::{BufRead, BufReader, Read, Write};
465    use std::net::TcpListener;
466    use std::thread::{self, JoinHandle};
467
468    /// Start a minimal HTTP test server that serves a canned response.
469    /// Returns the base URL and a join handle for the server thread.
470    fn start_test_server(
471        status: u16,
472        body: &str,
473        headers: Vec<(&str, &str)>,
474    ) -> (String, JoinHandle<()>) {
475        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
476        let port = listener.local_addr().unwrap().port();
477        let url = format!("http://127.0.0.1:{port}");
478        let body = body.to_string();
479        let headers: Vec<(String, String)> = headers
480            .into_iter()
481            .map(|(k, v)| (k.to_string(), v.to_string()))
482            .collect();
483
484        let handle = thread::spawn(move || {
485            let (mut stream, _) = listener.accept().unwrap();
486            let mut reader = BufReader::new(stream.try_clone().unwrap());
487
488            // Read the request line and headers
489            let mut request_lines = Vec::new();
490            loop {
491                let mut line = String::new();
492                reader.read_line(&mut line).unwrap();
493                if line.trim().is_empty() {
494                    break;
495                }
496                request_lines.push(line);
497            }
498
499            // Read body if Content-Length is present
500            let content_length: usize = request_lines
501                .iter()
502                .find(|l| l.to_lowercase().starts_with("content-length:"))
503                .and_then(|l| l.split(':').nth(1))
504                .and_then(|v| v.trim().parse().ok())
505                .unwrap_or(0);
506
507            if content_length > 0 {
508                let mut body_buf = vec![0u8; content_length];
509                reader.read_exact(&mut body_buf).ok();
510            }
511
512            let status_text = match status {
513                200 => "OK",
514                201 => "Created",
515                301 => "Moved Permanently",
516                302 => "Found",
517                400 => "Bad Request",
518                401 => "Unauthorized",
519                404 => "Not Found",
520                500 => "Internal Server Error",
521                _ => "Unknown",
522            };
523
524            let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
525            response.push_str(&format!("Content-Length: {}\r\n", body.len()));
526            for (k, v) in &headers {
527                response.push_str(&format!("{k}: {v}\r\n"));
528            }
529            response.push_str("\r\n");
530            response.push_str(&body);
531
532            stream.write_all(response.as_bytes()).unwrap();
533            stream.flush().unwrap();
534        });
535
536        (url, handle)
537    }
538
539    #[test]
540    fn test_get_assert_status() {
541        let (url, handle) = start_test_server(200, "hello", vec![]);
542        let response = get(&url).send().unwrap();
543        response.assert_status(200);
544        handle.join().unwrap();
545    }
546
547    #[test]
548    fn test_post_with_json_body() {
549        let (url, handle) = start_test_server(201, r#"{"id":1}"#, vec![("Content-Type", "application/json")]);
550        let response = post(&url)
551            .json_body(&json!({"name": "Alice"}))
552            .send()
553            .unwrap();
554        response.assert_status(201);
555        handle.join().unwrap();
556    }
557
558    #[test]
559    fn test_assert_ok() {
560        let (url, handle) = start_test_server(200, "ok", vec![]);
561        let response = get(&url).send().unwrap();
562        response.assert_ok();
563        handle.join().unwrap();
564    }
565
566    #[test]
567    fn test_assert_client_error() {
568        let (url, handle) = start_test_server(404, "not found", vec![]);
569        let response = get(&url).send().unwrap();
570        response.assert_client_error();
571        handle.join().unwrap();
572    }
573
574    #[test]
575    fn test_assert_header() {
576        let (url, handle) = start_test_server(200, "ok", vec![("X-Custom", "test-value")]);
577        let response = get(&url).send().unwrap();
578        response
579            .assert_status(200)
580            .assert_header("X-Custom", "test-value");
581        handle.join().unwrap();
582    }
583
584    #[test]
585    fn test_assert_header_exists() {
586        let (url, handle) = start_test_server(200, "ok", vec![("X-Request-Id", "abc123")]);
587        let response = get(&url).send().unwrap();
588        response.assert_header_exists("X-Request-Id");
589        handle.join().unwrap();
590    }
591
592    #[test]
593    fn test_assert_body_contains() {
594        let (url, handle) = start_test_server(200, "hello world", vec![]);
595        let response = get(&url).send().unwrap();
596        response.assert_body_contains("world");
597        handle.join().unwrap();
598    }
599
600    #[test]
601    fn test_assert_body_equals() {
602        let (url, handle) = start_test_server(200, "exact match", vec![]);
603        let response = get(&url).send().unwrap();
604        response.assert_body_equals("exact match");
605        handle.join().unwrap();
606    }
607
608    #[test]
609    fn test_assert_json_path_nested() {
610        let body = r#"{"data":{"users":[{"name":"Alice"},{"name":"Bob"}]}}"#;
611        let (url, handle) = start_test_server(200, body, vec![("Content-Type", "application/json")]);
612        let response = get(&url).send().unwrap();
613        response
614            .assert_json_path("data.users[0].name", &json!("Alice"))
615            .assert_json_path("data.users[1].name", &json!("Bob"));
616        handle.join().unwrap();
617    }
618
619    #[test]
620    fn test_assert_json_path_simple() {
621        let body = r#"{"count":42,"active":true}"#;
622        let (url, handle) = start_test_server(200, body, vec![]);
623        let response = get(&url).send().unwrap();
624        response
625            .assert_json_path("count", &json!(42))
626            .assert_json_path("active", &json!(true));
627        handle.join().unwrap();
628    }
629
630    #[test]
631    fn test_bearer_token() {
632        // We can verify the token is set by checking the request reaches the server.
633        // The test server doesn't validate the token, but we verify the builder works.
634        let (url, handle) = start_test_server(200, "ok", vec![]);
635        let response = get(&url).bearer_token("my-secret-token").send().unwrap();
636        response.assert_ok();
637        handle.join().unwrap();
638    }
639
640    #[test]
641    fn test_basic_auth() {
642        let (url, handle) = start_test_server(200, "ok", vec![]);
643        let response = get(&url).basic_auth("admin", "password").send().unwrap();
644        response.assert_ok();
645        handle.join().unwrap();
646    }
647
648    #[test]
649    fn test_query_params() {
650        let (url, handle) = start_test_server(200, "ok", vec![]);
651        let response = get(&url)
652            .query("page", "1")
653            .query("limit", "10")
654            .send()
655            .unwrap();
656        response.assert_ok();
657        handle.join().unwrap();
658    }
659
660    #[test]
661    fn test_put_request() {
662        let (url, handle) = start_test_server(200, "updated", vec![]);
663        let response = put(&url).body("data").send().unwrap();
664        response.assert_ok();
665        handle.join().unwrap();
666    }
667
668    #[test]
669    fn test_delete_request() {
670        let (url, handle) = start_test_server(200, "", vec![]);
671        let response = delete(&url).send().unwrap();
672        response.assert_ok();
673        handle.join().unwrap();
674    }
675
676    #[test]
677    fn test_patch_request() {
678        let (url, handle) = start_test_server(200, "patched", vec![]);
679        let response = patch(&url).body("partial").send().unwrap();
680        response.assert_ok();
681        handle.join().unwrap();
682    }
683
684    #[test]
685    fn test_json_parsing() {
686        let body = r#"{"key":"value"}"#;
687        let (url, handle) = start_test_server(200, body, vec![]);
688        let response = get(&url).send().unwrap();
689        let json = response.json().unwrap();
690        assert_eq!(json["key"], "value");
691        handle.join().unwrap();
692    }
693
694    #[test]
695    fn test_assert_redirect() {
696        let response = TestResponse {
697            status: 302,
698            headers: vec![("location".to_string(), "https://example.com".to_string())],
699            body: String::new(),
700        };
701        response.assert_redirect();
702    }
703
704    #[test]
705    fn test_assert_server_error() {
706        let (url, handle) = start_test_server(500, "error", vec![]);
707        let response = get(&url).send().unwrap();
708        response.assert_server_error();
709        handle.join().unwrap();
710    }
711
712    #[test]
713    fn test_chained_assertions() {
714        let body = r#"{"status":"ok","count":5}"#;
715        let (url, handle) = start_test_server(200, body, vec![("X-Api", "v1")]);
716        let response = get(&url).send().unwrap();
717        response
718            .assert_ok()
719            .assert_status(200)
720            .assert_header("X-Api", "v1")
721            .assert_body_contains("ok")
722            .assert_json_path("count", &json!(5));
723        handle.join().unwrap();
724    }
725
726    #[test]
727    #[should_panic(expected = "expected status 201")]
728    fn test_assert_status_fails() {
729        let response = TestResponse {
730            status: 200,
731            headers: vec![],
732            body: String::new(),
733        };
734        response.assert_status(201);
735    }
736
737    #[test]
738    #[should_panic(expected = "expected 2xx status")]
739    fn test_assert_ok_fails() {
740        let response = TestResponse {
741            status: 404,
742            headers: vec![],
743            body: String::new(),
744        };
745        response.assert_ok();
746    }
747
748    #[test]
749    fn test_base64_encode() {
750        assert_eq!(base64_encode(b"admin:password"), "YWRtaW46cGFzc3dvcmQ=");
751        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
752        assert_eq!(base64_encode(b""), "");
753    }
754
755    #[test]
756    fn test_resolve_json_path() {
757        let data = json!({
758            "a": {
759                "b": [1, 2, 3],
760                "c": "hello"
761            }
762        });
763        assert_eq!(resolve_json_path(&data, "a.c"), Some(&json!("hello")));
764        assert_eq!(resolve_json_path(&data, "a.b[1]"), Some(&json!(2)));
765        assert_eq!(resolve_json_path(&data, "a.missing"), None);
766    }
767
768    #[test]
769    fn test_error_display() {
770        let err = HttpTestError::RequestFailed("timeout".to_string());
771        assert_eq!(format!("{err}"), "request failed: timeout");
772
773        let err = HttpTestError::ConnectionError("refused".to_string());
774        assert_eq!(format!("{err}"), "connection error: refused");
775
776        let err = HttpTestError::JsonPathError("invalid".to_string());
777        assert_eq!(format!("{err}"), "JSON path error: invalid");
778
779        let err = HttpTestError::AssertionFailed {
780            expected: "200".to_string(),
781            actual: "404".to_string(),
782            context: "status".to_string(),
783        };
784        assert_eq!(
785            format!("{err}"),
786            "assertion failed (status): expected 200, got 404"
787        );
788    }
789}